From 5486a22922045ab423565497274cf5c01b063e1a Mon Sep 17 00:00:00 2001 From: rbondesson Date: Tue, 26 May 2026 08:36:05 +0200 Subject: [PATCH] Refactor EventTile using the MVVM pattern - #5b (#33600) * Extract EventTile E2E padlock state * Refactor EventTile E2E padlock rendering after review comments --- .../src/components/views/rooms/EventTile.tsx | 164 ++--------- .../EventTile/E2eStandardPadlockIcon.tsx | 27 ++ .../timeline/event-tile/EventTileE2eState.ts | 211 ++++++++++++++ .../event-tiles/EventTileE2eState-test.ts | 267 ++++++++++++++++++ 4 files changed, 528 insertions(+), 141 deletions(-) create mode 100644 apps/web/src/components/views/rooms/EventTile/E2eStandardPadlockIcon.tsx create mode 100644 apps/web/src/viewmodels/room/timeline/event-tile/EventTileE2eState.ts create mode 100644 apps/web/test/viewmodels/event-tiles/EventTileE2eState-test.ts diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index cec0ac9cc2..a3438420c8 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -22,7 +22,7 @@ import React, { } from "react"; import classNames from "classnames"; import { - EventStatus, + type EventStatus, EventType, type MatrixEvent, MatrixEventEvent, @@ -38,9 +38,8 @@ import { logger } from "matrix-js-sdk/src/logger"; import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call"; import { CryptoEvent, - DecryptionFailureCode, EventShieldColour, - EventShieldReason, + type EventShieldReason, type UserVerificationStatus, } from "matrix-js-sdk/src/crypto-api"; import { Tooltip } from "@vector-im/compound-web"; @@ -49,8 +48,6 @@ import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-de import { useCreateAutoDisposedViewModel, ActionBarView, - E2ePadlock, - E2ePadlockIcon, MessageTimestampView, PinnedMessageBadge, ReactionsRowButtonView, @@ -98,7 +95,8 @@ import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper"; import { Icon as LateIcon } from "../../../../res/img/sensor.svg"; import PinningUtils from "../../../utils/PinningUtils"; import { EventPreview } from "./EventPreview"; -import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon.tsx"; +import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon"; +import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon"; import SettingsStore from "../../../settings/SettingsStore"; import { CardContext } from "../right_panel/context"; import { EventTileViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; @@ -144,6 +142,7 @@ import { ThreadListActionBarViewModel } from "../../../viewmodels/room/ThreadLis import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useSettingValue } from "../../../hooks/useSettings"; import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory"; +import { getEventTileE2ePadlockViewState } from "../../../viewmodels/room/timeline/event-tile/EventTileE2eState"; /** Relation lookup type retained for EventTile consumers. */ export type { GetRelationsForEvent } from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState"; @@ -728,120 +727,29 @@ export class UnwrappedEventTile extends React.Component private renderE2EPadlock(): ReactNode { // if the event was edited, show the verification info for the edit, not // the original - const ev = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent; + const verificationEvent = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent; + const e2ePadlockViewState = getEventTileE2ePadlockViewState({ + mxEvent: this.props.mxEvent, + verificationEvent, + shieldColour: this.state.shieldColour, + shieldReason: this.state.shieldReason, + isRoomEncrypted: this.context.isRoomEncrypted, + isLocalRoom: isLocalRoom(verificationEvent.getRoomId()!), + }); - // no icon for local rooms - if (isLocalRoom(ev.getRoomId()!)) return null; - - // event could not be decrypted - if (ev.isDecryptionFailure()) { - switch (ev.decryptionFailureReason) { - // These two errors get icons from DecryptionFailureBody, so we hide the padlock icon - case DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED: - case DecryptionFailureCode.UNSIGNED_SENDER_DEVICE: - return null; - default: - return ; - } - } - - if (this.state.shieldReason === EventShieldReason.AUTHENTICITY_NOT_GUARANTEED) { - // This may happen if the message was forwarded to us by another user, in which case we can show a better message - const forwarder = this.props.mxEvent.getKeyForwardingUser(); - if (forwarder) { - return ; - } - } - - if (this.state.shieldColour !== EventShieldColour.NONE) { - let shieldReasonMessage: string; - switch (this.state.shieldReason) { - case EventShieldReason.UNVERIFIED_IDENTITY: - shieldReasonMessage = _t("encryption|event_shield_reason_unverified_identity"); - break; - - case EventShieldReason.UNSIGNED_DEVICE: - shieldReasonMessage = _t("encryption|event_shield_reason_unsigned_device"); - break; - - case EventShieldReason.UNKNOWN_DEVICE: - shieldReasonMessage = _t("encryption|event_shield_reason_unknown_device"); - break; - - case EventShieldReason.AUTHENTICITY_NOT_GUARANTEED: - shieldReasonMessage = _t("encryption|event_shield_reason_authenticity_not_guaranteed"); - break; - - case EventShieldReason.MISMATCHED_SENDER_KEY: - shieldReasonMessage = _t("encryption|event_shield_reason_mismatched_sender_key"); - break; - - case EventShieldReason.SENT_IN_CLEAR: - shieldReasonMessage = _t("common|unencrypted"); - break; - - case EventShieldReason.VERIFICATION_VIOLATION: - shieldReasonMessage = _t("timeline|decryption_failure|sender_identity_previously_verified"); - break; - - case EventShieldReason.MISMATCHED_SENDER: - shieldReasonMessage = _t("encryption|event_shield_reason_mismatched_sender"); - break; - - default: - shieldReasonMessage = _t("error|unknown"); - break; - } - - if (this.state.shieldColour === EventShieldColour.GREY) { + switch (e2ePadlockViewState.kind) { + case "none": + return null; + case "messageShared": return ( - ); - } else { - // red, by elimination - return ( - - ); - } + case "icon": + return ; } - - if (this.context.isRoomEncrypted) { - // else if room is encrypted - // and event is being encrypted or is not_sent (Unknown Devices/Network Error) - if (ev.status === EventStatus.ENCRYPTING) { - return null; - } - if (ev.status === EventStatus.NOT_SENT) { - return null; - } - if (ev.isState()) { - return null; // we expect this to be unencrypted - } - if (ev.isRedacted()) { - return null; // we expect this to be unencrypted - } - if (!ev.isEncrypted()) { - // if the event is not encrypted, but it's an e2e room, show a warning - return ; - } - } - - // no padlock needed - return null; } private readonly onActionBarFocusChange = (actionBarFocused: boolean): void => { @@ -1585,32 +1493,6 @@ const SafeEventTile = (props: EventTileProps): JSX.Element => { }; export default SafeEventTile; -function E2ePadlockUnencrypted(): JSX.Element { - return ( - - ); -} - -function E2ePadlockDecryptionFailure(): JSX.Element { - return ( - - ); -} - interface ISentReceiptProps { messageState: EventStatus | undefined; } diff --git a/apps/web/src/components/views/rooms/EventTile/E2eStandardPadlockIcon.tsx b/apps/web/src/components/views/rooms/EventTile/E2eStandardPadlockIcon.tsx new file mode 100644 index 0000000000..ade790e2db --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/E2eStandardPadlockIcon.tsx @@ -0,0 +1,27 @@ +/* +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 React, { type JSX } from "react"; +import { E2ePadlock, type E2ePadlockIcon } from "@element-hq/web-shared-components"; + +interface E2eStandardPadlockIconProps { + icon: E2ePadlockIcon; + title: string; +} + +export function E2eStandardPadlockIcon({ icon, title }: Readonly): JSX.Element { + return ( + + ); +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileE2eState.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileE2eState.ts new file mode 100644 index 0000000000..735754d8a2 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileE2eState.ts @@ -0,0 +1,211 @@ +/* +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, type MatrixEvent } 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 { _t } from "../../../../languageHandler"; + +/** Display state for the EventTile E2E padlock area. */ +export type EventTileE2ePadlockState = + | { + /** No E2E padlock should render. */ + kind: "none"; + } + | { + /** Render the undecryptable padlock. */ + kind: "decryptionFailure"; + } + | { + /** Render the forwarded/shared message indicator. */ + kind: "messageShared"; + /** User ID that forwarded the message key. */ + keyForwardingUserId: string; + /** Room ID used by the shared-message indicator. */ + roomId: string; + } + | { + /** Render a trust shield padlock. */ + kind: "shield"; + /** Shield colour used to choose the rendered icon. */ + shieldColour: EventShieldColour; + /** Shield reason used to choose the visible tooltip. */ + shieldReason: EventShieldReason | null; + } + | { + /** Render the unencrypted warning in an encrypted room. */ + kind: "unencrypted"; + }; + +/** Renderable display state for the EventTile E2E padlock area. */ +export type EventTileE2ePadlockViewState = + | { + /** No E2E padlock should render. */ + kind: "none"; + } + | { + /** Render the shared-message indicator. */ + kind: "messageShared"; + /** User ID that forwarded the message key. */ + keyForwardingUserId: string; + /** Room ID used by the shared-message indicator. */ + roomId: string; + } + | { + /** Render a standard E2E padlock icon. */ + kind: "icon"; + /** Icon variant to render. */ + icon: E2ePadlockIcon; + /** Tooltip shown for the icon. */ + title: string; + }; + +/** Inputs for deriving EventTile E2E padlock display state. */ +export interface EventTileE2ePadlockStateInput { + /** Matrix event rendered by the tile. */ + mxEvent: MatrixEvent; + /** Event used for verification decisions, usually the replacing event for edits. */ + verificationEvent: MatrixEvent; + /** Current event shield colour. */ + shieldColour: EventShieldColour; + /** Current event shield reason. */ + shieldReason: EventShieldReason | null; + /** Whether the room is encrypted. */ + isRoomEncrypted?: boolean | null; + /** Whether the event belongs to a local room. */ + isLocalRoom: boolean; +} + +/** Derives the E2E padlock display state for EventTile. */ +export function getEventTileE2ePadlockState({ + mxEvent, + verificationEvent, + shieldColour, + shieldReason, + isRoomEncrypted, + isLocalRoom, +}: EventTileE2ePadlockStateInput): EventTileE2ePadlockState { + if (isLocalRoom) { + return { kind: "none" }; + } + + if (verificationEvent.isDecryptionFailure()) { + switch (verificationEvent.decryptionFailureReason) { + case DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED: + case DecryptionFailureCode.UNSIGNED_SENDER_DEVICE: + return { kind: "none" }; + default: + return { kind: "decryptionFailure" }; + } + } + + if (shieldReason === EventShieldReason.AUTHENTICITY_NOT_GUARANTEED) { + const keyForwardingUserId = mxEvent.getKeyForwardingUser(); + if (keyForwardingUserId) { + return { + kind: "messageShared", + keyForwardingUserId, + roomId: verificationEvent.getRoomId()!, + }; + } + } + + if (shieldColour !== EventShieldColour.NONE) { + return { + kind: "shield", + shieldColour, + shieldReason, + }; + } + + if (isRoomEncrypted) { + if ( + verificationEvent.status === EventStatus.ENCRYPTING || + verificationEvent.status === EventStatus.NOT_SENT || + verificationEvent.isState() || + verificationEvent.isRedacted() + ) { + return { kind: "none" }; + } + + if (!verificationEvent.isEncrypted()) { + return { kind: "unencrypted" }; + } + } + + return { kind: "none" }; +} + +function getEventTileShieldReasonMessage(shieldReason: EventShieldReason | null): string { + switch (shieldReason) { + case EventShieldReason.UNVERIFIED_IDENTITY: + return _t("encryption|event_shield_reason_unverified_identity"); + + case EventShieldReason.UNSIGNED_DEVICE: + return _t("encryption|event_shield_reason_unsigned_device"); + + case EventShieldReason.UNKNOWN_DEVICE: + return _t("encryption|event_shield_reason_unknown_device"); + + case EventShieldReason.AUTHENTICITY_NOT_GUARANTEED: + return _t("encryption|event_shield_reason_authenticity_not_guaranteed"); + + case EventShieldReason.MISMATCHED_SENDER_KEY: + return _t("encryption|event_shield_reason_mismatched_sender_key"); + + case EventShieldReason.SENT_IN_CLEAR: + return _t("common|unencrypted"); + + case EventShieldReason.VERIFICATION_VIOLATION: + return _t("timeline|decryption_failure|sender_identity_previously_verified"); + + case EventShieldReason.MISMATCHED_SENDER: + return _t("encryption|event_shield_reason_mismatched_sender"); + + default: + return _t("error|unknown"); + } +} + +/** Derives renderable E2E padlock display state for EventTile. */ +export function getEventTileE2ePadlockViewState(input: EventTileE2ePadlockStateInput): EventTileE2ePadlockViewState { + const state = getEventTileE2ePadlockState(input); + + switch (state.kind) { + case "none": + return { kind: "none" }; + + case "decryptionFailure": + return { + kind: "icon", + icon: E2ePadlockIcon.DecryptionFailure, + title: _t("timeline|undecryptable_tooltip"), + }; + + case "messageShared": + return { + kind: "messageShared", + keyForwardingUserId: state.keyForwardingUserId, + roomId: state.roomId, + }; + + case "unencrypted": + return { + kind: "icon", + icon: E2ePadlockIcon.Warning, + title: _t("common|unencrypted"), + }; + + case "shield": + return { + kind: "icon", + icon: state.shieldColour === EventShieldColour.GREY ? E2ePadlockIcon.Normal : E2ePadlockIcon.Warning, + title: getEventTileShieldReasonMessage(state.shieldReason), + }; + } +} diff --git a/apps/web/test/viewmodels/event-tiles/EventTileE2eState-test.ts b/apps/web/test/viewmodels/event-tiles/EventTileE2eState-test.ts new file mode 100644 index 0000000000..0246ae36b3 --- /dev/null +++ b/apps/web/test/viewmodels/event-tiles/EventTileE2eState-test.ts @@ -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 { + 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, + }); + }); +});