From d41ff700570aedea96174413bf934f19c1b66a7f Mon Sep 17 00:00:00 2001 From: rbondesson Date: Wed, 20 May 2026 10:32:05 +0200 Subject: [PATCH] Refactor EventTile using the MVVM pattern - #3 (#33516) * Introduce thin EventTile view model snapshot * Fix errors after merge conflict resolution --- .../src/components/views/rooms/EventTile.tsx | 198 +++------ .../event-tile/EventTileDerivedState.ts} | 0 .../timeline/event-tile/EventTileViewModel.ts | 397 ++++++++++++++++++ .../event-tiles/EventTileViewModel-test.ts | 282 +++++++++++++ 4 files changed, 745 insertions(+), 132 deletions(-) rename apps/web/src/{components/views/rooms/EventTile/eventTileDerivedState.ts => viewmodels/room/timeline/event-tile/EventTileDerivedState.ts} (100%) create mode 100644 apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts create mode 100644 apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 80a5560f1e..eee5d3d830 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -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 } 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 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 // 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 = (
); } - const senderProfileMode = getSenderProfileMode({ - needsSenderProfile, - hideSender: this.props.hideSender, - timelineRenderingType: this.context.timelineRenderingType, - }); + const senderProfileMode = eventTileSnapshot.sender.profileMode; if (senderProfileMode === "clickable") { sender = ; } else if (senderProfileMode === "tooltip") { @@ -1196,15 +1172,7 @@ export class UnwrappedEventTile extends React.Component sender = ; } - 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 ? ( /> ) : 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 /> ); - 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 ? : 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 = ; } let reactionsRow: JSX.Element | undefined; - if (!isRedacted) { + if (hasReactionsRow) { reactionsRow = ( 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 ); } - // 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 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 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 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 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, diff --git a/apps/web/src/components/views/rooms/EventTile/eventTileDerivedState.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileDerivedState.ts similarity index 100% rename from apps/web/src/components/views/rooms/EventTile/eventTileDerivedState.ts rename to apps/web/src/viewmodels/room/timeline/event-tile/EventTileDerivedState.ts diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts new file mode 100644 index 0000000000..4fa6041621 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts @@ -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; +} + +/** Line state derived for the EventTile snapshot. */ +export interface EventTileLineSnapshot { + /** EventTile line CSS class flags. */ + classState: ReturnType; +} + +/** 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 { + 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() {} +} diff --git a/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts b/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts new file mode 100644 index 0000000000..9babc1a173 --- /dev/null +++ b/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts @@ -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; + display?: Partial; + interaction?: Partial; + sender?: Partial; + timestamp?: Partial; + footer?: Partial; + }; + + function makeMessageEvent({ + type = EventType.RoomMessage, + content = { msgtype: MsgType.Text, body: "Hello" }, + ts = 123, + status, + }: { + type?: string; + content?: Record; + 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, + }); + }); +});