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
+53 -104
View File
@@ -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<EventTileProps, IState>
private isListeningForReceipts: boolean;
private tile = createRef<IEventTileType>();
private replyChain = createRef<ReplyChain>();
private readonly e2eViewModel: EventTileE2eViewModel;
private e2eViewModelSubscription?: () => void;
public readonly ref = createRef<HTMLElement>();
@@ -329,7 +311,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>;
private unmounted = false;
private readonly id = uniqueId();
private staleHoverCheckActive = false;
@@ -344,15 +325,20 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
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<EventTileProps, IState>
}
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<EventTileProps, IState>
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<EventTileProps, IState>
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<EventTileProps, IState>
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<EventTileProps>, prevState: Readonly<IState>): void {
public componentDidUpdate(_prevProps: Readonly<EventTileProps>, prevState: Readonly<IState>): 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<EventTileProps, IState>
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<EventTileProps, IState>
/** 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<void> {
// 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<keyof EventTileProps>;
const keysB = Object.keys(objB) as Array<keyof EventTileProps>;
@@ -725,17 +678,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
};
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<EventTileProps, IState>
// 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<EventTileProps, IState>
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<EventTileProps, IState>
// 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<EventTileProps, IState>
) : 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<EventTileProps, IState>
/>
);
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 ? <span className="mx_MessageTimestamp" /> : null;
const timestamp = showRealTimestamp ? messageTimestamp : dummyTimestamp;
const linkedTimestamp = showLinkedTimestamp ? linkedMessageTimestamp : dummyTimestamp;
const dummyTimestamp = eventTileRenderState.timestamp.showDummy ? (
<span className="mx_MessageTimestamp" />
) : 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<EventTileProps, IState>
);
}
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<EventTileProps, IState>
);
}
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<EventTileProps, IState>
showHiddenEvents: this.context.showHiddenEvents,
})}
{actionBar}
{this.props.layout === Layout.IRC && (
{eventTileRenderState.footer.showInIrcLayout && (
<>
{hasFooter && (
<div className="mx_EventTile_footer">
@@ -1412,7 +1361,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
</>
)}
</div>
{this.props.layout !== Layout.IRC && (
{eventTileRenderState.footer.showInDefaultLayout && (
<>
{hasFooter && (
<div className="mx_EventTile_footer">