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;
@@ -292,6 +292,15 @@ describe("EventTile", () => {
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", () => {
const localEcho = makeOwnMessage();
localEcho.setStatus(EventStatus.SENDING);
@@ -7,14 +7,36 @@
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
import { Layout } from "../../../src/settings/enums/Layout";
import { mkEvent, stubClient } from "../../test-utils";
import {
EventTileViewModel,
type EventTileViewModelDependencies,
type NormalizedEventTileViewModelProps,
type EventTileViewModelProps,
} from "../../../src/viewmodels/room/timeline/event-tile/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 = {
event?: Partial<EventTileViewModelProps["event"]>;
event?: Partial<NormalizedEventTileViewModelProps["event"]>;
display?: Partial<EventTileViewModelProps["display"]>;
interaction?: Partial<EventTileViewModelProps["interaction"]>;
sender?: Partial<EventTileViewModelProps["sender"]>;
@@ -22,13 +44,15 @@ describe("EventTileViewModel", () => {
footer?: Partial<EventTileViewModelProps["footer"]>;
};
function makeProps(overrides: EventTileViewModelPropsOverrides = {}): EventTileViewModelProps {
function makeProps(overrides: EventTileViewModelPropsOverrides = {}): NormalizedEventTileViewModelProps {
return {
event: {
eventType: "m.room.message",
msgtype: "m.text",
eventTs: 123,
eventId: "$event",
isState: false,
hasReplyChain: false,
isLocalEcho: false,
isSending: false,
ariaLive: "off",
@@ -37,6 +61,8 @@ describe("EventTileViewModel", () => {
isRtcNotification: false,
isEditing: false,
isEncryptionFailure: false,
hasRenderer: true,
isSeeingThroughMessageHiddenForModeration: false,
forExport: false,
...overrides.event,
},
@@ -91,8 +117,21 @@ describe("EventTileViewModel", () => {
);
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.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);
});
@@ -468,13 +507,13 @@ describe("EventTileViewModel", () => {
});
it("updates an instance snapshot when inputs change", () => {
const vm = new EventTileViewModel(makeProps());
const vm = new EventTileViewModel(makeDependencies(), makeProps());
const listener = jest.fn();
const unsubscribe = vm.subscribe(listener);
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(listener).toHaveBeenCalled();
@@ -483,8 +522,162 @@ describe("EventTileViewModel", () => {
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", () => {
const vm = new EventTileViewModel(makeProps());
const vm = new EventTileViewModel(makeDependencies(), makeProps());
const messageTimestampViewModel = vm.getMessageTimestampViewModel({ ts: 123 });
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", () => {
const vm = new EventTileViewModel(
makeDependencies(),
makeProps({
event: {
eventTs: 0,
@@ -512,7 +706,7 @@ describe("EventTileViewModel", () => {
});
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 onCopyLinkClick = jest.fn();