From 6c0f9960b5971975e88544194e15184c6b49e20b Mon Sep 17 00:00:00 2001 From: rbondesson Date: Wed, 27 May 2026 13:34:56 +0200 Subject: [PATCH] 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 --- .../src/components/views/rooms/EventTile.tsx | 157 +++------ .../event-tile/EventTileE2eViewModel.ts | 212 ++++++++++++ .../timeline/event-tile/EventTileViewModel.ts | 81 +++++ .../event-tiles/EventTileE2eViewModel-test.ts | 302 ++++++++++++++++++ .../event-tiles/EventTileViewModel-test.ts | 171 ++++++++++ 5 files changed, 819 insertions(+), 104 deletions(-) create mode 100644 apps/web/src/viewmodels/room/timeline/event-tile/EventTileE2eViewModel.ts create mode 100644 apps/web/test/viewmodels/event-tiles/EventTileE2eViewModel-test.ts diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index a3438420c8..6bda81145e 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -20,7 +20,6 @@ import React, { type MouseEvent, type ReactNode, } from "react"; -import classNames from "classnames"; import { type EventStatus, EventType, @@ -36,12 +35,6 @@ import { } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call"; -import { - CryptoEvent, - EventShieldColour, - type EventShieldReason, - type UserVerificationStatus, -} from "matrix-js-sdk/src/crypto-api"; import { Tooltip } from "@vector-im/compound-web"; import { uniqueId, uniqBy } from "lodash"; import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; @@ -89,7 +82,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 { getLateEventInfo } from "../../structures/grouper/LateEventGrouper"; import { Icon as LateIcon } from "../../../../res/img/sensor.svg"; @@ -142,7 +134,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"; +import { EventTileE2eViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileE2eViewModel"; /** Relation lookup type retained for EventTile consumers. */ export type { GetRelationsForEvent } from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState"; @@ -292,18 +284,6 @@ export interface EventTileProps { interface IState { interaction: EventTileInteractionState; - /** - * E2EE shield we should show for decryption problems. - * - * Note this will be `EventShieldColour.NONE` for all unencrypted events, **including those in encrypted rooms**. - */ - shieldColour: EventShieldColour; - - /** - * Reason code for the E2EE shield. `null` if `shieldColour` is `EventShieldColour.NONE` - */ - shieldReason: EventShieldReason | null; - // The Relations model from the JS SDK for reactions to `mxEvent` reactions?: Relations | null | undefined; @@ -318,6 +298,8 @@ export class UnwrappedEventTile extends React.Component private isListeningForReceipts: boolean; private tile = createRef(); private replyChain = createRef(); + private readonly e2eViewModel: EventTileE2eViewModel; + private e2eViewModelSubscription?: () => void; public readonly ref = createRef(); @@ -329,7 +311,6 @@ export class UnwrappedEventTile extends React.Component public static contextType = RoomContext; declare public context: React.ContextType; - private unmounted = false; private readonly id = uniqueId(); private staleHoverCheckActive = false; @@ -344,15 +325,20 @@ export class UnwrappedEventTile extends React.Component this.state = { interaction: initialEventTileInteractionState, - shieldColour: EventShieldColour.NONE, - shieldReason: null, - // The Relations model from the JS SDK for reactions to `mxEvent` reactions: this.getReactions(), thread, }; + this.e2eViewModel = new EventTileE2eViewModel({ + cli: MatrixClientPeg.safeGet(), + mxEvent: this.props.mxEvent, + isRoomEncrypted: this.context.isRoomEncrypted, + eventSendStatus: this.props.eventSendStatus, + enableListeners: !this.props.forExport, + }); + // don't do RR animations until we are mounted this.suppressReadReceiptAnimation = true; @@ -379,11 +365,14 @@ export class UnwrappedEventTile extends React.Component } public componentDidMount(): void { - this.unmounted = false; this.suppressReadReceiptAnimation = false; + this.e2eViewModelSubscription = this.e2eViewModel.subscribe(() => { + this.forceUpdate(); + }); + this.e2eViewModel.start(); + const client = MatrixClientPeg.safeGet(); if (!this.props.forExport) { - client.on(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); this.props.mxEvent.on(MatrixEventEvent.Decrypted, this.onDecrypted); this.props.mxEvent.on(MatrixEventEvent.Replaced, this.onReplaced); DecryptionFailureTracker.instance.addVisibleEvent(this.props.mxEvent); @@ -403,8 +392,6 @@ export class UnwrappedEventTile extends React.Component const room = client.getRoom(this.props.mxEvent.getRoomId()); room?.on(ThreadEvent.New, this.onNewThread); - - this.verifyEvent(); } private readonly updateThread = (thread: Thread): void => { @@ -423,7 +410,6 @@ export class UnwrappedEventTile extends React.Component this.stopStaleHoverCheck(); const client = MatrixClientPeg.get(); if (client) { - client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); client.removeListener(RoomEvent.Receipt, this.onRoomReceipt); const room = client.getRoom(this.props.mxEvent.getRoomId()); room?.off(ThreadEvent.New, this.onNewThread); @@ -435,11 +421,13 @@ 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; + this.e2eViewModelSubscription?.(); + this.e2eViewModelSubscription = undefined; + this.e2eViewModel.dispose(); if (this.props.resizeObserver && this.ref.current) this.props.resizeObserver.unobserve(this.ref.current); } - public componentDidUpdate(prevProps: Readonly, prevState: Readonly): void { + public componentDidUpdate(_prevProps: Readonly, prevState: Readonly): void { // Some overlays, such as portalled tooltips, can interrupt the normal mouseleave path. // While hover is active, verify it against the browser's real :hover state on mouse movement. if (!prevState.interaction.hover && this.state.interaction.hover) { @@ -453,10 +441,12 @@ export class UnwrappedEventTile extends React.Component MatrixClientPeg.safeGet().on(RoomEvent.Receipt, this.onRoomReceipt); this.isListeningForReceipts = true; } - // re-check the sender verification as outgoing events progress through the send process. - if (prevProps.eventSendStatus !== this.props.eventSendStatus) { - this.verifyEvent(); - } + this.e2eViewModel.setProps({ + mxEvent: this.props.mxEvent, + isRoomEncrypted: this.context.isRoomEncrypted, + eventSendStatus: this.props.eventSendStatus, + enableListeners: !this.props.forExport, + }); if (this.props.resizeObserver && this.ref.current) this.props.resizeObserver.observe(this.ref.current); @@ -571,53 +561,16 @@ export class UnwrappedEventTile extends React.Component /** called when the event is decrypted after we show it. */ private readonly onDecrypted = (): void => { - // we need to re-verify the sending device. - this.verifyEvent(); + // E2E padlock verification is handled by EventTileE2eViewModel; this refreshes the rest of the tile body. this.forceUpdate(); }; - private readonly onUserVerificationChanged = (userId: string, _trustStatus: UserVerificationStatus): void => { - if (userId === this.props.mxEvent.getSender()) { - this.verifyEvent(); - } - }; - /** called when the event is edited after we show it. */ private readonly onReplaced = (): void => { + // E2E padlock verification is handled by EventTileE2eViewModel; this refreshes the rest of the tile body. this.forceUpdate(); - // re-verify the event if it is replaced (the edit may not be verified) - this.verifyEvent(); }; - private verifyEvent(): void { - this.doVerifyEvent().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; - - 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; - } - - this.setState({ shieldColour: encryptionInfo.shieldColour, shieldReason: encryptionInfo.shieldReason }); - } - private propsEqual(objA: EventTileProps, objB: EventTileProps): boolean { const keysA = Object.keys(objA) as Array; const keysB = Object.keys(objB) as Array; @@ -725,17 +678,7 @@ 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 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()!), - }); + const e2ePadlockViewState = this.e2eViewModel.getSnapshot(); switch (e2ePadlockViewState.kind) { case "none": @@ -964,7 +907,7 @@ export class UnwrappedEventTile extends React.Component // Use `getSender()` because searched events might not have a proper `sender`. const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.safeGet().getUserId(); - const eventTileSnapshot = EventTileViewModel.createSnapshot({ + const eventTileRenderState = EventTileViewModel.createRenderState({ event: { mxEvent: this.props.mxEvent, eventSendStatus: this.props.eventSendStatus, @@ -1012,11 +955,12 @@ export class UnwrappedEventTile extends React.Component hasPinnedMessageBadge, }, }); + const eventTileSnapshot = eventTileRenderState.snapshot; - const lineClasses = classNames("mx_EventTile_line", eventTileSnapshot.line.classState); - const tileClasses = classNames(eventTileSnapshot.root.classState); - const tileAriaLive = eventTileSnapshot.root.ariaLive; - const isRenderingNotification = eventTileSnapshot.event.isRenderingNotification; + const lineClasses = eventTileRenderState.line.className; + const tileClasses = eventTileRenderState.root.className; + const tileAriaLive = eventTileRenderState.root.ariaLive; + const isRenderingNotification = eventTileRenderState.root.isRenderingNotification; let permalink = "#"; if (this.props.permalinkCreator) { @@ -1025,7 +969,7 @@ export class UnwrappedEventTile extends React.Component // we can't use local echoes as scroll tokens, because their event IDs change. // Local echos have a send "status". - const scrollToken = eventTileSnapshot.root.scrollToken; + const scrollToken = eventTileRenderState.root.scrollToken; let avatar: JSX.Element | null = null; let sender: JSX.Element | null = null; @@ -1068,7 +1012,7 @@ export class UnwrappedEventTile extends React.Component ) : undefined; // Thread panel shows the timestamp of the last reply in that thread - const ts = eventTileSnapshot.timestamp.value; + const ts = eventTileRenderState.timestamp.value; const messageTimestampProps: MessageTimestampViewModelProps = { showRelative: this.context.timelineRenderingType === TimelineRenderingType.ThreadsList, @@ -1086,11 +1030,16 @@ export class UnwrappedEventTile extends React.Component /> ); - const { useIRCLayout, showRealTimestamp, showLinkedTimestamp } = eventTileSnapshot.timestamp.displayState; // Used to simplify the UI layout where necessary by not conditionally rendering an element at the start - const dummyTimestamp = useIRCLayout ? : null; - const timestamp = showRealTimestamp ? messageTimestamp : dummyTimestamp; - const linkedTimestamp = showLinkedTimestamp ? linkedMessageTimestamp : dummyTimestamp; + const dummyTimestamp = eventTileRenderState.timestamp.showDummy ? ( + + ) : null; + const timestamp = eventTileRenderState.timestamp.displayState.showRealTimestamp + ? messageTimestamp + : dummyTimestamp; + const linkedTimestamp = eventTileRenderState.timestamp.displayState.showLinkedTimestamp + ? linkedMessageTimestamp + : dummyTimestamp; let pinnedMessageBadge: JSX.Element | undefined; if (hasPinnedMessageBadge) { @@ -1108,10 +1057,10 @@ export class UnwrappedEventTile extends React.Component ); } - const groupTimestamp = !useIRCLayout ? linkedTimestamp : null; - const ircTimestamp = useIRCLayout ? linkedTimestamp : null; - const groupPadlock = !useIRCLayout && !isBubbleMessage && this.renderE2EPadlock(); - const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock(); + const groupTimestamp = eventTileRenderState.timestamp.showInGroupLine ? linkedTimestamp : null; + const ircTimestamp = eventTileRenderState.timestamp.showInIrcLine ? linkedTimestamp : null; + const groupPadlock = eventTileRenderState.e2ePadlock.showInGroupLine && this.renderE2EPadlock(); + const ircPadlock = eventTileRenderState.e2ePadlock.showInIrcLine && this.renderE2EPadlock(); const receiptState = this.receiptState; let msgOption: JSX.Element | undefined; @@ -1154,7 +1103,7 @@ export class UnwrappedEventTile extends React.Component ); } - const { hasFooter, showMainPinnedMessageBadge, showBubblePinnedMessageBadge } = eventTileSnapshot.footer; + const { hasFooter, showMainPinnedMessageBadge, showBubblePinnedMessageBadge } = eventTileRenderState.footer; switch (this.context.timelineRenderingType) { case TimelineRenderingType.Thread: { @@ -1400,7 +1349,7 @@ export class UnwrappedEventTile extends React.Component showHiddenEvents: this.context.showHiddenEvents, })} {actionBar} - {this.props.layout === Layout.IRC && ( + {eventTileRenderState.footer.showInIrcLayout && ( <> {hasFooter && (
@@ -1412,7 +1361,7 @@ export class UnwrappedEventTile extends React.Component )}
- {this.props.layout !== Layout.IRC && ( + {eventTileRenderState.footer.showInDefaultLayout && ( <> {hasFooter && (
diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileE2eViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileE2eViewModel.ts new file mode 100644 index 0000000000..d4167f8ddc --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileE2eViewModel.ts @@ -0,0 +1,212 @@ +/* +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 { type MatrixClient, MatrixEventEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; +import { CryptoEvent, EventShieldColour, type EventShieldReason } from "matrix-js-sdk/src/crypto-api"; +import { BaseViewModel } from "@element-hq/web-shared-components"; + +import { isLocalRoom } from "../../../../utils/localRoom/isLocalRoom"; +import { objectHasDiff } from "../../../../utils/objects"; +import { getEventTileE2ePadlockViewState, type EventTileE2ePadlockViewState } from "./EventTileE2eState"; + +export interface EventTileE2eViewModelProps { + /** Matrix client used for crypto verification lookups. */ + cli: MatrixClient; + /** Matrix event rendered by the tile. */ + mxEvent: MatrixEvent; + /** Whether the room is encrypted. */ + isRoomEncrypted?: boolean | null; + /** Current event send status. Used to re-check verification as local echoes progress. */ + eventSendStatus?: MatrixEvent["status"] | null; + /** Whether the view model should register live event and trust listeners. */ + enableListeners: boolean; + /** Optional local-room predicate for tests. */ + isLocalRoom?: (roomId: string) => boolean; +} + +interface EventTileE2eShieldState { + shieldColour: EventShieldColour; + shieldReason: EventShieldReason | null; +} + +/** View model for EventTile E2E padlock state and verification refreshes. */ +export class EventTileE2eViewModel extends BaseViewModel { + private shieldState: EventTileE2eShieldState = { + shieldColour: EventShieldColour.NONE, + shieldReason: null, + }; + private listenerCleanups: Array<() => void> = []; + private started = false; + private verificationRequestId = 0; + + public constructor(props: EventTileE2eViewModelProps) { + super( + props, + EventTileE2eViewModel.calculateSnapshot(props, { + shieldColour: EventShieldColour.NONE, + shieldReason: null, + }), + ); + } + + private static calculateSnapshot( + props: EventTileE2eViewModelProps, + shieldState: EventTileE2eShieldState, + ): EventTileE2ePadlockViewState { + const verificationEvent = props.mxEvent.replacingEvent() ?? props.mxEvent; + const roomId = verificationEvent.getRoomId()!; + + return getEventTileE2ePadlockViewState({ + mxEvent: props.mxEvent, + verificationEvent, + shieldColour: shieldState.shieldColour, + shieldReason: shieldState.shieldReason, + isRoomEncrypted: props.isRoomEncrypted, + isLocalRoom: props.isLocalRoom?.(roomId) ?? isLocalRoom(roomId), + }); + } + + /** Starts live listeners and runs the initial verification check. */ + public start(): void { + if (this.started) return; + + this.started = true; + this.setupListeners(); + this.verifyEvent(); + } + + /** Updates inputs, refreshes listeners when the event changes, and re-checks verification when needed. */ + public setProps(newProps: Partial): void { + const prevProps = this.props; + const prevEvent = this.props.mxEvent; + + this.props = { + ...this.props, + ...newProps, + }; + + const eventChanged = prevEvent !== this.props.mxEvent; + const shouldVerify = + eventChanged || + prevProps.eventSendStatus !== this.props.eventSendStatus || + prevProps.enableListeners !== this.props.enableListeners; + + if (eventChanged) { + this.shieldState = { + shieldColour: EventShieldColour.NONE, + shieldReason: null, + }; + this.refreshSnapshot(); + this.setupListeners(); + } else if (prevProps.enableListeners !== this.props.enableListeners) { + this.setupListeners(); + } else if (prevProps.isRoomEncrypted !== this.props.isRoomEncrypted) { + this.refreshSnapshot(); + } + + if (shouldVerify) { + this.verifyEvent(); + } + } + + public override dispose(): void { + this.teardownListeners(); + super.dispose(); + } + + private setupListeners(): void { + this.teardownListeners(); + + if (!this.started || !this.props.enableListeners) { + return; + } + + const { cli, mxEvent } = this.props; + + cli.on(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); + this.listenerCleanups.push(() => { + cli.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); + }); + + mxEvent.on(MatrixEventEvent.Decrypted, this.onDecrypted); + this.listenerCleanups.push(() => { + mxEvent.removeListener(MatrixEventEvent.Decrypted, this.onDecrypted); + }); + + mxEvent.on(MatrixEventEvent.Replaced, this.onReplaced); + this.listenerCleanups.push(() => { + mxEvent.removeListener(MatrixEventEvent.Replaced, this.onReplaced); + }); + } + + private teardownListeners(): void { + for (const cleanup of this.listenerCleanups) { + cleanup(); + } + this.listenerCleanups = []; + } + + private readonly onDecrypted = (): void => { + this.verifyEvent(); + }; + + private readonly onReplaced = (): void => { + this.verifyEvent(); + }; + + private readonly onUserVerificationChanged = (userId: string): void => { + if (userId === this.props.mxEvent.getSender()) { + this.verifyEvent(); + } + }; + + private verifyEvent(): void { + const requestId = ++this.verificationRequestId; + + this.doVerifyEvent(requestId).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(requestId: number): Promise { + const verificationEvent = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent; + + if (!verificationEvent.isEncrypted() || verificationEvent.isRedacted()) { + this.setShieldState(EventShieldColour.NONE, null); + return; + } + + const encryptionInfo = (await this.props.cli.getCrypto()?.getEncryptionInfoForEvent(verificationEvent)) ?? null; + if (this.isDisposed || requestId !== this.verificationRequestId) return; + + if (encryptionInfo === null) { + // likely a decryption error + this.setShieldState(EventShieldColour.NONE, null); + return; + } + + this.setShieldState(encryptionInfo.shieldColour, encryptionInfo.shieldReason); + } + + private setShieldState(shieldColour: EventShieldColour, shieldReason: EventShieldReason | null): void { + this.shieldState = { + shieldColour, + shieldReason, + }; + this.refreshSnapshot(); + } + + private refreshSnapshot(): void { + const nextSnapshot = EventTileE2eViewModel.calculateSnapshot(this.props, this.shieldState); + + if (objectHasDiff(this.snapshot.current, nextSnapshot)) { + this.snapshot.set(nextSnapshot); + } + } +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts index 4fa6041621..e1f8b8428f 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts @@ -6,6 +6,7 @@ Please see LICENSE files in the repository root for full details. */ import { type EventStatus, type MatrixEvent, type RoomMember } from "matrix-js-sdk/src/matrix"; +import classNames from "classnames"; import { type EventTileSenderProfileState, @@ -229,8 +230,88 @@ export interface EventTileViewModelSnapshot { footer: EventTileFooterSnapshot; } +/** Render-ready EventTile state consumed by the existing component. */ +export interface EventTileRenderState { + /** Derived EventTile view state. */ + snapshot: EventTileViewModelSnapshot; + /** EventTile root render state. */ + root: { + /** EventTile root CSS classes. */ + className: string; + /** EventTile aria-live value. */ + ariaLive?: "off"; + /** Stable scroll token for the event. */ + scrollToken?: string; + /** Whether the tile is rendering as a notification. */ + isRenderingNotification: boolean; + }; + /** EventTile line render state. */ + line: { + /** EventTile line CSS classes. */ + className: string; + }; + /** EventTile timestamp render state. */ + timestamp: EventTileTimestampSnapshot & { + /** Whether EventTile should render the placeholder timestamp used by IRC layout. */ + showDummy: boolean; + /** Whether the timestamp slot belongs in the group-layout line. */ + showInGroupLine: boolean; + /** Whether the timestamp slot belongs in the IRC-layout line. */ + showInIrcLine: boolean; + }; + /** EventTile E2E padlock slot state. */ + e2ePadlock: { + /** Whether the padlock should render in the group-layout timestamp area. */ + showInGroupLine: boolean; + /** Whether the padlock should render in the IRC-layout timestamp area. */ + showInIrcLine: boolean; + }; + /** EventTile footer slot state. */ + footer: EventTileFooterSnapshot & { + /** Whether the footer belongs inside the IRC-layout message line. */ + showInIrcLayout: boolean; + /** Whether the footer belongs below the message line. */ + showInDefaultLayout: boolean; + }; +} + /** Derives the current EventTile snapshot from component-owned inputs. */ export class EventTileViewModel { + /** Derives render-ready EventTile state from component-owned inputs. */ + public static createRenderState(props: EventTileViewModelProps): EventTileRenderState { + const snapshot = EventTileViewModel.createSnapshot(props); + const useIRCLayout = snapshot.timestamp.displayState.useIRCLayout; + const showPadlock = !props.display.isBubbleMessage; + + return { + snapshot, + root: { + className: classNames(snapshot.root.classState), + ariaLive: snapshot.root.ariaLive, + scrollToken: snapshot.root.scrollToken, + isRenderingNotification: snapshot.event.isRenderingNotification, + }, + line: { + className: classNames("mx_EventTile_line", snapshot.line.classState), + }, + timestamp: { + ...snapshot.timestamp, + showDummy: useIRCLayout, + showInGroupLine: !useIRCLayout, + showInIrcLine: useIRCLayout, + }, + e2ePadlock: { + showInGroupLine: !useIRCLayout && showPadlock, + showInIrcLine: useIRCLayout && showPadlock, + }, + footer: { + ...snapshot.footer, + showInIrcLayout: useIRCLayout, + showInDefaultLayout: !useIRCLayout, + }, + }; + } + /** Creates an EventTile view model snapshot. */ public static createSnapshot(props: EventTileViewModelProps): EventTileViewModelSnapshot { const { event, display, interaction, sender, timestamp, footer } = props; diff --git a/apps/web/test/viewmodels/event-tiles/EventTileE2eViewModel-test.ts b/apps/web/test/viewmodels/event-tiles/EventTileE2eViewModel-test.ts new file mode 100644 index 0000000000..30f4e772ee --- /dev/null +++ b/apps/web/test/viewmodels/event-tiles/EventTileE2eViewModel-test.ts @@ -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, [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[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((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.", + }); + }); +}); diff --git a/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts b/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts index 9babc1a173..75a9be8f02 100644 --- a/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts +++ b/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts @@ -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, + }); + }); });