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
This commit is contained in:
rbondesson
2026-05-27 11:34:56 +00:00
committed by GitHub
parent 1ef544c5c6
commit 6c0f9960b5
5 changed files with 819 additions and 104 deletions
@@ -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<EventTileE2ePadlockViewState, EventTileE2eViewModelProps> {
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<EventTileE2eViewModelProps>): 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<void> {
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);
}
}
}
@@ -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;