Refactor EventTile using the MVVM pattern - #3 (#33516)

* Introduce thin EventTile view model snapshot

* Fix errors after merge conflict resolution
This commit is contained in:
rbondesson
2026-05-20 10:32:05 +02:00
committed by GitHub
parent 0140563b64
commit d41ff70057
4 changed files with 745 additions and 132 deletions
+66 -132
View File
@@ -101,26 +101,9 @@ import PinningUtils from "../../../utils/PinningUtils";
import { EventPreview } from "./EventPreview";
import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon.tsx";
import { E2ePadlock, E2ePadlockIcon } from "./EventTile/E2ePadlock.tsx";
import {
getAriaLive,
getEventTileAvatarMember,
getEventTileClassState,
getEventTileLineClassState,
getEventTileSenderProfileState,
getEventTileTimestamp,
getFooterDisplayState,
getIsContinuation,
getReplyChainAlwaysShowTimestamps,
getScrollToken,
getSenderProfileMode,
getShouldShowMessageActionBar,
getShouldShowTimestamp,
getShouldViewUserOnClick,
getTimestampDisplayState,
isSendingStatus,
} from "./EventTile/eventTileDerivedState";
import SettingsStore from "../../../settings/SettingsStore";
import { CardContext } from "../right_panel/context";
import { EventTileViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
import {
MessageTimestampViewModel,
type MessageTimestampViewModelProps,
@@ -1060,7 +1043,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
}
public render(): ReactNode {
const msgtype = this.props.mxEvent.getContent().msgtype;
const eventType = this.props.mxEvent.getType();
const replacingEventId = this.props.mxEvent.replacingEventId();
@@ -1093,56 +1075,67 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
const isProbablyMedia = MediaEventHelper.isEligible(this.props.mxEvent);
const lineClasses = classNames(
"mx_EventTile_line",
getEventTileLineClassState({
isProbablyMedia,
eventType,
msgtype,
}),
);
const isSending = isSendingStatus(this.props.eventSendStatus);
const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted;
const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure();
const isContinuation = getIsContinuation(
this.props.continuation,
this.context.timelineRenderingType,
this.props.layout,
);
const isRenderingNotification = this.context.timelineRenderingType === TimelineRenderingType.Notification;
const isEditing = !!this.props.editState;
const classes = classNames(
getEventTileClassState({
const hasPinnedMessageBadge = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
const hasReactionsRow = !isRedacted;
// Use `getSender()` because searched events might not have a proper `sender`.
const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.safeGet().getUserId();
const eventTileSnapshot = EventTileViewModel.createSnapshot({
event: {
mxEvent: this.props.mxEvent,
eventSendStatus: this.props.eventSendStatus,
isEditing,
isEncryptionFailure,
forExport: this.props.forExport,
},
display: {
timelineRenderingType: this.context.timelineRenderingType,
layout: this.props.layout,
continuation: this.props.continuation,
isProbablyMedia,
isBubbleMessage,
isLeftAlignedBubbleMessage,
isAlignedBetweenBubbles,
isEditing,
isInfoMessage,
noBubbleEvent,
isTwelveHour: this.props.isTwelveHour,
isSending,
isHighlighted: this.shouldHighlight(),
isSelected: this.props.isSelectedEvent || !!this.state.contextMenu,
isContinuation,
eventType,
isLast: this.props.last,
isLastInSection: this.props.lastInSection,
isContextual: this.props.contextual,
},
interaction: {
hover: this.state.hover,
showActionBarFromFocus: this.state.showActionBarFromFocus,
focusWithin: this.state.focusWithin,
isActionBarFocused: this.state.actionBarFocused,
isEncryptionFailure,
msgtype,
hasContextMenu: !!this.state.contextMenu,
inhibitInteraction: this.props.inhibitInteraction,
},
sender: {
hideSender: this.props.hideSender,
timelineRenderingType: this.context.timelineRenderingType,
isRenderingNotification,
noBubbleEvent,
}),
);
},
timestamp: {
alwaysShowTimestamps: this.props.alwaysShowTimestamps,
hideTimestamp: this.props.hideTimestamp,
threadReplyEventTs: this.state.thread?.replyToEvent?.getTs(),
},
footer: {
isOwnEvent,
hasReactionsRow,
hasReactions: !!this.state.reactions,
hasPinnedMessageBadge,
},
});
// If the tile is in the Sending state, don't speak the message.
const ariaLive = getAriaLive(this.props.eventSendStatus);
const lineClasses = classNames("mx_EventTile_line", eventTileSnapshot.line.classState);
const tileClasses = classNames(eventTileSnapshot.root.classState);
const tileAriaLive = eventTileSnapshot.root.ariaLive;
const isRenderingNotification = eventTileSnapshot.event.isRenderingNotification;
let permalink = "#";
if (this.props.permalinkCreator) {
@@ -1151,43 +1144,26 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
// we can't use local echoes as scroll tokens, because their event IDs change.
// Local echos have a send "status".
const scrollToken = getScrollToken(this.props.mxEvent);
const scrollToken = eventTileSnapshot.root.scrollToken;
let avatar: JSX.Element | null = null;
let sender: JSX.Element | null = null;
const { avatarSize, needsSenderProfile } = getEventTileSenderProfileState({
isRenderingNotification,
isInfoMessage,
timelineRenderingType: this.context.timelineRenderingType,
continuation: this.props.continuation,
eventType,
isBubbleMessage,
layout: this.props.layout,
});
const { avatarSize } = eventTileSnapshot.sender.profileState;
if (this.props.mxEvent.sender && avatarSize !== null) {
const member = getEventTileAvatarMember(this.props.mxEvent);
const viewUserOnClick = getShouldViewUserOnClick(
this.props.inhibitInteraction,
this.context.timelineRenderingType,
);
avatar = (
<div className="mx_EventTile_avatar">
<MemberAvatar
member={member}
member={eventTileSnapshot.sender.avatarMember}
size={avatarSize}
viewUserOnClick={viewUserOnClick}
viewUserOnClick={eventTileSnapshot.sender.viewUserOnClick}
forceHistorical={this.props.mxEvent.getType() === EventType.RoomMember}
/>
</div>
);
}
const senderProfileMode = getSenderProfileMode({
needsSenderProfile,
hideSender: this.props.hideSender,
timelineRenderingType: this.context.timelineRenderingType,
});
const senderProfileMode = eventTileSnapshot.sender.profileMode;
if (senderProfileMode === "clickable") {
sender = <SenderProfile onClick={this.onSenderProfileClick} mxEvent={this.props.mxEvent} />;
} else if (senderProfileMode === "tooltip") {
@@ -1196,15 +1172,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
sender = <SenderProfile mxEvent={this.props.mxEvent} />;
}
const showMessageActionBar = getShouldShowMessageActionBar({
isEditing,
forExport: this.props.forExport,
hover: this.state.hover,
showActionBarFromFocus: this.state.showActionBarFromFocus,
actionBarFocused: this.state.actionBarFocused,
hasContextMenu: !!this.state.contextMenu,
});
const actionBar = showMessageActionBar ? (
const actionBar = eventTileSnapshot.actionBar.show ? (
<ActionBarWrapper
mxEvent={this.props.mxEvent}
reactions={this.state.reactions}
@@ -1218,24 +1186,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
/>
) : undefined;
const showTimestamp = getShouldShowTimestamp({
eventTs: this.props.mxEvent.getTs(),
eventType,
hideTimestamp: this.props.hideTimestamp,
alwaysShowTimestamps: this.props.alwaysShowTimestamps,
last: this.props.last,
hover: this.state.hover,
focusWithin: this.state.focusWithin,
actionBarFocused: this.state.actionBarFocused,
hasContextMenu: Boolean(this.state.contextMenu),
});
// Thread panel shows the timestamp of the last reply in that thread
const ts = getEventTileTimestamp({
timelineRenderingType: this.context.timelineRenderingType,
eventTs: this.props.mxEvent.getTs(),
threadReplyEventTs: this.state.thread?.replyToEvent?.getTs(),
});
const ts = eventTileSnapshot.timestamp.value;
const messageTimestampProps: MessageTimestampViewModelProps = {
showRelative: this.context.timelineRenderingType === TimelineRenderingType.ThreadsList,
@@ -1253,24 +1205,19 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
/>
);
const { useIRCLayout, showRealTimestamp, showLinkedTimestamp } = getTimestampDisplayState({
layout: this.props.layout,
showTimestamp,
timestamp: ts,
hideTimestamp: this.props.hideTimestamp,
});
const { useIRCLayout, showRealTimestamp, showLinkedTimestamp } = eventTileSnapshot.timestamp.displayState;
// Used to simplify the UI layout where necessary by not conditionally rendering an element at the start
const dummyTimestamp = useIRCLayout ? <span className="mx_MessageTimestamp" /> : null;
const timestamp = showRealTimestamp ? messageTimestamp : dummyTimestamp;
const linkedTimestamp = showLinkedTimestamp ? linkedMessageTimestamp : dummyTimestamp;
let pinnedMessageBadge: JSX.Element | undefined;
if (PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent)) {
if (hasPinnedMessageBadge) {
pinnedMessageBadge = <PinnedMessageBadge aria-describedby={this.id} tabIndex={0} />;
}
let reactionsRow: JSX.Element | undefined;
if (!isRedacted) {
if (hasReactionsRow) {
reactionsRow = (
<ReactionsRowWrapper
mxEvent={this.props.mxEvent}
@@ -1312,11 +1259,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
forExport={this.props.forExport}
permalinkCreator={this.props.permalinkCreator}
layout={this.props.layout}
alwaysShowTimestamps={getReplyChainAlwaysShowTimestamps({
alwaysShowTimestamps: this.props.alwaysShowTimestamps,
hover: this.state.hover,
focusWithin: this.state.focusWithin,
})}
alwaysShowTimestamps={eventTileSnapshot.replyChain.alwaysShowTimestamps}
isQuoteExpanded={isQuoteExpanded}
setQuoteExpanded={this.setQuoteExpanded}
getRelationsForEvent={this.props.getRelationsForEvent}
@@ -1324,16 +1267,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
);
}
// Use `getSender()` because searched events might not have a proper `sender`.
const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.safeGet().getUserId();
const { hasFooter, showMainPinnedMessageBadge, showBubblePinnedMessageBadge } = getFooterDisplayState({
hasReactionsRow: !!reactionsRow,
hasReactions: !!this.state.reactions,
hasPinnedMessageBadge: !!pinnedMessageBadge,
layout: this.props.layout,
isOwnEvent,
});
const { hasFooter, showMainPinnedMessageBadge, showBubblePinnedMessageBadge } = eventTileSnapshot.footer;
switch (this.context.timelineRenderingType) {
case TimelineRenderingType.Thread: {
@@ -1341,8 +1275,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
this.props.as || "li",
{
"ref": this.ref,
"className": classes,
"aria-live": ariaLive,
"className": tileClasses,
"aria-live": tileAriaLive,
"aria-atomic": true,
"data-scroll-tokens": scrollToken,
"data-has-reply": !!replyChain,
@@ -1403,9 +1337,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
this.props.as || "li",
{
"ref": this.ref,
"className": classes,
"className": tileClasses,
"tabIndex": -1,
"aria-live": ariaLive,
"aria-live": tileAriaLive,
"aria-atomic": "true",
"data-scroll-tokens": scrollToken,
"data-layout": this.props.layout,
@@ -1491,8 +1425,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
return React.createElement(
this.props.as || "li",
{
"className": classes,
"aria-live": ariaLive,
"className": tileClasses,
"aria-live": tileAriaLive,
"aria-atomic": true,
"data-scroll-tokens": scrollToken,
},
@@ -1536,9 +1470,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
this.props.as || "li",
{
"ref": this.ref,
"className": classes,
"className": tileClasses,
"tabIndex": -1,
"aria-live": ariaLive,
"aria-live": tileAriaLive,
"aria-atomic": "true",
"data-scroll-tokens": scrollToken,
"data-layout": this.props.layout,
@@ -0,0 +1,397 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type EventStatus, type MatrixEvent, type RoomMember } from "matrix-js-sdk/src/matrix";
import {
type EventTileSenderProfileState,
type FooterDisplayState,
getAriaLive,
getEventTileAvatarMember,
getEventTileClassState,
getEventTileLineClassState,
getEventTileSenderProfileState,
getEventTileTimestamp,
getFooterDisplayState,
getIsContinuation,
getReplyChainAlwaysShowTimestamps,
getScrollToken,
getSenderProfileMode,
getShouldShowMessageActionBar,
getShouldShowTimestamp,
getShouldViewUserOnClick,
getTimestampDisplayState,
isSendingStatus,
type SenderProfileMode,
type TimestampDisplayState,
} from "./EventTileDerivedState";
import { TimelineRenderingType } from "../../../../contexts/RoomContext";
import { type Layout } from "../../../../settings/enums/Layout";
/** Event-level inputs for deriving the EventTile snapshot. */
export interface EventTileEventInput {
/** The Matrix event rendered by the tile. */
mxEvent: MatrixEvent;
/** The event send status supplied by EventTile. */
eventSendStatus?: EventStatus | null;
/** 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;
}
/** Display inputs for deriving the EventTile snapshot. */
export interface EventTileDisplayInput {
/** The current timeline rendering mode. */
timelineRenderingType: TimelineRenderingType;
/** The current timeline layout. */
layout?: Layout;
/** Whether the tile is a continuation of the previous event. */
continuation?: boolean;
/** Whether the event body is likely to render media content. */
isProbablyMedia: boolean;
/** Whether the tile should use bubble container styling. */
isBubbleMessage: boolean;
/** Whether the bubble tile is left-aligned. */
isLeftAlignedBubbleMessage: boolean;
/** Whether the event is aligned between bubble columns. */
isAlignedBetweenBubbles: boolean;
/** Whether the event renders as an informational timeline item. */
isInfoMessage: boolean;
/** Whether bubble styling should be suppressed for this event. */
noBubbleEvent: boolean;
/** Whether timestamps use twelve-hour formatting. */
isTwelveHour?: boolean;
/** Whether the event should be highlighted. */
isHighlighted: boolean;
/** Whether the tile is selected or has an open context menu. */
isSelected: boolean;
/** Whether the tile is the last event in the timeline. */
isLast?: boolean;
/** Whether the tile is the last event in its section. */
isLastInSection?: boolean;
/** Whether the tile is being rendered in contextual mode. */
isContextual?: boolean;
}
/** Interaction inputs for deriving the EventTile snapshot. */
export interface EventTileInteractionInput {
/** Whether the tile is currently hovered. */
hover: boolean;
/** Whether focus should force the action bar visible. */
showActionBarFromFocus: boolean;
/** Whether focus is currently inside the tile. */
focusWithin: boolean;
/** Whether the action bar currently has focus. */
isActionBarFocused: boolean;
/** Whether an EventTile context menu is currently open. */
hasContextMenu: boolean;
/** Whether interaction should be inhibited inside the tile. */
inhibitInteraction?: boolean;
}
/** Sender inputs for deriving the EventTile snapshot. */
export interface EventTileSenderInput {
/** Whether sender details should be hidden. */
hideSender?: boolean;
}
/** Timestamp inputs for deriving the EventTile snapshot. */
export interface EventTileTimestampInput {
/** Whether timestamps should always show. */
alwaysShowTimestamps?: boolean;
/** Whether timestamp rendering is disabled. */
hideTimestamp?: boolean;
/** The latest thread reply timestamp, when available. */
threadReplyEventTs?: number;
}
/** Footer inputs for deriving the EventTile snapshot. */
export interface EventTileFooterInput {
/** Whether the event was sent by the current user. */
isOwnEvent: boolean;
/** Whether a reactions row element will render. */
hasReactionsRow: boolean;
/** Whether reactions data is available. */
hasReactions: boolean;
/** Whether a pinned message badge element will render. */
hasPinnedMessageBadge: boolean;
}
/** Inputs for deriving the EventTile view model snapshot. */
export interface EventTileViewModelProps {
/** Event-level inputs. */
event: EventTileEventInput;
/** Display inputs. */
display: EventTileDisplayInput;
/** Interaction inputs. */
interaction: EventTileInteractionInput;
/** Sender inputs. */
sender: EventTileSenderInput;
/** Timestamp inputs. */
timestamp: EventTileTimestampInput;
/** Footer inputs. */
footer: EventTileFooterInput;
}
/** Event-level state derived for the EventTile snapshot. */
export interface EventTileEventSnapshot {
/** The Matrix event type. */
eventType: string;
/** The Matrix message type. */
msgtype?: string;
/** Whether the event is in a pending send state. */
isSending: boolean;
/** Whether the event is currently being edited. */
isEditing: boolean;
/** Whether EventTile should render as a continuation. */
isContinuation?: boolean;
/** Whether the tile is rendering as a notification. */
isRenderingNotification: boolean;
}
/** Root state derived for the EventTile snapshot. */
export interface EventTileRootSnapshot {
/** The aria-live setting used by EventTile. */
ariaLive?: "off";
/** The stable scroll token for the event. */
scrollToken?: string;
/** EventTile root CSS class flags. */
classState: ReturnType<typeof getEventTileClassState>;
}
/** Line state derived for the EventTile snapshot. */
export interface EventTileLineSnapshot {
/** EventTile line CSS class flags. */
classState: ReturnType<typeof getEventTileLineClassState>;
}
/** Sender state derived for the EventTile snapshot. */
export interface EventTileSenderSnapshot {
/** EventTile avatar and sender profile display state. */
profileState: EventTileSenderProfileState;
/** The room member whose avatar should render. */
avatarMember: RoomMember | null;
/** Whether clicking the avatar should open the user profile. */
viewUserOnClick: boolean;
/** SenderProfile rendering mode. */
profileMode: SenderProfileMode;
}
/** Action bar state derived for the EventTile snapshot. */
export interface EventTileActionBarSnapshot {
/** Whether EventTile should render the message action bar. */
show: boolean;
}
/** Timestamp state derived for the EventTile snapshot. */
export interface EventTileTimestampSnapshot {
/** Whether EventTile should render the message timestamp. */
show: boolean;
/** The timestamp EventTile should display. */
value: number;
/** EventTile timestamp display state. */
displayState: TimestampDisplayState;
}
/** Reply chain state derived for the EventTile snapshot. */
export interface EventTileReplyChainSnapshot {
/** Whether ReplyChain should always show timestamps. */
alwaysShowTimestamps: boolean;
}
/** Footer state derived for the EventTile snapshot. */
export type EventTileFooterSnapshot = FooterDisplayState;
/** Derived EventTile view state consumed by the existing component. */
export interface EventTileViewModelSnapshot {
/** Event-level derived state. */
event: EventTileEventSnapshot;
/** Root derived state. */
root: EventTileRootSnapshot;
/** Line derived state. */
line: EventTileLineSnapshot;
/** Sender derived state. */
sender: EventTileSenderSnapshot;
/** Action bar derived state. */
actionBar: EventTileActionBarSnapshot;
/** Timestamp derived state. */
timestamp: EventTileTimestampSnapshot;
/** Reply chain derived state. */
replyChain: EventTileReplyChainSnapshot;
/** Footer derived state. */
footer: EventTileFooterSnapshot;
}
/** Derives the current EventTile snapshot from component-owned inputs. */
export class EventTileViewModel {
/** Creates an EventTile view model snapshot. */
public static createSnapshot(props: EventTileViewModelProps): EventTileViewModelSnapshot {
const { event, display, interaction, sender, timestamp, footer } = props;
const eventType = event.mxEvent.getType();
const msgtype = event.mxEvent.getContent().msgtype;
const isSending = isSendingStatus(event.eventSendStatus ?? undefined);
const isContinuation = getIsContinuation(display.continuation, display.timelineRenderingType, display.layout);
const isRenderingNotification = display.timelineRenderingType === TimelineRenderingType.Notification;
const eventSnapshot: EventTileEventSnapshot = {
eventType,
msgtype,
isSending,
isEditing: event.isEditing,
isContinuation,
isRenderingNotification,
};
const senderProfileState = getEventTileSenderProfileState({
isRenderingNotification,
isInfoMessage: display.isInfoMessage,
timelineRenderingType: display.timelineRenderingType,
continuation: display.continuation,
eventType,
isBubbleMessage: display.isBubbleMessage,
layout: display.layout,
});
const showTimestamp = getShouldShowTimestamp({
eventTs: event.mxEvent.getTs(),
eventType,
hideTimestamp: timestamp.hideTimestamp,
alwaysShowTimestamps: timestamp.alwaysShowTimestamps,
last: display.isLast,
hover: interaction.hover,
focusWithin: interaction.focusWithin,
actionBarFocused: interaction.isActionBarFocused,
hasContextMenu: interaction.hasContextMenu,
});
const timestampValue = getEventTileTimestamp({
timelineRenderingType: display.timelineRenderingType,
eventTs: event.mxEvent.getTs(),
threadReplyEventTs: timestamp.threadReplyEventTs,
});
return {
event: eventSnapshot,
root: {
ariaLive: getAriaLive(event.eventSendStatus),
scrollToken: getScrollToken(event.mxEvent),
classState: EventTileViewModel.getClassState({
event,
display,
interaction,
sender,
eventType,
msgtype,
isSending,
isContinuation,
isRenderingNotification,
}),
},
line: {
classState: getEventTileLineClassState({
isProbablyMedia: display.isProbablyMedia,
eventType,
msgtype,
}),
},
sender: {
profileState: senderProfileState,
avatarMember: getEventTileAvatarMember(event.mxEvent),
viewUserOnClick: getShouldViewUserOnClick(
interaction.inhibitInteraction,
display.timelineRenderingType,
),
profileMode: getSenderProfileMode({
needsSenderProfile: senderProfileState.needsSenderProfile,
hideSender: sender.hideSender,
timelineRenderingType: display.timelineRenderingType,
}),
},
actionBar: {
show: getShouldShowMessageActionBar({
isEditing: event.isEditing,
forExport: event.forExport,
hover: interaction.hover,
showActionBarFromFocus: interaction.showActionBarFromFocus,
actionBarFocused: interaction.isActionBarFocused,
hasContextMenu: interaction.hasContextMenu,
}),
},
timestamp: {
show: showTimestamp,
value: timestampValue,
displayState: getTimestampDisplayState({
layout: display.layout,
showTimestamp,
timestamp: timestampValue,
hideTimestamp: timestamp.hideTimestamp,
}),
},
replyChain: {
alwaysShowTimestamps: getReplyChainAlwaysShowTimestamps({
alwaysShowTimestamps: timestamp.alwaysShowTimestamps,
hover: interaction.hover,
focusWithin: interaction.focusWithin,
}),
},
footer: getFooterDisplayState({
hasReactionsRow: footer.hasReactionsRow,
hasReactions: footer.hasReactions,
hasPinnedMessageBadge: footer.hasPinnedMessageBadge,
layout: display.layout,
isOwnEvent: footer.isOwnEvent,
}),
};
}
private static getClassState({
event,
display,
interaction,
sender,
eventType,
msgtype,
isSending,
isContinuation,
isRenderingNotification,
}: {
event: EventTileEventInput;
display: EventTileDisplayInput;
interaction: EventTileInteractionInput;
sender: EventTileSenderInput;
eventType: string;
msgtype?: string;
isSending: boolean;
isContinuation?: boolean;
isRenderingNotification: boolean;
}): ReturnType<typeof getEventTileClassState> {
return getEventTileClassState({
isBubbleMessage: display.isBubbleMessage,
isLeftAlignedBubbleMessage: display.isLeftAlignedBubbleMessage,
isAlignedBetweenBubbles: display.isAlignedBetweenBubbles,
isEditing: event.isEditing,
isInfoMessage: display.isInfoMessage,
isTwelveHour: display.isTwelveHour,
isSending,
isHighlighted: display.isHighlighted,
isSelected: display.isSelected,
isContinuation,
eventType,
isLast: display.isLast,
isLastInSection: display.isLastInSection,
isContextual: display.isContextual,
isActionBarFocused: interaction.isActionBarFocused,
isEncryptionFailure: event.isEncryptionFailure,
msgtype,
hideSender: sender.hideSender,
timelineRenderingType: display.timelineRenderingType,
isRenderingNotification,
noBubbleEvent: display.noBubbleEvent,
});
}
private constructor() {}
}
@@ -0,0 +1,282 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { EventStatus, EventType, type MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
import { mkEvent } from "../../test-utils";
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
import { Layout } from "../../../src/settings/enums/Layout";
import {
EventTileViewModel,
type EventTileViewModelProps,
} from "../../../src/viewmodels/room/timeline/event-tile/EventTileViewModel";
describe("EventTileViewModel", () => {
const roomId = "!room:example.org";
const userId = "@alice:example.org";
type EventTileViewModelPropsOverrides = {
event?: Partial<EventTileViewModelProps["event"]>;
display?: Partial<EventTileViewModelProps["display"]>;
interaction?: Partial<EventTileViewModelProps["interaction"]>;
sender?: Partial<EventTileViewModelProps["sender"]>;
timestamp?: Partial<EventTileViewModelProps["timestamp"]>;
footer?: Partial<EventTileViewModelProps["footer"]>;
};
function makeMessageEvent({
type = EventType.RoomMessage,
content = { msgtype: MsgType.Text, body: "Hello" },
ts = 123,
status,
}: {
type?: string;
content?: Record<string, unknown>;
ts?: number;
status?: EventStatus;
} = {}): MatrixEvent {
return mkEvent({
event: true,
type,
room: roomId,
user: userId,
content,
ts,
status,
});
}
function makeProps(overrides: EventTileViewModelPropsOverrides = {}): EventTileViewModelProps {
return {
event: {
mxEvent: makeMessageEvent(),
isEditing: false,
isEncryptionFailure: false,
...overrides.event,
},
display: {
timelineRenderingType: TimelineRenderingType.Room,
layout: Layout.Group,
isProbablyMedia: false,
isBubbleMessage: false,
isLeftAlignedBubbleMessage: false,
isAlignedBetweenBubbles: false,
isInfoMessage: false,
noBubbleEvent: false,
isHighlighted: false,
isSelected: false,
...overrides.display,
},
interaction: {
hover: false,
showActionBarFromFocus: false,
focusWithin: false,
isActionBarFocused: false,
hasContextMenu: false,
...overrides.interaction,
},
sender: {
...overrides.sender,
},
timestamp: {
...overrides.timestamp,
},
footer: {
isOwnEvent: false,
hasReactionsRow: false,
hasReactions: false,
hasPinnedMessageBadge: false,
...overrides.footer,
},
};
}
it("derives sending, aria-live, and local echo scroll state", () => {
const mxEvent = makeMessageEvent({ status: EventStatus.SENDING });
const snapshot = EventTileViewModel.createSnapshot(
makeProps({
event: {
mxEvent,
eventSendStatus: EventStatus.SENDING,
},
}),
);
expect(snapshot.event.isSending).toBe(true);
expect(snapshot.root.ariaLive).toBe("off");
expect(snapshot.root.scrollToken).toBeUndefined();
expect(snapshot.root.classState.mx_EventTile_sending).toBe(true);
});
it("normalizes continuation by rendering mode and bubble layout", () => {
const fileSnapshot = EventTileViewModel.createSnapshot(
makeProps({
display: {
continuation: true,
timelineRenderingType: TimelineRenderingType.File,
layout: Layout.Group,
},
}),
);
const bubbleSnapshot = EventTileViewModel.createSnapshot(
makeProps({
display: {
continuation: true,
timelineRenderingType: TimelineRenderingType.File,
layout: Layout.Bubble,
},
}),
);
expect(fileSnapshot.event.isContinuation).toBe(false);
expect(bubbleSnapshot.event.isContinuation).toBe(true);
});
it("derives line classes from event type, message type, and media eligibility", () => {
const snapshot = EventTileViewModel.createSnapshot(
makeProps({
event: {
mxEvent: makeMessageEvent({
content: { msgtype: MsgType.Image, body: "image" },
}),
},
display: {
isProbablyMedia: true,
},
}),
);
expect(snapshot.line.classState).toMatchObject({
mx_EventTile_mediaLine: true,
mx_EventTile_image: true,
mx_EventTile_sticker: false,
mx_EventTile_emote: false,
});
});
it("derives aligned-between-bubbles root class state", () => {
const snapshot = EventTileViewModel.createSnapshot(
makeProps({
display: {
isAlignedBetweenBubbles: true,
},
}),
);
expect(snapshot.root.classState.mx_EventTile_alignedBetweenBubbles).toBe(true);
});
it("derives avatar and sender profile state for thread timelines", () => {
const snapshot = EventTileViewModel.createSnapshot(
makeProps({
display: {
timelineRenderingType: TimelineRenderingType.Thread,
continuation: false,
},
}),
);
expect(snapshot.sender.profileState).toEqual({
avatarSize: "32px",
needsSenderProfile: true,
});
expect(snapshot.sender.profileMode).toBe("clickable");
expect(snapshot.sender.viewUserOnClick).toBe(true);
});
it("derives action bar visibility from interaction state", () => {
const hoverSnapshot = EventTileViewModel.createSnapshot(makeProps({ interaction: { hover: true } }));
const contextMenuSnapshot = EventTileViewModel.createSnapshot(
makeProps({
interaction: {
isActionBarFocused: true,
hasContextMenu: true,
},
}),
);
expect(hoverSnapshot.actionBar.show).toBe(true);
expect(contextMenuSnapshot.actionBar.show).toBe(false);
});
it("derives timestamp state for thread list events", () => {
const snapshot = EventTileViewModel.createSnapshot(
makeProps({
display: {
timelineRenderingType: TimelineRenderingType.ThreadsList,
},
interaction: {
hover: true,
},
timestamp: {
threadReplyEventTs: 456,
},
}),
);
expect(snapshot.timestamp.show).toBe(true);
expect(snapshot.timestamp.value).toBe(456);
expect(snapshot.timestamp.displayState.showRealTimestamp).toBe(true);
});
it("suppresses RTC notification timestamps", () => {
const snapshot = EventTileViewModel.createSnapshot(
makeProps({
event: {
mxEvent: makeMessageEvent({
type: EventType.RTCNotification,
content: {},
}),
},
interaction: {
hover: true,
},
}),
);
expect(snapshot.timestamp.show).toBe(false);
expect(snapshot.timestamp.displayState.showRealTimestamp).toBe(false);
});
it("derives footer placement state", () => {
const groupSnapshot = EventTileViewModel.createSnapshot(
makeProps({
display: {
layout: Layout.Group,
},
footer: {
isOwnEvent: true,
hasReactionsRow: true,
hasReactions: true,
hasPinnedMessageBadge: true,
},
}),
);
const bubbleSnapshot = EventTileViewModel.createSnapshot(
makeProps({
display: {
layout: Layout.Bubble,
},
footer: {
isOwnEvent: true,
hasPinnedMessageBadge: true,
},
}),
);
expect(groupSnapshot.footer).toMatchObject({
hasFooter: true,
showMainPinnedMessageBadge: true,
showBubblePinnedMessageBadge: false,
});
expect(bubbleSnapshot.footer).toMatchObject({
hasFooter: true,
showMainPinnedMessageBadge: false,
showBubblePinnedMessageBadge: true,
});
});
});