diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 1379560d55..e354f8bbcc 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -28,9 +28,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"; @@ -75,7 +74,6 @@ import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../event import ThreadSummary, { ThreadMessagePreview } from "./ThreadSummary"; import { ReadReceiptGroup } from "./ReadReceiptGroup"; import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; -import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge"; import { EventTileThreadToolbar } from "./EventTile/EventTileThreadToolbar"; import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper"; @@ -83,6 +81,8 @@ import PinningUtils from "../../../utils/PinningUtils"; import { PinnedMessageBadge } from "../messages/PinnedMessageBadge"; import { EventPreview } from "./EventPreview"; import { ElementCallEventType } from "../../../call-types"; +import { E2ePadlockViewModel } from "../../../viewmodels/event-tile/E2ePadlockViewModel"; +import { E2EPadlockView } from "../../../shared-components/event-tile/E2ePadlockView"; export type GetRelationsForEvent = ( eventId: string, @@ -286,6 +286,7 @@ export class UnwrappedEventTile extends React.Component private isListeningForReceipts: boolean; private tile = createRef(); private replyChain = createRef(); + private e2ePadlockViewModel: E2ePadlockViewModel; public readonly ref = createRef(); @@ -297,7 +298,7 @@ export class UnwrappedEventTile extends React.Component public static contextType = RoomContext; declare public context: React.ContextType; - private unmounted = false; + // private unmounted = false; public constructor(props: EventTileProps, context: React.ContextType) { super(props, context); @@ -328,6 +329,12 @@ export class UnwrappedEventTile extends React.Component // to determine if we've already subscribed and use a combination of other flags to find // out if we should even be subscribed at all. this.isListeningForReceipts = false; + + this.e2ePadlockViewModel = new E2ePadlockViewModel({ + event: this.props.mxEvent, + cli: MatrixClientPeg.get()!, + isRoomEncrypted: !!this.context.isRoomEncrypted, + }); } /** @@ -386,7 +393,6 @@ export class UnwrappedEventTile extends React.Component } public componentDidMount(): void { - this.unmounted = false; this.suppressReadReceiptAnimation = false; const client = MatrixClientPeg.safeGet(); if (!this.props.forExport) { @@ -441,8 +447,8 @@ export class UnwrappedEventTile extends React.Component this.props.mxEvent.removeListener(MatrixEventEvent.RelationsCreated, this.onReactionsCreated); } this.props.mxEvent.off(ThreadEvent.Update, this.updateThread); - this.unmounted = false; if (this.props.resizeObserver && this.ref.current) this.props.resizeObserver.unobserve(this.ref.current); + if (this.e2ePadlockViewModel) this.e2ePadlockViewModel.dispose(); } public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { @@ -577,33 +583,33 @@ export class UnwrappedEventTile extends React.Component }; private verifyEvent(): void { - this.doVerifyEvent().catch((e) => { + this.e2ePadlockViewModel.verifyEvent().catch((e) => { const event = this.props.mxEvent; logger.error(`Error getting encryption info on event ${event.getId()} in room ${event.getRoomId()}`, e); }); } - private async doVerifyEvent(): Promise { - // if the event was edited, show the verification info for the edit, not - // the original - const mxEvent = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent; + // private async doVerifyEvent(): Promise { + // // if the event was edited, show the verification info for the edit, not + // // the original + // const mxEvent = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent; - if (!mxEvent.isEncrypted() || mxEvent.isRedacted()) { - this.setState({ shieldColour: EventShieldColour.NONE, shieldReason: null }); - return; - } + // if (!mxEvent.isEncrypted() || mxEvent.isRedacted()) { + // this.setState({ shieldColour: EventShieldColour.NONE, shieldReason: null }); + // return; + // } - const encryptionInfo = - (await MatrixClientPeg.safeGet().getCrypto()?.getEncryptionInfoForEvent(mxEvent)) ?? null; - if (this.unmounted) return; - if (encryptionInfo === null) { - // likely a decryption error - this.setState({ shieldColour: EventShieldColour.NONE, shieldReason: null }); - return; - } + // const encryptionInfo = + // (await MatrixClientPeg.safeGet().getCrypto()?.getEncryptionInfoForEvent(mxEvent)) ?? null; + // if (this.unmounted) return; + // if (encryptionInfo === null) { + // // likely a decryption error + // this.setState({ shieldColour: EventShieldColour.NONE, shieldReason: null }); + // return; + // } - this.setState({ shieldColour: encryptionInfo.shieldColour, shieldReason: encryptionInfo.shieldReason }); - } + // this.setState({ shieldColour: encryptionInfo.shieldColour, shieldReason: encryptionInfo.shieldReason }); + // } private propsEqual(objA: EventTileProps, objB: EventTileProps): boolean { const keysA = Object.keys(objA) as Array; @@ -708,98 +714,95 @@ 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; + // 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; - // no icon for local rooms - if (isLocalRoom(ev.getRoomId()!)) return null; + // // 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 ; - } - } + // // 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.shieldColour !== EventShieldColour.NONE) { - let shieldReasonMessage: string; - switch (this.state.shieldReason) { - case EventShieldReason.UNVERIFIED_IDENTITY: - shieldReasonMessage = _t("encryption|event_shield_reason_unverified_identity"); - break; + // if (this.state.shieldColour !== EventShieldColour.NONE) { + // let shieldReasonMessage: string; + // switch (this.state.shieldReason) { + // case null: + // case EventShieldReason.UNKNOWN: + // shieldReasonMessage = _t("error|unknown"); + // break; - case EventShieldReason.UNSIGNED_DEVICE: - shieldReasonMessage = _t("encryption|event_shield_reason_unsigned_device"); - break; + // case EventShieldReason.UNVERIFIED_IDENTITY: + // shieldReasonMessage = _t("encryption|event_shield_reason_unverified_identity"); + // break; - case EventShieldReason.UNKNOWN_DEVICE: - shieldReasonMessage = _t("encryption|event_shield_reason_unknown_device"); - break; + // case EventShieldReason.UNSIGNED_DEVICE: + // shieldReasonMessage = _t("encryption|event_shield_reason_unsigned_device"); + // break; - case EventShieldReason.AUTHENTICITY_NOT_GUARANTEED: - shieldReasonMessage = _t("encryption|event_shield_reason_authenticity_not_guaranteed"); - break; + // case EventShieldReason.UNKNOWN_DEVICE: + // shieldReasonMessage = _t("encryption|event_shield_reason_unknown_device"); + // break; - case EventShieldReason.MISMATCHED_SENDER_KEY: - shieldReasonMessage = _t("encryption|event_shield_reason_mismatched_sender_key"); - break; + // case EventShieldReason.AUTHENTICITY_NOT_GUARANTEED: + // shieldReasonMessage = _t("encryption|event_shield_reason_authenticity_not_guaranteed"); + // break; - case EventShieldReason.SENT_IN_CLEAR: - shieldReasonMessage = _t("common|unencrypted"); - break; + // case EventShieldReason.MISMATCHED_SENDER_KEY: + // shieldReasonMessage = _t("encryption|event_shield_reason_mismatched_sender_key"); + // break; - case EventShieldReason.VERIFICATION_VIOLATION: - shieldReasonMessage = _t("timeline|decryption_failure|sender_identity_previously_verified"); - break; + // case EventShieldReason.SENT_IN_CLEAR: + // shieldReasonMessage = _t("common|unencrypted"); + // break; - case EventShieldReason.MISMATCHED_SENDER: - shieldReasonMessage = _t("encryption|event_shield_reason_mismatched_sender"); - break; + // case EventShieldReason.VERIFICATION_VIOLATION: + // shieldReasonMessage = _t("timeline|decryption_failure|sender_identity_previously_verified"); + // break; + // } - default: - shieldReasonMessage = _t("error|unknown"); - break; - } + // if (this.state.shieldColour === EventShieldColour.GREY) { + // return ; + // } else { + // // red, by elimination + // return ; + // } + // } - if (this.state.shieldColour === EventShieldColour.GREY) { - return ; - } else { - // red, by elimination - 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 ; + // } + // } - 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; - } + // // no padlock needed + // return null; + // } private onActionBarFocusChange = (actionBarFocused: boolean): void => { this.setState({ actionBarFocused }); @@ -1174,8 +1177,9 @@ export class UnwrappedEventTile extends React.Component const groupTimestamp = !useIRCLayout ? linkedTimestamp : null; const ircTimestamp = useIRCLayout ? linkedTimestamp : null; const bubbleTimestamp = this.props.layout === Layout.Bubble ? messageTimestamp : undefined; - const groupPadlock = !useIRCLayout && !isBubbleMessage && this.renderE2EPadlock(); - const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock(); + const padlock = ; + const groupPadlock = !useIRCLayout && !isBubbleMessage && padlock; + const ircPadlock = useIRCLayout && !isBubbleMessage && padlock; let msgOption: JSX.Element | undefined; if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) { @@ -1487,53 +1491,6 @@ const SafeEventTile = (props: EventTileProps): JSX.Element => { }; export default SafeEventTile; -function E2ePadlockUnencrypted(props: Omit): JSX.Element { - return ; -} - -function E2ePadlockDecryptionFailure(props: Omit): JSX.Element { - return ( - - ); -} - -enum E2ePadlockIcon { - /** grey shield */ - Normal = "normal", - - /** red shield with (!) */ - Warning = "warning", - - /** key in grey circle */ - DecryptionFailure = "decryption_failure", -} - -interface IE2ePadlockProps { - icon: E2ePadlockIcon; - title: string; -} - -class E2ePadlock extends React.Component { - public constructor(props: IE2ePadlockProps) { - super(props); - - this.state = { - hover: false, - }; - } - - public render(): ReactNode { - const classes = `mx_EventTile_e2eIcon mx_EventTile_e2eIcon_${this.props.icon}`; - // We specify isTriggerInteractive=true and make the div interactive manually as a workaround for - // https://github.com/element-hq/compound/issues/294 - return ( - -
- - ); - } -} - interface ISentReceiptProps { messageState: EventStatus | null; } diff --git a/src/shared-components/event-tile/E2ePadlockView.tsx b/src/shared-components/event-tile/E2ePadlockView.tsx new file mode 100644 index 0000000000..196d81de53 --- /dev/null +++ b/src/shared-components/event-tile/E2ePadlockView.tsx @@ -0,0 +1,56 @@ +/* +Copyright 2025 New Vector 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 from "react"; +import { Tooltip } from "@vector-im/compound-web"; + +import { useViewModel } from "../useViewModel"; +import type { ViewModel } from "../ViewModel"; +import { _t } from "../../languageHandler"; + +interface E2ePadlockViewSnapshotWithShield { + noShield?: false; + iconType: E2ePadlockIconType; + message: string; +} +interface E2ePadlockViewSnapshotNoShield { + noShield: true; +} +export type E2ePadlockViewSnapshot = E2ePadlockViewSnapshotNoShield | E2ePadlockViewSnapshotWithShield; + +export enum E2ePadlockIconType { + /** grey shield */ + Normal = "normal", + + /** red shield with (!) */ + Warning = "warning", + + /** key in grey circle */ + DecryptionFailure = "decryption_failure", +} + +interface Props { + vm: ViewModel; +} + +/** + * This is the padlock icon that is rendered before the encrypted message. + */ +export const E2EPadlockView: React.FC = ({ vm }) => { + const vs = useViewModel(vm); + if (vs.noShield) return null; + + const { iconType: icon, message: title } = vs; + const classes = `mx_EventTile_e2eIcon mx_EventTile_e2eIcon_${icon}`; + // We specify isTriggerInteractive=true and make the div interactive manually as a workaround for + // https://github.com/element-hq/compound/issues/294 + return ( + +
+ + ); +}; diff --git a/src/viewmodels/event-tile/E2ePadlockViewModel.ts b/src/viewmodels/event-tile/E2ePadlockViewModel.ts new file mode 100644 index 0000000000..59c9b7c6c8 --- /dev/null +++ b/src/viewmodels/event-tile/E2ePadlockViewModel.ts @@ -0,0 +1,154 @@ +/* +Copyright 2025 New Vector 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 MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { DecryptionFailureCode, EventShieldColour, EventShieldReason } from "matrix-js-sdk/src/crypto-api"; + +import { E2ePadlockIconType, type E2ePadlockViewSnapshot } from "../../shared-components/event-tile/E2ePadlockView"; +import { BaseViewModel } from "../base/BaseViewModel"; +import { isLocalRoom } from "../../utils/localRoom/isLocalRoom"; +import { _t } from "../../languageHandler"; + +interface Props { + event: MatrixEvent; + cli: MatrixClient; + isRoomEncrypted: boolean; +} + +/** + * View-model for the padlock icon rendered before encrypted message. + */ +export class E2ePadlockViewModel extends BaseViewModel { + public constructor(props: Props) { + super(props, { noShield: true }); + } + + /** + * Calculates the icon and message to show by verifying the encryption + * info of the associated event. + */ + public async verifyEvent(): Promise { + const [colour, reason] = await this.getShieldInfo(); + const newSnapshot = this.getIconAndMessage(colour, reason); + this.snapshot.set(newSnapshot); + } + + private async getShieldInfo(): Promise<[EventShieldColour, EventShieldReason | null]> { + const { event, cli } = this.props; + // if the event was edited, show the verification info for the edit, not + // the original + const mxEvent = event.replacingEvent() ?? event; + + if (!mxEvent.isEncrypted() || mxEvent.isRedacted()) { + return [EventShieldColour.NONE, null]; + } + + const encryptionInfo = (await cli.getCrypto()?.getEncryptionInfoForEvent(mxEvent)) ?? null; + if (encryptionInfo === null) { + // likely a decryption error + return [EventShieldColour.NONE, null]; + } + + return [encryptionInfo.shieldColour, encryptionInfo.shieldReason]; + } + + /** + * Convert EventShieldReason to a user readable message. + */ + private getShieldMessage(reason: EventShieldReason | null): string { + switch (reason) { + 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"); + + default: + return _t("error|unknown"); + } + } + + /** + * Some events are expected to be unencrypted even in an encrypted room. + * Checks if this is such an event. + */ + private isEventAllowedToBeUnencrypted(event: MatrixEvent): boolean { + // event is being encrypted or is not_sent (Unknown Devices/Network Error) + if (event.status === EventStatus.ENCRYPTING) { + return true; + } + if (event.status === EventStatus.NOT_SENT) { + return true; + } + if (event.isState()) { + return true; // we expect this to be unencrypted + } + if (event.isRedacted()) { + return true; // we expect this to be unencrypted + } + return false; + } + + private getIconAndMessage( + shieldColour: EventShieldColour, + shieldReason: EventShieldReason | null, + ): E2ePadlockViewSnapshot { + const { isRoomEncrypted } = this.props; + const event = this.props.event.replacingEvent() ?? this.props.event; + + if (isLocalRoom(event.getRoomId()!)) { + // no icon for local rooms + return { noShield: true }; + } + + // event could not be decrypted + if (event.isDecryptionFailure()) { + switch (event.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 { noShield: true }; + default: + return { + message: _t("timeline|undecryptable_tooltip"), + iconType: E2ePadlockIconType.DecryptionFailure, + }; + } + } + + if (shieldColour !== EventShieldColour.NONE) { + const message = this.getShieldMessage(shieldReason); + const iconType = + shieldColour === EventShieldColour.GREY ? E2ePadlockIconType.Normal : E2ePadlockIconType.Warning; + return { message, iconType }; + } + + if (isRoomEncrypted && !event.isEncrypted() && !this.isEventAllowedToBeUnencrypted(event)) { + // if the event is not encrypted, but it's an e2e room, show a warning + return { message: _t("common|unencrypted"), iconType: E2ePadlockIconType.Warning }; + } + + return { + noShield: true, + }; + } +}