Move EventTile to shared components - #4a (#34469)

* Add EventTile root properties to view model

* Move render-related event reads into the vm

* Remove duplicate root derivation in UnwrappedEventTile

* Move data-has-reply derivation into EventTileViewModel

* Move getEventDisplayInfo() and dependencies into EventTileViewModel

* Add tests for improved coverage

* Cleanup and comments

* Fix: Replacement events have a fallback tile but must not show their own reply chain

* Combine dependency and prop updates into one atomic VM update

* Remove property which was never read and fix oxfmt issue
This commit is contained in:
rbondesson
2026-07-30 13:35:09 +00:00
committed by GitHub
parent 242d2c5209
commit d4d4794f7e
4 changed files with 445 additions and 136 deletions
+52 -103
View File
@@ -18,8 +18,6 @@ import React, {
} from "react";
import {
EventStatus,
EventType,
MsgType,
type MatrixEvent,
MatrixEventEvent,
type Relations,
@@ -50,19 +48,13 @@ import { type ComposerInsertPayload } from "../../../dispatcher/payloads/Compose
import { Action } from "../../../dispatcher/actions";
import PlatformPeg from "../../../PlatformPeg";
import { type IReadReceiptPosition } from "./ReadReceiptMarker";
import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { copyPlaintext } from "../../../utils/strings";
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import PosthogTrackers from "../../../PosthogTrackers";
import {
haveRendererForEvent,
isMessageEvent,
renderTile,
type EventTileTypeProps,
} from "../../../events/EventTileFactory";
import { isMessageEvent, renderTile, type EventTileTypeProps } from "../../../events/EventTileFactory";
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
@@ -80,7 +72,9 @@ import { EventTileThreadInfo, EventTileThreadPanelSummary } from "./EventTile/Ev
import { EventTileTimestampSlot } from "./EventTile/EventTileTimestampSlot";
import {
EventTileViewModel,
type EventTileRenderState,
type EventTileViewModelProps,
type EventTileViewModelDependencies,
} from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
import {
getEventTileReceiptState,
@@ -91,7 +85,6 @@ import {
getEventTileThreadState,
type EventTileThreadState,
} from "../../../viewmodels/room/timeline/event-tile/EventTileThreadState";
import { getEventTileReplyChainState } from "../../../viewmodels/room/timeline/event-tile/EventTileReplyChainState";
import {
eventTileActionBarFocusChange,
eventTileBlurWithin,
@@ -279,19 +272,12 @@ interface IState {
}
interface EventTileRenderInputs {
displayInfo: ReturnType<typeof getEventDisplayInfo>;
hasPinnedMessageBadge: boolean;
hasReactionsRow: boolean;
threadState: EventTileThreadState;
isOwnEvent: boolean;
}
interface EventTileRootRenderState {
tileClasses: string;
tileAriaLive?: "off";
scrollToken?: string;
}
/** EventTile implementation rendered inside a RoomContext with `timelineRenderingType` set. */
export class UnwrappedEventTile extends React.Component<EventTileProps, IState> {
private suppressReadReceiptAnimation: boolean;
@@ -332,7 +318,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
thread,
};
this.viewModel = new EventTileViewModel(this.createViewModelProps());
this.viewModel = new EventTileViewModel(this.createViewModelDependencies(), this.createViewModelProps());
this.e2eViewModel = new EventTileE2eViewModel({
cli: MatrixClientPeg.safeGet(),
@@ -795,19 +781,19 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
}
private createRootAttributes({
tileClasses,
tileAriaLive,
className,
ariaLive,
scrollToken,
}: EventTileRootRenderState): Record<string, unknown> {
}: EventTileRenderState["root"]): Record<string, unknown> {
return {
"className": tileClasses,
"aria-live": tileAriaLive,
"className": className,
"aria-live": ariaLive,
"aria-atomic": true,
"data-scroll-tokens": scrollToken,
};
}
private createInteractiveRootAttributes(rootRenderState: EventTileRootRenderState): Record<string, unknown> {
private createInteractiveRootAttributes(rootRenderState: EventTileRenderState["root"]): Record<string, unknown> {
return {
...this.createRootAttributes(rootRenderState),
ref: this.ref,
@@ -839,14 +825,16 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
};
}
private createRenderInputs(
displayInfo = getEventDisplayInfo(
MatrixClientPeg.safeGet(),
this.props.mxEvent,
this.context.showHiddenEvents,
shouldHideEventTile({ callEventGrouper: this.props.callEventGrouper }),
),
): EventTileRenderInputs {
private createViewModelDependencies(): EventTileViewModelDependencies {
return {
mxEvent: this.props.mxEvent,
matrixClient: MatrixClientPeg.safeGet(),
showHiddenEvents: this.context.showHiddenEvents,
hideEvent: shouldHideEventTile({ callEventGrouper: this.props.callEventGrouper }),
};
}
private createRenderInputs(): EventTileRenderInputs {
const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted;
const hasPinnedMessageBadge = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
const hasReactionsRow = !isRedacted;
@@ -855,7 +843,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.safeGet().getUserId();
return {
displayInfo,
hasPinnedMessageBadge,
hasReactionsRow,
threadState,
@@ -864,12 +851,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
}
private createViewModelProps(inputs: EventTileRenderInputs = this.createRenderInputs()): EventTileViewModelProps {
const { displayInfo, hasPinnedMessageBadge, hasReactionsRow, threadState, isOwnEvent } = inputs;
const { hasPinnedMessageBadge, hasReactionsRow, threadState, isOwnEvent } = inputs;
const isProbablyMedia = MediaEventHelper.isEligible(this.props.mxEvent);
const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure();
const isEditing = !!this.props.editState;
const eventType = this.props.mxEvent.getType();
const msgtype = this.props.mxEvent.getContent().msgtype;
const isSending =
this.props.eventSendStatus === EventStatus.SENDING ||
this.props.eventSendStatus === EventStatus.QUEUED ||
@@ -877,18 +861,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
return {
event: {
eventType,
msgtype,
eventTs: this.props.mxEvent.getTs(),
eventId: this.props.mxEvent.getId() ?? undefined,
isLocalEcho: !!this.props.mxEvent.status,
isSending,
ariaLive: this.props.eventSendStatus === null ? undefined : "off",
isRoomCreate: eventType === EventType.RoomCreate,
isCallInvite: eventType === EventType.CallInvite,
isRtcNotification: eventType === EventType.RTCNotification,
isEditing,
isEncryptionFailure,
forExport: this.props.forExport,
},
display: {
@@ -896,11 +871,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
layout: this.props.layout,
continuation: this.props.continuation,
isProbablyMedia,
isBubbleMessage: displayInfo.isBubbleMessage,
isLeftAlignedBubbleMessage: displayInfo.isLeftAlignedBubbleMessage,
isAlignedBetweenBubbles: displayInfo.isAlignedBetweenBubbles,
isInfoMessage: displayInfo.isInfoMessage,
noBubbleEvent: displayInfo.noBubbleEvent,
isTwelveHour: this.props.isTwelveHour,
isHighlighted: shouldHighlightEventTile({
cli: MatrixClientPeg.safeGet(),
@@ -923,7 +893,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
inhibitInteraction: this.props.inhibitInteraction,
},
sender: {
senderId: this.props.mxEvent.getSender() ?? undefined,
member: roomMemberToMemberInfo(
this.props.useEventSenderSnapshot
? this.getAvatarMember()
@@ -936,7 +905,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
}),
),
hideSender: this.props.hideSender,
isEmote: this.props.mxEvent.getContent().msgtype === MsgType.Emote,
},
timestamp: {
alwaysShowTimestamps: this.props.alwaysShowTimestamps,
@@ -977,22 +945,20 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
}
public render(): ReactNode {
const eventType = this.props.mxEvent.getType();
const replacingEventId = this.props.mxEvent.replacingEventId();
const displayInfo = getEventDisplayInfo(
MatrixClientPeg.safeGet(),
this.props.mxEvent,
this.context.showHiddenEvents,
shouldHideEventTile({ callEventGrouper: this.props.callEventGrouper }),
);
const { hasRenderer, isSeeingThroughMessageHiddenForModeration } = displayInfo;
const { isQuoteExpanded } = this.state;
const renderInputs = this.createRenderInputs();
const { hasPinnedMessageBadge, hasReactionsRow, threadState } = renderInputs;
this.viewModel.setInputs(this.createViewModelDependencies(), this.createViewModelProps(renderInputs));
const eventTileRenderState = this.viewModel.getSnapshot();
const eventTileSnapshot = eventTileRenderState.snapshot;
// This shouldn't happen: the caller should check we support this type
// before trying to instantiate us
if (!hasRenderer) {
const { mxEvent } = this.props;
logger.warn(`Event type not supported: type:${eventType} isState:${mxEvent.isState()}`);
if (!eventTileSnapshot.event.hasRenderer) {
logger.warn(
`Event type not supported: type:${eventTileSnapshot.event.eventType} isState:${eventTileSnapshot.event.isState}`,
);
return (
<div className="mx_EventTile mx_EventTile_info mx_MNoticeBody">
<div className="mx_EventTile_line">{_t("timeline|error_no_renderer")}</div>
@@ -1000,22 +966,13 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
);
}
const renderInputs = this.createRenderInputs(displayInfo);
const { hasPinnedMessageBadge, hasReactionsRow, threadState, isOwnEvent } = renderInputs;
this.viewModel.setProps(this.createViewModelProps(renderInputs));
const eventTileRenderState = this.viewModel.getSnapshot();
const eventTileSnapshot = eventTileRenderState.snapshot;
const lineClasses = eventTileRenderState.line.className;
const tileClasses = eventTileRenderState.root.className;
const tileAriaLive = eventTileRenderState.root.ariaLive;
const isRenderingNotification = eventTileRenderState.root.isRenderingNotification;
const isRenderingNotification = eventTileSnapshot.event.isRenderingNotification;
const isSeeingThroughMessageHiddenForModeration =
eventTileSnapshot.event.isSeeingThroughMessageHiddenForModeration;
const permalink = this.getPermalink();
const scrollToken = eventTileRenderState.root.scrollToken;
const rootRenderState = { tileClasses, tileAriaLive, scrollToken };
const rootRenderState = eventTileRenderState.root;
const avatarMember = this.getAvatarMember();
const avatar = <EventTileAvatarAdapter avatarMember={avatarMember} senderSnapshot={eventTileSnapshot.sender} />;
@@ -1083,16 +1040,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
/>
);
const replyChainState = getEventTileReplyChainState({
mxEvent: this.props.mxEvent,
hasRenderer: haveRendererForEvent(
this.props.mxEvent,
MatrixClientPeg.safeGet(),
this.context.showHiddenEvents,
),
});
let replyChain: JSX.Element | undefined;
if (replyChainState.shouldShowReplyChain) {
if (eventTileSnapshot.root.data.hasReply) {
replyChain = (
<ReplyChain
parentEv={this.props.mxEvent}
@@ -1115,10 +1064,10 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
this.props.as || "li",
{
...this.createInteractiveRootAttributes(rootRenderState),
"data-has-reply": !!replyChain,
"data-layout": this.props.layout,
"data-self": isOwnEvent,
"data-event-id": this.props.mxEvent.getId(),
"data-has-reply": eventTileSnapshot.root.data.hasReply,
"data-layout": eventTileSnapshot.root.data.layout,
"data-self": eventTileSnapshot.root.data.isOwnEvent,
"data-event-id": eventTileSnapshot.root.data.eventId,
},
[
<div className="mx_EventTile_senderDetails" key="mx_EventTile_senderDetails">
@@ -1136,7 +1085,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{renderTile(
TimelineRenderingType.Thread,
this.createRenderTileProps({
replacingEventId,
replacingEventId: eventTileSnapshot.event.replacingEventId,
isSeeingThroughMessageHiddenForModeration,
permalinkCreator: this.props.permalinkCreator!,
}),
@@ -1169,10 +1118,10 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{
...this.createInteractiveRootAttributes(rootRenderState),
"tabIndex": -1,
"data-layout": this.props.layout,
"data-shape": this.context.timelineRenderingType,
"data-self": isOwnEvent,
"data-has-reply": !!replyChain,
"data-layout": eventTileSnapshot.root.data.layout,
"data-shape": eventTileSnapshot.root.data.shape,
"data-self": eventTileSnapshot.root.data.isOwnEvent,
"data-has-reply": eventTileSnapshot.root.data.hasReply,
"onClick": (ev: MouseEvent) => {
const target = ev.currentTarget as HTMLElement;
let index = -1;
@@ -1210,7 +1159,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{timestamp}
<UnreadNotificationBadge
room={room || undefined}
threadId={this.props.mxEvent.getId()}
threadId={eventTileSnapshot.root.data.eventId}
forceDot={true}
/>
</div>
@@ -1274,10 +1223,10 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{
...this.createInteractiveRootAttributes(rootRenderState),
"tabIndex": -1,
"data-layout": this.props.layout,
"data-self": isOwnEvent,
"data-event-id": this.props.mxEvent.getId(),
"data-has-reply": !!replyChain,
"data-layout": eventTileSnapshot.root.data.layout,
"data-self": eventTileSnapshot.root.data.isOwnEvent,
"data-event-id": eventTileSnapshot.root.data.eventId,
"data-has-reply": eventTileSnapshot.root.data.hasReply,
},
<>
{ircTimestamp}
@@ -1295,7 +1244,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{groupPadlock}
{replyChain}
{renderTile(
this.context.timelineRenderingType,
eventTileSnapshot.root.data.shape,
this.createRenderTileProps({
isSeeingThroughMessageHiddenForModeration,
}),
@@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details.
import classNames from "classnames";
import { BaseViewModel } from "@element-hq/web-shared-components";
import { EventType, MsgType, type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import {
type EventTileSenderProfileState,
@@ -42,6 +43,9 @@ import {
type E2eMessageSharedIconViewModelProps,
} from "./E2eMessageSharedIconViewModel";
import { EventPreviewViewModel, type EventPreviewViewModelProps } from "./EventPreviewViewModel";
import { getEventTileReplyChainState } from "./EventTileReplyChainState";
import { getEventDisplayInfo } from "../../../../utils/EventRenderingUtils";
import { haveRendererForEvent } from "../../../../events/EventTileFactory";
import {
ThreadListActionBarViewModel,
type ThreadListActionBarViewModelProps,
@@ -51,6 +55,18 @@ import { ReactionsRowViewModel, type ReactionsRowViewModelProps } from "./reacti
/** Event-level inputs for deriving the EventTile snapshot. */
export interface EventTileEventInput {
/** Whether the event is in a pending send state. */
isSending: boolean;
/** Whether EventTile should announce updates in an aria-live region. */
ariaLive?: "off";
/** Whether the event is currently being edited. */
isEditing: boolean;
/** Whether the tile is rendering for export. */
forExport?: boolean;
}
/** Event-level inputs after SDK data has been converted to pure values. */
export interface EventTileDerivedEventInput extends EventTileEventInput {
/** The event type rendered by the tile. */
eventType: string;
/** The Matrix message type rendered by the tile. */
@@ -59,24 +75,26 @@ export interface EventTileEventInput {
eventTs: number;
/** The stable event identifier, when available. */
eventId?: string;
/** The event identifier replaced by this event, when available. */
replacingEventId?: string;
/** Whether the event is a state event. */
isState: boolean;
/** Whether the event is a local echo. */
isLocalEcho: boolean;
/** Whether the event is in a pending send state. */
isSending: boolean;
/** Whether EventTile should announce updates in an aria-live region. */
ariaLive?: "off";
/** Whether the event is a room create event. */
isRoomCreate: boolean;
/** Whether the event is a call invite. */
isCallInvite: boolean;
/** Whether the event is an RTC notification. */
isRtcNotification: boolean;
/** Whether the event is currently being edited. */
isEditing: boolean;
/** Whether the event failed decryption. */
isEncryptionFailure: boolean;
/** Whether the tile is rendering for export. */
forExport?: boolean;
/** Whether a renderer is available for the event. */
hasRenderer: boolean;
/** Whether the event should be rendered through the moderation fallback. */
isSeeingThroughMessageHiddenForModeration: boolean;
/** Whether EventTile should render the reply chain. */
hasReplyChain: boolean;
}
/** Display inputs for deriving the EventTile snapshot. */
@@ -90,15 +108,15 @@ export interface EventTileDisplayInput {
/** Whether the event body is likely to render media content. */
isProbablyMedia: boolean;
/** Whether the tile should use bubble container styling. */
isBubbleMessage: boolean;
isBubbleMessage?: boolean;
/** Whether the bubble tile is left-aligned. */
isLeftAlignedBubbleMessage: boolean;
isLeftAlignedBubbleMessage?: boolean;
/** Whether the event is aligned between bubble columns. */
isAlignedBetweenBubbles: boolean;
isAlignedBetweenBubbles?: boolean;
/** Whether the event renders as an informational timeline item. */
isInfoMessage: boolean;
isInfoMessage?: boolean;
/** Whether bubble styling should be suppressed for this event. */
noBubbleEvent: boolean;
noBubbleEvent?: boolean;
/** Whether timestamps use twelve-hour formatting. */
isTwelveHour?: boolean;
/** Whether the event should be highlighted. */
@@ -113,6 +131,15 @@ export interface EventTileDisplayInput {
isContextual?: boolean;
}
/** Event display inputs after renderer and event display information has been normalized. */
export interface EventTileDerivedDisplayInput extends EventTileDisplayInput {
isBubbleMessage: boolean;
isLeftAlignedBubbleMessage: boolean;
isAlignedBetweenBubbles: boolean;
isInfoMessage: boolean;
noBubbleEvent: boolean;
}
/** Interaction inputs for deriving the EventTile snapshot. */
export interface EventTileInteractionInput {
/** Whether the tile is currently hovered. */
@@ -138,7 +165,7 @@ export interface EventTileSenderInput {
/** Whether sender details should be hidden. */
hideSender?: boolean;
/** Whether the event body renders as an emote. */
isEmote: boolean;
isEmote?: boolean;
}
/** Timestamp inputs for deriving the EventTile snapshot. */
@@ -179,12 +206,44 @@ export interface EventTileViewModelProps {
footer: EventTileFooterInput;
}
/** Pure EventTile inputs after event and sender data has been normalized. */
export interface NormalizedEventTileViewModelProps {
event: EventTileDerivedEventInput;
display: EventTileDerivedDisplayInput;
interaction: EventTileInteractionInput;
sender: EventTileSenderInput;
timestamp: EventTileTimestampInput;
footer: EventTileFooterInput;
}
/** Application dependencies used by EventTileViewModel to derive render data. */
export interface EventTileViewModelDependencies {
/** The Matrix event being rendered. */
mxEvent: MatrixEvent;
/** Matrix client used to select the event renderer. */
matrixClient: MatrixClient;
/** Whether hidden events should use their fallback renderer. */
showHiddenEvents: boolean;
/** Whether the event is hidden by the current tile context. */
hideEvent?: boolean;
}
/** Event-level state derived for the EventTile snapshot. */
export interface EventTileEventSnapshot {
/** The Matrix event type. */
eventType: string;
/** The Matrix message type. */
msgtype?: string;
/** The stable event identifier, when available. */
eventId?: string;
/** The event identifier replaced by this event, when available. */
replacingEventId?: string;
/** Whether the event is a state event. */
isState: boolean;
/** The event origin timestamp. */
eventTs: number;
/** Whether the event is a local echo. */
isLocalEcho: boolean;
/** Whether the event is in a pending send state. */
isSending: boolean;
/** Whether the event is currently being edited. */
@@ -193,6 +252,26 @@ export interface EventTileEventSnapshot {
isContinuation?: boolean;
/** Whether the tile is rendering as a notification. */
isRenderingNotification: boolean;
/** Whether the event failed decryption. */
isEncryptionFailure: boolean;
/** Whether a renderer is available for the event. */
hasRenderer: boolean;
/** Whether the event should be rendered through the moderation fallback. */
isSeeingThroughMessageHiddenForModeration: boolean;
}
/** Plain data attributes rendered on the EventTile root element. */
export interface EventTileRootData {
/** The event identifier exposed through `data-event-id`. */
eventId?: string;
/** The configured tile layout exposed through `data-layout`. */
layout?: Layout;
/** The timeline rendering mode exposed through `data-shape`. */
shape: TimelineRenderingType;
/** Whether the event belongs to the current user, exposed through `data-self`. */
isOwnEvent: boolean;
/** Whether EventTile renders a reply chain, exposed through `data-has-reply`. */
hasReply: boolean;
}
/** Root state derived for the EventTile snapshot. */
@@ -201,6 +280,8 @@ export interface EventTileRootSnapshot {
ariaLive?: "off";
/** The stable scroll token for the event. */
scrollToken?: string;
/** Plain data attributes used by the EventTile root element. */
data: EventTileRootData;
/** EventTile root CSS class flags. */
classState: ReturnType<typeof getEventTileClassState>;
}
@@ -288,6 +369,8 @@ export interface EventTileRenderState {
scrollToken?: string;
/** Whether the tile is rendering as a notification. */
isRenderingNotification: boolean;
/** Plain data attributes used by the EventTile root element. */
data: EventTileRootData;
};
/** EventTile line render state. */
line: {
@@ -319,7 +402,11 @@ export interface EventTileRenderState {
};
}
/** Derives the current EventTile snapshot from component-owned inputs. */
/**
* Aggregate application-side render-state boundary for EventTile.
*
* SDK objects are converted to plain render data here before the existing render tree consumes it.
*/
export class EventTileViewModel extends BaseViewModel<EventTileRenderState, EventTileViewModelProps> {
private messageTimestampViewModel?: MessageTimestampViewModel;
private linkedMessageTimestampViewModel?: MessageTimestampViewModel;
@@ -331,16 +418,18 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
private actionBarViewModel?: EventTileActionBarViewModel;
private reactionsRowViewModel?: ReactionsRowViewModel;
public constructor(props: EventTileViewModelProps) {
const initialRenderState = EventTileViewModel.createRenderState(props);
public constructor(dependencies: EventTileViewModelDependencies, props: EventTileViewModelProps) {
const normalizedProps = EventTileViewModel.normalizeDependencies(dependencies, props);
const initialRenderState = EventTileViewModel.createRenderState(normalizedProps);
super(props, initialRenderState);
super(normalizedProps, initialRenderState);
}
/** Updates root EventTile inputs and refreshes the derived render state. */
public setProps(props: EventTileViewModelProps): void {
this.props = props;
this.snapshot.set(EventTileViewModel.createRenderState(props));
/** Updates dependencies and root inputs together, emitting one consistent render state. */
public setInputs(dependencies: EventTileViewModelDependencies, props: EventTileViewModelProps): void {
const normalizedProps = EventTileViewModel.normalizeDependencies(dependencies, props);
this.props = normalizedProps;
this.snapshot.set(EventTileViewModel.createRenderState(normalizedProps));
}
public override dispose(): void {
@@ -447,7 +536,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
}
/** Derives render-ready EventTile state from component-owned inputs. */
public static createRenderState(props: EventTileViewModelProps): EventTileRenderState {
public static createRenderState(props: NormalizedEventTileViewModelProps): EventTileRenderState {
const snapshot = EventTileViewModel.createSnapshot(props);
const useIRCLayout = snapshot.timestamp.displayState.useIRCLayout;
const showPadlock = !props.display.isBubbleMessage;
@@ -459,6 +548,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
ariaLive: snapshot.root.ariaLive,
scrollToken: snapshot.root.scrollToken,
isRenderingNotification: snapshot.event.isRenderingNotification,
data: snapshot.root.data,
},
line: {
className: classNames("mx_EventTile_line", snapshot.line.classState),
@@ -481,18 +571,78 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
};
}
/**
* Derives pure inputs from application dependencies while keeping the VM's public props SDK-free.
*/
private static normalizeDependencies(
dependencies: EventTileViewModelDependencies,
props: EventTileViewModelProps,
): NormalizedEventTileViewModelProps {
const { mxEvent } = dependencies;
const eventType = mxEvent.getType();
const displayInfo = getEventDisplayInfo(
dependencies.matrixClient,
mxEvent,
dependencies.showHiddenEvents,
dependencies.hideEvent,
);
const replyChainState = getEventTileReplyChainState({
mxEvent,
// Replacement events have a fallback tile but must not show their own reply chain
hasRenderer: haveRendererForEvent(mxEvent, dependencies.matrixClient, dependencies.showHiddenEvents),
});
return {
...props,
event: {
...props.event,
eventType,
msgtype: mxEvent.getContent().msgtype,
eventTs: mxEvent.getTs(),
eventId: mxEvent.getId() ?? undefined,
replacingEventId: mxEvent.replacingEventId() ?? undefined,
isState: mxEvent.isState(),
isLocalEcho: !!mxEvent.status,
isRoomCreate: eventType === EventType.RoomCreate,
isCallInvite: eventType === EventType.CallInvite,
isRtcNotification: eventType === EventType.RTCNotification,
isEncryptionFailure: mxEvent.isDecryptionFailure(),
hasRenderer: displayInfo.hasRenderer,
isSeeingThroughMessageHiddenForModeration: displayInfo.isSeeingThroughMessageHiddenForModeration,
hasReplyChain: replyChainState.shouldShowReplyChain,
},
display: {
...props.display,
...displayInfo,
},
sender: {
...props.sender,
senderId: mxEvent.getSender() ?? undefined,
isEmote: mxEvent.getContent().msgtype === MsgType.Emote,
},
};
}
/** Creates an EventTile view model snapshot. */
public static createSnapshot(props: EventTileViewModelProps): EventTileViewModelSnapshot {
public static createSnapshot(props: NormalizedEventTileViewModelProps): EventTileViewModelSnapshot {
const { event, display, interaction, sender, timestamp, footer } = props;
const isContinuation = getIsContinuation(display.continuation, display.timelineRenderingType, display.layout);
const isRenderingNotification = display.timelineRenderingType === TimelineRenderingType.Notification;
const eventSnapshot: EventTileEventSnapshot = {
eventType: event.eventType,
msgtype: event.msgtype,
eventId: event.eventId,
replacingEventId: event.replacingEventId,
isState: event.isState,
eventTs: event.eventTs,
isLocalEcho: event.isLocalEcho,
isSending: event.isSending,
isEditing: event.isEditing,
isContinuation,
isRenderingNotification,
isEncryptionFailure: event.isEncryptionFailure,
hasRenderer: event.hasRenderer,
isSeeingThroughMessageHiddenForModeration: event.isSeeingThroughMessageHiddenForModeration,
};
const senderProfileState = getEventTileSenderProfileState({
isRenderingNotification,
@@ -531,6 +681,13 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
eventId: event.eventId,
isLocalEcho: event.isLocalEcho,
}),
data: {
eventId: event.eventId,
layout: display.layout,
shape: display.timelineRenderingType,
isOwnEvent: footer.isOwnEvent,
hasReply: event.hasReplyChain,
},
classState: EventTileViewModel.getClassState({
event,
display,
@@ -565,7 +722,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
timelineRenderingType: display.timelineRenderingType,
}),
forceHistoricalAvatar: event.eventType === "m.room.member",
isEmote: sender.isEmote,
isEmote: sender.isEmote ?? false,
},
actionBar: {
show: getShouldShowMessageActionBar({
@@ -616,8 +773,8 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
isContinuation,
isRenderingNotification,
}: {
event: EventTileEventInput;
display: EventTileDisplayInput;
event: EventTileDerivedEventInput;
display: EventTileDerivedDisplayInput;
interaction: EventTileInteractionInput;
sender: EventTileSenderInput;
eventType: string;