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"; } from "react";
import { import {
EventStatus, EventStatus,
EventType,
MsgType,
type MatrixEvent, type MatrixEvent,
MatrixEventEvent, MatrixEventEvent,
type Relations, type Relations,
@@ -50,19 +48,13 @@ import { type ComposerInsertPayload } from "../../../dispatcher/payloads/Compose
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import PlatformPeg from "../../../PlatformPeg"; import PlatformPeg from "../../../PlatformPeg";
import { type IReadReceiptPosition } from "./ReadReceiptMarker"; import { type IReadReceiptPosition } from "./ReadReceiptMarker";
import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { copyPlaintext } from "../../../utils/strings"; import { copyPlaintext } from "../../../utils/strings";
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker"; import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import PosthogTrackers from "../../../PosthogTrackers"; import PosthogTrackers from "../../../PosthogTrackers";
import { import { isMessageEvent, renderTile, type EventTileTypeProps } from "../../../events/EventTileFactory";
haveRendererForEvent,
isMessageEvent,
renderTile,
type EventTileTypeProps,
} from "../../../events/EventTileFactory";
import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge"; import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper"; import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
@@ -80,7 +72,9 @@ import { EventTileThreadInfo, EventTileThreadPanelSummary } from "./EventTile/Ev
import { EventTileTimestampSlot } from "./EventTile/EventTileTimestampSlot"; import { EventTileTimestampSlot } from "./EventTile/EventTileTimestampSlot";
import { import {
EventTileViewModel, EventTileViewModel,
type EventTileRenderState,
type EventTileViewModelProps, type EventTileViewModelProps,
type EventTileViewModelDependencies,
} from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
import { import {
getEventTileReceiptState, getEventTileReceiptState,
@@ -91,7 +85,6 @@ import {
getEventTileThreadState, getEventTileThreadState,
type EventTileThreadState, type EventTileThreadState,
} from "../../../viewmodels/room/timeline/event-tile/EventTileThreadState"; } from "../../../viewmodels/room/timeline/event-tile/EventTileThreadState";
import { getEventTileReplyChainState } from "../../../viewmodels/room/timeline/event-tile/EventTileReplyChainState";
import { import {
eventTileActionBarFocusChange, eventTileActionBarFocusChange,
eventTileBlurWithin, eventTileBlurWithin,
@@ -279,19 +272,12 @@ interface IState {
} }
interface EventTileRenderInputs { interface EventTileRenderInputs {
displayInfo: ReturnType<typeof getEventDisplayInfo>;
hasPinnedMessageBadge: boolean; hasPinnedMessageBadge: boolean;
hasReactionsRow: boolean; hasReactionsRow: boolean;
threadState: EventTileThreadState; threadState: EventTileThreadState;
isOwnEvent: boolean; isOwnEvent: boolean;
} }
interface EventTileRootRenderState {
tileClasses: string;
tileAriaLive?: "off";
scrollToken?: string;
}
/** EventTile implementation rendered inside a RoomContext with `timelineRenderingType` set. */ /** EventTile implementation rendered inside a RoomContext with `timelineRenderingType` set. */
export class UnwrappedEventTile extends React.Component<EventTileProps, IState> { export class UnwrappedEventTile extends React.Component<EventTileProps, IState> {
private suppressReadReceiptAnimation: boolean; private suppressReadReceiptAnimation: boolean;
@@ -332,7 +318,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
thread, thread,
}; };
this.viewModel = new EventTileViewModel(this.createViewModelProps()); this.viewModel = new EventTileViewModel(this.createViewModelDependencies(), this.createViewModelProps());
this.e2eViewModel = new EventTileE2eViewModel({ this.e2eViewModel = new EventTileE2eViewModel({
cli: MatrixClientPeg.safeGet(), cli: MatrixClientPeg.safeGet(),
@@ -795,19 +781,19 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
} }
private createRootAttributes({ private createRootAttributes({
tileClasses, className,
tileAriaLive, ariaLive,
scrollToken, scrollToken,
}: EventTileRootRenderState): Record<string, unknown> { }: EventTileRenderState["root"]): Record<string, unknown> {
return { return {
"className": tileClasses, "className": className,
"aria-live": tileAriaLive, "aria-live": ariaLive,
"aria-atomic": true, "aria-atomic": true,
"data-scroll-tokens": scrollToken, "data-scroll-tokens": scrollToken,
}; };
} }
private createInteractiveRootAttributes(rootRenderState: EventTileRootRenderState): Record<string, unknown> { private createInteractiveRootAttributes(rootRenderState: EventTileRenderState["root"]): Record<string, unknown> {
return { return {
...this.createRootAttributes(rootRenderState), ...this.createRootAttributes(rootRenderState),
ref: this.ref, ref: this.ref,
@@ -839,14 +825,16 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
}; };
} }
private createRenderInputs( private createViewModelDependencies(): EventTileViewModelDependencies {
displayInfo = getEventDisplayInfo( return {
MatrixClientPeg.safeGet(), mxEvent: this.props.mxEvent,
this.props.mxEvent, matrixClient: MatrixClientPeg.safeGet(),
this.context.showHiddenEvents, showHiddenEvents: this.context.showHiddenEvents,
shouldHideEventTile({ callEventGrouper: this.props.callEventGrouper }), hideEvent: shouldHideEventTile({ callEventGrouper: this.props.callEventGrouper }),
), };
): EventTileRenderInputs { }
private createRenderInputs(): EventTileRenderInputs {
const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted; const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted;
const hasPinnedMessageBadge = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent); const hasPinnedMessageBadge = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
const hasReactionsRow = !isRedacted; const hasReactionsRow = !isRedacted;
@@ -855,7 +843,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.safeGet().getUserId(); const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.safeGet().getUserId();
return { return {
displayInfo,
hasPinnedMessageBadge, hasPinnedMessageBadge,
hasReactionsRow, hasReactionsRow,
threadState, threadState,
@@ -864,12 +851,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
} }
private createViewModelProps(inputs: EventTileRenderInputs = this.createRenderInputs()): EventTileViewModelProps { 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 isProbablyMedia = MediaEventHelper.isEligible(this.props.mxEvent);
const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure();
const isEditing = !!this.props.editState; const isEditing = !!this.props.editState;
const eventType = this.props.mxEvent.getType();
const msgtype = this.props.mxEvent.getContent().msgtype;
const isSending = const isSending =
this.props.eventSendStatus === EventStatus.SENDING || this.props.eventSendStatus === EventStatus.SENDING ||
this.props.eventSendStatus === EventStatus.QUEUED || this.props.eventSendStatus === EventStatus.QUEUED ||
@@ -877,18 +861,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
return { return {
event: { event: {
eventType,
msgtype,
eventTs: this.props.mxEvent.getTs(),
eventId: this.props.mxEvent.getId() ?? undefined,
isLocalEcho: !!this.props.mxEvent.status,
isSending, isSending,
ariaLive: this.props.eventSendStatus === null ? undefined : "off", ariaLive: this.props.eventSendStatus === null ? undefined : "off",
isRoomCreate: eventType === EventType.RoomCreate,
isCallInvite: eventType === EventType.CallInvite,
isRtcNotification: eventType === EventType.RTCNotification,
isEditing, isEditing,
isEncryptionFailure,
forExport: this.props.forExport, forExport: this.props.forExport,
}, },
display: { display: {
@@ -896,11 +871,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
layout: this.props.layout, layout: this.props.layout,
continuation: this.props.continuation, continuation: this.props.continuation,
isProbablyMedia, isProbablyMedia,
isBubbleMessage: displayInfo.isBubbleMessage,
isLeftAlignedBubbleMessage: displayInfo.isLeftAlignedBubbleMessage,
isAlignedBetweenBubbles: displayInfo.isAlignedBetweenBubbles,
isInfoMessage: displayInfo.isInfoMessage,
noBubbleEvent: displayInfo.noBubbleEvent,
isTwelveHour: this.props.isTwelveHour, isTwelveHour: this.props.isTwelveHour,
isHighlighted: shouldHighlightEventTile({ isHighlighted: shouldHighlightEventTile({
cli: MatrixClientPeg.safeGet(), cli: MatrixClientPeg.safeGet(),
@@ -923,7 +893,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
inhibitInteraction: this.props.inhibitInteraction, inhibitInteraction: this.props.inhibitInteraction,
}, },
sender: { sender: {
senderId: this.props.mxEvent.getSender() ?? undefined,
member: roomMemberToMemberInfo( member: roomMemberToMemberInfo(
this.props.useEventSenderSnapshot this.props.useEventSenderSnapshot
? this.getAvatarMember() ? this.getAvatarMember()
@@ -936,7 +905,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
}), }),
), ),
hideSender: this.props.hideSender, hideSender: this.props.hideSender,
isEmote: this.props.mxEvent.getContent().msgtype === MsgType.Emote,
}, },
timestamp: { timestamp: {
alwaysShowTimestamps: this.props.alwaysShowTimestamps, alwaysShowTimestamps: this.props.alwaysShowTimestamps,
@@ -977,22 +945,20 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
} }
public render(): ReactNode { 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 { 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 // This shouldn't happen: the caller should check we support this type
// before trying to instantiate us // before trying to instantiate us
if (!hasRenderer) { if (!eventTileSnapshot.event.hasRenderer) {
const { mxEvent } = this.props; logger.warn(
logger.warn(`Event type not supported: type:${eventType} isState:${mxEvent.isState()}`); `Event type not supported: type:${eventTileSnapshot.event.eventType} isState:${eventTileSnapshot.event.isState}`,
);
return ( return (
<div className="mx_EventTile mx_EventTile_info mx_MNoticeBody"> <div className="mx_EventTile mx_EventTile_info mx_MNoticeBody">
<div className="mx_EventTile_line">{_t("timeline|error_no_renderer")}</div> <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 lineClasses = eventTileRenderState.line.className;
const tileClasses = eventTileRenderState.root.className; const isRenderingNotification = eventTileSnapshot.event.isRenderingNotification;
const tileAriaLive = eventTileRenderState.root.ariaLive; const isSeeingThroughMessageHiddenForModeration =
const isRenderingNotification = eventTileRenderState.root.isRenderingNotification; eventTileSnapshot.event.isSeeingThroughMessageHiddenForModeration;
const permalink = this.getPermalink(); const permalink = this.getPermalink();
const rootRenderState = eventTileRenderState.root;
const scrollToken = eventTileRenderState.root.scrollToken;
const rootRenderState = { tileClasses, tileAriaLive, scrollToken };
const avatarMember = this.getAvatarMember(); const avatarMember = this.getAvatarMember();
const avatar = <EventTileAvatarAdapter avatarMember={avatarMember} senderSnapshot={eventTileSnapshot.sender} />; 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; let replyChain: JSX.Element | undefined;
if (replyChainState.shouldShowReplyChain) { if (eventTileSnapshot.root.data.hasReply) {
replyChain = ( replyChain = (
<ReplyChain <ReplyChain
parentEv={this.props.mxEvent} parentEv={this.props.mxEvent}
@@ -1115,10 +1064,10 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
this.props.as || "li", this.props.as || "li",
{ {
...this.createInteractiveRootAttributes(rootRenderState), ...this.createInteractiveRootAttributes(rootRenderState),
"data-has-reply": !!replyChain, "data-has-reply": eventTileSnapshot.root.data.hasReply,
"data-layout": this.props.layout, "data-layout": eventTileSnapshot.root.data.layout,
"data-self": isOwnEvent, "data-self": eventTileSnapshot.root.data.isOwnEvent,
"data-event-id": this.props.mxEvent.getId(), "data-event-id": eventTileSnapshot.root.data.eventId,
}, },
[ [
<div className="mx_EventTile_senderDetails" key="mx_EventTile_senderDetails"> <div className="mx_EventTile_senderDetails" key="mx_EventTile_senderDetails">
@@ -1136,7 +1085,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{renderTile( {renderTile(
TimelineRenderingType.Thread, TimelineRenderingType.Thread,
this.createRenderTileProps({ this.createRenderTileProps({
replacingEventId, replacingEventId: eventTileSnapshot.event.replacingEventId,
isSeeingThroughMessageHiddenForModeration, isSeeingThroughMessageHiddenForModeration,
permalinkCreator: this.props.permalinkCreator!, permalinkCreator: this.props.permalinkCreator!,
}), }),
@@ -1169,10 +1118,10 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{ {
...this.createInteractiveRootAttributes(rootRenderState), ...this.createInteractiveRootAttributes(rootRenderState),
"tabIndex": -1, "tabIndex": -1,
"data-layout": this.props.layout, "data-layout": eventTileSnapshot.root.data.layout,
"data-shape": this.context.timelineRenderingType, "data-shape": eventTileSnapshot.root.data.shape,
"data-self": isOwnEvent, "data-self": eventTileSnapshot.root.data.isOwnEvent,
"data-has-reply": !!replyChain, "data-has-reply": eventTileSnapshot.root.data.hasReply,
"onClick": (ev: MouseEvent) => { "onClick": (ev: MouseEvent) => {
const target = ev.currentTarget as HTMLElement; const target = ev.currentTarget as HTMLElement;
let index = -1; let index = -1;
@@ -1210,7 +1159,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{timestamp} {timestamp}
<UnreadNotificationBadge <UnreadNotificationBadge
room={room || undefined} room={room || undefined}
threadId={this.props.mxEvent.getId()} threadId={eventTileSnapshot.root.data.eventId}
forceDot={true} forceDot={true}
/> />
</div> </div>
@@ -1274,10 +1223,10 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{ {
...this.createInteractiveRootAttributes(rootRenderState), ...this.createInteractiveRootAttributes(rootRenderState),
"tabIndex": -1, "tabIndex": -1,
"data-layout": this.props.layout, "data-layout": eventTileSnapshot.root.data.layout,
"data-self": isOwnEvent, "data-self": eventTileSnapshot.root.data.isOwnEvent,
"data-event-id": this.props.mxEvent.getId(), "data-event-id": eventTileSnapshot.root.data.eventId,
"data-has-reply": !!replyChain, "data-has-reply": eventTileSnapshot.root.data.hasReply,
}, },
<> <>
{ircTimestamp} {ircTimestamp}
@@ -1295,7 +1244,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{groupPadlock} {groupPadlock}
{replyChain} {replyChain}
{renderTile( {renderTile(
this.context.timelineRenderingType, eventTileSnapshot.root.data.shape,
this.createRenderTileProps({ this.createRenderTileProps({
isSeeingThroughMessageHiddenForModeration, isSeeingThroughMessageHiddenForModeration,
}), }),
@@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details.
import classNames from "classnames"; import classNames from "classnames";
import { BaseViewModel } from "@element-hq/web-shared-components"; import { BaseViewModel } from "@element-hq/web-shared-components";
import { EventType, MsgType, type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { import {
type EventTileSenderProfileState, type EventTileSenderProfileState,
@@ -42,6 +43,9 @@ import {
type E2eMessageSharedIconViewModelProps, type E2eMessageSharedIconViewModelProps,
} from "./E2eMessageSharedIconViewModel"; } from "./E2eMessageSharedIconViewModel";
import { EventPreviewViewModel, type EventPreviewViewModelProps } from "./EventPreviewViewModel"; import { EventPreviewViewModel, type EventPreviewViewModelProps } from "./EventPreviewViewModel";
import { getEventTileReplyChainState } from "./EventTileReplyChainState";
import { getEventDisplayInfo } from "../../../../utils/EventRenderingUtils";
import { haveRendererForEvent } from "../../../../events/EventTileFactory";
import { import {
ThreadListActionBarViewModel, ThreadListActionBarViewModel,
type ThreadListActionBarViewModelProps, type ThreadListActionBarViewModelProps,
@@ -51,6 +55,18 @@ import { ReactionsRowViewModel, type ReactionsRowViewModelProps } from "./reacti
/** Event-level inputs for deriving the EventTile snapshot. */ /** Event-level inputs for deriving the EventTile snapshot. */
export interface EventTileEventInput { 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. */ /** The event type rendered by the tile. */
eventType: string; eventType: string;
/** The Matrix message type rendered by the tile. */ /** The Matrix message type rendered by the tile. */
@@ -59,24 +75,26 @@ export interface EventTileEventInput {
eventTs: number; eventTs: number;
/** The stable event identifier, when available. */ /** The stable event identifier, when available. */
eventId?: string; 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. */ /** Whether the event is a local echo. */
isLocalEcho: boolean; 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. */ /** Whether the event is a room create event. */
isRoomCreate: boolean; isRoomCreate: boolean;
/** Whether the event is a call invite. */ /** Whether the event is a call invite. */
isCallInvite: boolean; isCallInvite: boolean;
/** Whether the event is an RTC notification. */ /** Whether the event is an RTC notification. */
isRtcNotification: boolean; isRtcNotification: boolean;
/** Whether the event is currently being edited. */
isEditing: boolean;
/** Whether the event failed decryption. */ /** Whether the event failed decryption. */
isEncryptionFailure: boolean; isEncryptionFailure: boolean;
/** Whether the tile is rendering for export. */ /** Whether a renderer is available for the event. */
forExport?: boolean; 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. */ /** Display inputs for deriving the EventTile snapshot. */
@@ -90,15 +108,15 @@ export interface EventTileDisplayInput {
/** Whether the event body is likely to render media content. */ /** Whether the event body is likely to render media content. */
isProbablyMedia: boolean; isProbablyMedia: boolean;
/** Whether the tile should use bubble container styling. */ /** Whether the tile should use bubble container styling. */
isBubbleMessage: boolean; isBubbleMessage?: boolean;
/** Whether the bubble tile is left-aligned. */ /** Whether the bubble tile is left-aligned. */
isLeftAlignedBubbleMessage: boolean; isLeftAlignedBubbleMessage?: boolean;
/** Whether the event is aligned between bubble columns. */ /** Whether the event is aligned between bubble columns. */
isAlignedBetweenBubbles: boolean; isAlignedBetweenBubbles?: boolean;
/** Whether the event renders as an informational timeline item. */ /** Whether the event renders as an informational timeline item. */
isInfoMessage: boolean; isInfoMessage?: boolean;
/** Whether bubble styling should be suppressed for this event. */ /** Whether bubble styling should be suppressed for this event. */
noBubbleEvent: boolean; noBubbleEvent?: boolean;
/** Whether timestamps use twelve-hour formatting. */ /** Whether timestamps use twelve-hour formatting. */
isTwelveHour?: boolean; isTwelveHour?: boolean;
/** Whether the event should be highlighted. */ /** Whether the event should be highlighted. */
@@ -113,6 +131,15 @@ export interface EventTileDisplayInput {
isContextual?: boolean; 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. */ /** Interaction inputs for deriving the EventTile snapshot. */
export interface EventTileInteractionInput { export interface EventTileInteractionInput {
/** Whether the tile is currently hovered. */ /** Whether the tile is currently hovered. */
@@ -138,7 +165,7 @@ export interface EventTileSenderInput {
/** Whether sender details should be hidden. */ /** Whether sender details should be hidden. */
hideSender?: boolean; hideSender?: boolean;
/** Whether the event body renders as an emote. */ /** Whether the event body renders as an emote. */
isEmote: boolean; isEmote?: boolean;
} }
/** Timestamp inputs for deriving the EventTile snapshot. */ /** Timestamp inputs for deriving the EventTile snapshot. */
@@ -179,12 +206,44 @@ export interface EventTileViewModelProps {
footer: EventTileFooterInput; 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. */ /** Event-level state derived for the EventTile snapshot. */
export interface EventTileEventSnapshot { export interface EventTileEventSnapshot {
/** The Matrix event type. */ /** The Matrix event type. */
eventType: string; eventType: string;
/** The Matrix message type. */ /** The Matrix message type. */
msgtype?: string; 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. */ /** Whether the event is in a pending send state. */
isSending: boolean; isSending: boolean;
/** Whether the event is currently being edited. */ /** Whether the event is currently being edited. */
@@ -193,6 +252,26 @@ export interface EventTileEventSnapshot {
isContinuation?: boolean; isContinuation?: boolean;
/** Whether the tile is rendering as a notification. */ /** Whether the tile is rendering as a notification. */
isRenderingNotification: boolean; 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. */ /** Root state derived for the EventTile snapshot. */
@@ -201,6 +280,8 @@ export interface EventTileRootSnapshot {
ariaLive?: "off"; ariaLive?: "off";
/** The stable scroll token for the event. */ /** The stable scroll token for the event. */
scrollToken?: string; scrollToken?: string;
/** Plain data attributes used by the EventTile root element. */
data: EventTileRootData;
/** EventTile root CSS class flags. */ /** EventTile root CSS class flags. */
classState: ReturnType<typeof getEventTileClassState>; classState: ReturnType<typeof getEventTileClassState>;
} }
@@ -288,6 +369,8 @@ export interface EventTileRenderState {
scrollToken?: string; scrollToken?: string;
/** Whether the tile is rendering as a notification. */ /** Whether the tile is rendering as a notification. */
isRenderingNotification: boolean; isRenderingNotification: boolean;
/** Plain data attributes used by the EventTile root element. */
data: EventTileRootData;
}; };
/** EventTile line render state. */ /** EventTile line render state. */
line: { 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> { export class EventTileViewModel extends BaseViewModel<EventTileRenderState, EventTileViewModelProps> {
private messageTimestampViewModel?: MessageTimestampViewModel; private messageTimestampViewModel?: MessageTimestampViewModel;
private linkedMessageTimestampViewModel?: MessageTimestampViewModel; private linkedMessageTimestampViewModel?: MessageTimestampViewModel;
@@ -331,16 +418,18 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
private actionBarViewModel?: EventTileActionBarViewModel; private actionBarViewModel?: EventTileActionBarViewModel;
private reactionsRowViewModel?: ReactionsRowViewModel; private reactionsRowViewModel?: ReactionsRowViewModel;
public constructor(props: EventTileViewModelProps) { public constructor(dependencies: EventTileViewModelDependencies, props: EventTileViewModelProps) {
const initialRenderState = EventTileViewModel.createRenderState(props); 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. */ /** Updates dependencies and root inputs together, emitting one consistent render state. */
public setProps(props: EventTileViewModelProps): void { public setInputs(dependencies: EventTileViewModelDependencies, props: EventTileViewModelProps): void {
this.props = props; const normalizedProps = EventTileViewModel.normalizeDependencies(dependencies, props);
this.snapshot.set(EventTileViewModel.createRenderState(props)); this.props = normalizedProps;
this.snapshot.set(EventTileViewModel.createRenderState(normalizedProps));
} }
public override dispose(): void { public override dispose(): void {
@@ -447,7 +536,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
} }
/** Derives render-ready EventTile state from component-owned inputs. */ /** 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 snapshot = EventTileViewModel.createSnapshot(props);
const useIRCLayout = snapshot.timestamp.displayState.useIRCLayout; const useIRCLayout = snapshot.timestamp.displayState.useIRCLayout;
const showPadlock = !props.display.isBubbleMessage; const showPadlock = !props.display.isBubbleMessage;
@@ -459,6 +548,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
ariaLive: snapshot.root.ariaLive, ariaLive: snapshot.root.ariaLive,
scrollToken: snapshot.root.scrollToken, scrollToken: snapshot.root.scrollToken,
isRenderingNotification: snapshot.event.isRenderingNotification, isRenderingNotification: snapshot.event.isRenderingNotification,
data: snapshot.root.data,
}, },
line: { line: {
className: classNames("mx_EventTile_line", snapshot.line.classState), 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. */ /** 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 { event, display, interaction, sender, timestamp, footer } = props;
const isContinuation = getIsContinuation(display.continuation, display.timelineRenderingType, display.layout); const isContinuation = getIsContinuation(display.continuation, display.timelineRenderingType, display.layout);
const isRenderingNotification = display.timelineRenderingType === TimelineRenderingType.Notification; const isRenderingNotification = display.timelineRenderingType === TimelineRenderingType.Notification;
const eventSnapshot: EventTileEventSnapshot = { const eventSnapshot: EventTileEventSnapshot = {
eventType: event.eventType, eventType: event.eventType,
msgtype: event.msgtype, msgtype: event.msgtype,
eventId: event.eventId,
replacingEventId: event.replacingEventId,
isState: event.isState,
eventTs: event.eventTs,
isLocalEcho: event.isLocalEcho,
isSending: event.isSending, isSending: event.isSending,
isEditing: event.isEditing, isEditing: event.isEditing,
isContinuation, isContinuation,
isRenderingNotification, isRenderingNotification,
isEncryptionFailure: event.isEncryptionFailure,
hasRenderer: event.hasRenderer,
isSeeingThroughMessageHiddenForModeration: event.isSeeingThroughMessageHiddenForModeration,
}; };
const senderProfileState = getEventTileSenderProfileState({ const senderProfileState = getEventTileSenderProfileState({
isRenderingNotification, isRenderingNotification,
@@ -531,6 +681,13 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
eventId: event.eventId, eventId: event.eventId,
isLocalEcho: event.isLocalEcho, isLocalEcho: event.isLocalEcho,
}), }),
data: {
eventId: event.eventId,
layout: display.layout,
shape: display.timelineRenderingType,
isOwnEvent: footer.isOwnEvent,
hasReply: event.hasReplyChain,
},
classState: EventTileViewModel.getClassState({ classState: EventTileViewModel.getClassState({
event, event,
display, display,
@@ -565,7 +722,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
timelineRenderingType: display.timelineRenderingType, timelineRenderingType: display.timelineRenderingType,
}), }),
forceHistoricalAvatar: event.eventType === "m.room.member", forceHistoricalAvatar: event.eventType === "m.room.member",
isEmote: sender.isEmote, isEmote: sender.isEmote ?? false,
}, },
actionBar: { actionBar: {
show: getShouldShowMessageActionBar({ show: getShouldShowMessageActionBar({
@@ -616,8 +773,8 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
isContinuation, isContinuation,
isRenderingNotification, isRenderingNotification,
}: { }: {
event: EventTileEventInput; event: EventTileDerivedEventInput;
display: EventTileDisplayInput; display: EventTileDerivedDisplayInput;
interaction: EventTileInteractionInput; interaction: EventTileInteractionInput;
sender: EventTileSenderInput; sender: EventTileSenderInput;
eventType: string; eventType: string;
@@ -292,6 +292,15 @@ describe("EventTile", () => {
expect(getTile(container)).toContainElement(getLine(container)); expect(getTile(container)).toContainElement(getLine(container));
}); });
it("preserves the existing root and line markup", () => {
const { container } = getComponent();
const tile = getTile(container);
expect(tile.tagName).toBe("LI");
expect(tile).toContainElement(getLine(container));
expect(getLine(container)).toHaveClass("mx_EventTile_line");
});
it("does not expose a scroll token for local echo events", () => { it("does not expose a scroll token for local echo events", () => {
const localEcho = makeOwnMessage(); const localEcho = makeOwnMessage();
localEcho.setStatus(EventStatus.SENDING); localEcho.setStatus(EventStatus.SENDING);
@@ -7,14 +7,36 @@
import { TimelineRenderingType } from "../../../src/contexts/RoomContext"; import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
import { Layout } from "../../../src/settings/enums/Layout"; import { Layout } from "../../../src/settings/enums/Layout";
import { mkEvent, stubClient } from "../../test-utils";
import { import {
EventTileViewModel, EventTileViewModel,
type EventTileViewModelDependencies,
type NormalizedEventTileViewModelProps,
type EventTileViewModelProps, type EventTileViewModelProps,
} from "../../../src/viewmodels/room/timeline/event-tile/EventTileViewModel"; } from "../../../src/viewmodels/room/timeline/event-tile/EventTileViewModel";
describe("EventTileViewModel", () => { describe("EventTileViewModel", () => {
const matrixClient = stubClient();
const makeEvent = () =>
mkEvent({
event: true,
id: "$event",
room: "!room:example.org",
ts: 123,
type: "m.room.message",
user: "@alice:example.org",
content: { msgtype: "m.text" },
});
const makeDependencies = (mxEvent = makeEvent()): EventTileViewModelDependencies => ({
mxEvent,
matrixClient,
showHiddenEvents: false,
});
type EventTileViewModelPropsOverrides = { type EventTileViewModelPropsOverrides = {
event?: Partial<EventTileViewModelProps["event"]>; event?: Partial<NormalizedEventTileViewModelProps["event"]>;
display?: Partial<EventTileViewModelProps["display"]>; display?: Partial<EventTileViewModelProps["display"]>;
interaction?: Partial<EventTileViewModelProps["interaction"]>; interaction?: Partial<EventTileViewModelProps["interaction"]>;
sender?: Partial<EventTileViewModelProps["sender"]>; sender?: Partial<EventTileViewModelProps["sender"]>;
@@ -22,13 +44,15 @@ describe("EventTileViewModel", () => {
footer?: Partial<EventTileViewModelProps["footer"]>; footer?: Partial<EventTileViewModelProps["footer"]>;
}; };
function makeProps(overrides: EventTileViewModelPropsOverrides = {}): EventTileViewModelProps { function makeProps(overrides: EventTileViewModelPropsOverrides = {}): NormalizedEventTileViewModelProps {
return { return {
event: { event: {
eventType: "m.room.message", eventType: "m.room.message",
msgtype: "m.text", msgtype: "m.text",
eventTs: 123, eventTs: 123,
eventId: "$event", eventId: "$event",
isState: false,
hasReplyChain: false,
isLocalEcho: false, isLocalEcho: false,
isSending: false, isSending: false,
ariaLive: "off", ariaLive: "off",
@@ -37,6 +61,8 @@ describe("EventTileViewModel", () => {
isRtcNotification: false, isRtcNotification: false,
isEditing: false, isEditing: false,
isEncryptionFailure: false, isEncryptionFailure: false,
hasRenderer: true,
isSeeingThroughMessageHiddenForModeration: false,
forExport: false, forExport: false,
...overrides.event, ...overrides.event,
}, },
@@ -91,8 +117,21 @@ describe("EventTileViewModel", () => {
); );
expect(snapshot.event.isSending).toBe(true); expect(snapshot.event.isSending).toBe(true);
expect(snapshot.event).toMatchObject({
eventId: "$event",
eventTs: 123,
isLocalEcho: true,
isEncryptionFailure: false,
});
expect(snapshot.root.ariaLive).toBe("off"); expect(snapshot.root.ariaLive).toBe("off");
expect(snapshot.root.scrollToken).toBeUndefined(); expect(snapshot.root.scrollToken).toBeUndefined();
expect(snapshot.root.data).toEqual({
eventId: "$event",
layout: Layout.Group,
shape: TimelineRenderingType.Room,
isOwnEvent: false,
hasReply: false,
});
expect(snapshot.root.classState.mx_EventTile_sending).toBe(true); expect(snapshot.root.classState.mx_EventTile_sending).toBe(true);
}); });
@@ -468,13 +507,13 @@ describe("EventTileViewModel", () => {
}); });
it("updates an instance snapshot when inputs change", () => { it("updates an instance snapshot when inputs change", () => {
const vm = new EventTileViewModel(makeProps()); const vm = new EventTileViewModel(makeDependencies(), makeProps());
const listener = jest.fn(); const listener = jest.fn();
const unsubscribe = vm.subscribe(listener); const unsubscribe = vm.subscribe(listener);
expect(vm.getSnapshot().snapshot.timestamp.show).toBe(false); expect(vm.getSnapshot().snapshot.timestamp.show).toBe(false);
vm.setProps(makeProps({ interaction: { hover: true } })); vm.setInputs(makeDependencies(), makeProps({ interaction: { hover: true } }));
expect(vm.getSnapshot().snapshot.timestamp.show).toBe(true); expect(vm.getSnapshot().snapshot.timestamp.show).toBe(true);
expect(listener).toHaveBeenCalled(); expect(listener).toHaveBeenCalled();
@@ -483,8 +522,162 @@ describe("EventTileViewModel", () => {
vm.dispose(); vm.dispose();
}); });
it("emits once when dependencies and inputs are updated together", () => {
const vm = new EventTileViewModel(makeDependencies(), makeProps());
const listener = jest.fn();
const unsubscribe = vm.subscribe(listener);
vm.setInputs(makeDependencies(), makeProps({ interaction: { hover: true } }));
expect(listener).toHaveBeenCalledTimes(1);
unsubscribe();
vm.dispose();
});
it("normalizes event and sender state from its SDK dependency", () => {
const event = mkEvent({
event: true,
id: "$member-event",
room: "!room:example.org",
ts: 456,
type: "m.room.member",
user: "@bob:example.org",
content: { membership: "join" },
});
const vm = new EventTileViewModel(
makeDependencies(event),
makeProps({
event: {
eventType: "m.room.message",
eventId: "$wrong-event",
eventTs: 123,
},
sender: {
senderId: "@wrong:example.org",
isEmote: false,
},
}),
);
expect(vm.getSnapshot().snapshot.event).toMatchObject({
eventType: "m.room.member",
eventId: "$member-event",
eventTs: 456,
isState: true,
});
expect(vm.getSnapshot().snapshot.sender).toMatchObject({
senderId: "@bob:example.org",
forceHistoricalAvatar: true,
isEmote: false,
});
vm.setInputs(
makeDependencies(
mkEvent({
event: true,
id: "$updated-event",
room: "!room:example.org",
ts: 789,
type: "m.call.invite",
user: "@carol:example.org",
content: { msgtype: "m.call.invite" },
}),
),
makeProps(),
);
expect(vm.getSnapshot().snapshot.event).toMatchObject({
eventType: "m.call.invite",
eventId: "$updated-event",
eventTs: 789,
});
expect(vm.getSnapshot().snapshot.sender.senderId).toBe("@carol:example.org");
vm.dispose();
});
it("normalizes event identity, replacement, renderer, and decryption state", () => {
const event = makeEvent();
jest.spyOn(event, "replacingEventId").mockReturnValue("$replaced-event");
jest.spyOn(event, "isDecryptionFailure").mockReturnValue(true);
const vm = new EventTileViewModel(makeDependencies(event), makeProps());
const snapshot = vm.getSnapshot().snapshot;
expect(snapshot.event).toMatchObject({
eventType: "m.room.message",
eventId: "$event",
replacingEventId: "$replaced-event",
isEncryptionFailure: true,
hasRenderer: true,
});
vm.dispose();
});
it("does not show a reply chain for replacement events", () => {
const event = mkEvent({
event: true,
id: "$replacement-event",
room: "!room:example.org",
ts: 123,
type: "m.room.message",
user: "@alice:example.org",
content: {
"msgtype": "m.text",
"m.relates_to": {
"rel_type": "m.replace",
"event_id": "$original-event",
"m.in_reply_to": {
event_id: "$parent-event",
},
},
},
});
const vm = new EventTileViewModel(makeDependencies(event), makeProps());
expect(vm.getSnapshot().snapshot.root.data.hasReply).toBe(false);
vm.dispose();
});
it("derives an unavailable renderer for unsupported events", () => {
const event = mkEvent({
event: true,
room: "!room:example.org",
type: "org.example.unsupported",
user: "@alice:example.org",
content: {},
});
const vm = new EventTileViewModel(makeDependencies(event), makeProps());
expect(vm.getSnapshot().snapshot.event.hasRenderer).toBe(false);
vm.dispose();
});
it("recalculates renderer state when dependencies change", () => {
const event = mkEvent({
event: true,
room: "!room:example.org",
type: "org.example.unsupported",
user: "@alice:example.org",
content: {},
});
const vm = new EventTileViewModel(makeDependencies(event), makeProps());
expect(vm.getSnapshot().snapshot.event.hasRenderer).toBe(false);
vm.setInputs(makeDependencies(makeEvent()), makeProps());
expect(vm.getSnapshot().snapshot.event.hasRenderer).toBe(true);
vm.dispose();
});
it("lazily owns timestamp child view models", () => { it("lazily owns timestamp child view models", () => {
const vm = new EventTileViewModel(makeProps()); const vm = new EventTileViewModel(makeDependencies(), makeProps());
const messageTimestampViewModel = vm.getMessageTimestampViewModel({ ts: 123 }); const messageTimestampViewModel = vm.getMessageTimestampViewModel({ ts: 123 });
const linkedMessageTimestampViewModel = vm.getLinkedMessageTimestampViewModel({ ts: 456 }); const linkedMessageTimestampViewModel = vm.getLinkedMessageTimestampViewModel({ ts: 456 });
@@ -496,6 +689,7 @@ describe("EventTileViewModel", () => {
it("does not initialize timestamp child view models for events without an origin timestamp", () => { it("does not initialize timestamp child view models for events without an origin timestamp", () => {
const vm = new EventTileViewModel( const vm = new EventTileViewModel(
makeDependencies(),
makeProps({ makeProps({
event: { event: {
eventTs: 0, eventTs: 0,
@@ -512,7 +706,7 @@ describe("EventTileViewModel", () => {
}); });
it("owns and updates the thread-list action bar child view model", () => { it("owns and updates the thread-list action bar child view model", () => {
const vm = new EventTileViewModel(makeProps()); const vm = new EventTileViewModel(makeDependencies(), makeProps());
const onViewInRoomClick = jest.fn(); const onViewInRoomClick = jest.fn();
const onCopyLinkClick = jest.fn(); const onCopyLinkClick = jest.fn();