* 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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user