From 7fe8cdafe0bdd4187e13d95521ff9f78792e7acb Mon Sep 17 00:00:00 2001 From: rbondesson Date: Mon, 18 May 2026 07:36:13 +0200 Subject: [PATCH] Refactor EventTile using the MVVM pattern - #2 (#33489) * Improve coverage * Add view model unit tests to improve coverage * Extract initial pure EventTile derived state helpers * Extract EventTile derived state helpers * Extract EventTile class state derivation * Extract EventTile line class derivation * Extract EventTile sender profile state * Extract EventTile avatar member selection * Extract EventTile avatar clickability state * Extract EventTile sender profile mode * Extract EventTile action bar visibility * Extract EventTile timestamp visibility * Extract EventTile timestamp selection * Extract EventTile timestamp display state * Extract ReplyChain timestamp visibility * Extract EventTile footer display state * Fix sonar issue --- .../src/components/views/rooms/EventTile.tsx | 285 +++++----- .../rooms/EventTile/eventTileDerivedState.ts | 489 ++++++++++++++++++ .../components/views/rooms/EventTile-test.tsx | 225 ++++++++ .../audio/AudioPlayerViewModel-test.tsx | 12 + .../MessageTimestampViewModel-test.tsx | 64 +++ .../ReactionsRowButtonViewModel-test.tsx | 32 ++ .../room/ThreadListActionBarViewModel-test.ts | 57 ++ 7 files changed, 1011 insertions(+), 153 deletions(-) create mode 100644 apps/web/src/components/views/rooms/EventTile/eventTileDerivedState.ts create mode 100644 apps/web/test/viewmodels/room/ThreadListActionBarViewModel-test.ts diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 0f3ba769e7..0fc6370a90 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -26,7 +26,6 @@ import { EventType, type MatrixEvent, MatrixEventEvent, - MsgType, type NotificationCountType, type Relations, type RelationType, @@ -100,9 +99,26 @@ import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper"; import { Icon as LateIcon } from "../../../../res/img/sensor.svg"; import PinningUtils from "../../../utils/PinningUtils"; import { EventPreview } from "./EventPreview"; -import { ElementCallEventType } from "../../../call-types"; 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 { @@ -1076,61 +1092,55 @@ export class UnwrappedEventTile extends React.Component const isProbablyMedia = MediaEventHelper.isEligible(this.props.mxEvent); - const lineClasses = classNames("mx_EventTile_line", { - mx_EventTile_mediaLine: isProbablyMedia, - mx_EventTile_image: - this.props.mxEvent.getType() === EventType.RoomMessage && - this.props.mxEvent.getContent().msgtype === MsgType.Image, - mx_EventTile_sticker: this.props.mxEvent.getType() === EventType.Sticker, - mx_EventTile_emote: - this.props.mxEvent.getType() === EventType.RoomMessage && - this.props.mxEvent.getContent().msgtype === MsgType.Emote, - }); + const lineClasses = classNames( + "mx_EventTile_line", + getEventTileLineClassState({ + isProbablyMedia, + eventType, + msgtype, + }), + ); - const isSending = ["sending", "queued", "encrypting"].includes(this.props.eventSendStatus!); + const isSending = isSendingStatus(this.props.eventSendStatus); const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted; const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure(); - let isContinuation = this.props.continuation; - if ( - this.context.timelineRenderingType !== TimelineRenderingType.Room && - this.context.timelineRenderingType !== TimelineRenderingType.Search && - this.context.timelineRenderingType !== TimelineRenderingType.Thread && - this.props.layout !== Layout.Bubble - ) { - isContinuation = false; - } + 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({ - mx_EventTile_bubbleContainer: isBubbleMessage, - mx_EventTile_leftAlignedBubble: isLeftAlignedBubbleMessage, - mx_EventTile: true, - mx_EventTile_isEditing: isEditing, - mx_EventTile_info: isInfoMessage, - mx_EventTile_12hr: this.props.isTwelveHour, - // Note: we keep the `sending` state class for tests, not for our styles - mx_EventTile_sending: !isEditing && isSending, - mx_EventTile_highlight: this.shouldHighlight(), - mx_EventTile_selected: this.props.isSelectedEvent || this.state.contextMenu, - mx_EventTile_continuation: - isContinuation || eventType === EventType.CallInvite || ElementCallEventType.matches(eventType), - mx_EventTile_last: this.props.last, - mx_EventTile_lastInSection: this.props.lastInSection, - mx_EventTile_contextual: this.props.contextual, - mx_EventTile_actionBarFocused: this.state.actionBarFocused, - mx_EventTile_bad: isEncryptionFailure, - mx_EventTile_emote: msgtype === MsgType.Emote, - mx_EventTile_noSender: this.props.hideSender, - mx_EventTile_clamp: - this.context.timelineRenderingType === TimelineRenderingType.ThreadsList || isRenderingNotification, - mx_EventTile_noBubble: noBubbleEvent, - }); + const classes = classNames( + getEventTileClassState({ + isBubbleMessage, + isLeftAlignedBubbleMessage, + isEditing, + isInfoMessage, + 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, + isActionBarFocused: this.state.actionBarFocused, + isEncryptionFailure, + msgtype, + hideSender: this.props.hideSender, + timelineRenderingType: this.context.timelineRenderingType, + isRenderingNotification, + noBubbleEvent, + }), + ); // If the tile is in the Sending state, don't speak the message. - const ariaLive = this.props.eventSendStatus !== null ? "off" : undefined; + const ariaLive = getAriaLive(this.props.eventSendStatus); let permalink = "#"; if (this.props.permalinkCreator) { @@ -1139,66 +1149,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 = this.props.mxEvent.status ? undefined : this.props.mxEvent.getId(); + const scrollToken = getScrollToken(this.props.mxEvent); let avatar: JSX.Element | null = null; let sender: JSX.Element | null = null; - let avatarSize: string | null; - let needsSenderProfile: boolean; - - if (isRenderingNotification) { - avatarSize = "24px"; - needsSenderProfile = true; - } else if (isInfoMessage) { - // a small avatar, with no sender profile, for - // joins/parts/etc - avatarSize = "14px"; - needsSenderProfile = false; - } else if ( - this.context.timelineRenderingType === TimelineRenderingType.ThreadsList || - (this.context.timelineRenderingType === TimelineRenderingType.Thread && !this.props.continuation) - ) { - avatarSize = "32px"; - needsSenderProfile = true; - } else if (eventType === EventType.RoomCreate || isBubbleMessage) { - avatarSize = null; - needsSenderProfile = false; - } else if (this.props.layout == Layout.IRC) { - avatarSize = "14px"; - needsSenderProfile = true; - } else if ( - (this.props.continuation && this.context.timelineRenderingType !== TimelineRenderingType.File) || - eventType === EventType.CallInvite || - ElementCallEventType.matches(eventType) || - eventType === EventType.RTCNotification - ) { - // no avatar or sender profile for continuation messages and call tiles - avatarSize = null; - needsSenderProfile = false; - } else if (this.context.timelineRenderingType === TimelineRenderingType.File) { - avatarSize = "20px"; - needsSenderProfile = true; - } else { - avatarSize = "30px"; - needsSenderProfile = true; - } + const { avatarSize, needsSenderProfile } = getEventTileSenderProfileState({ + isRenderingNotification, + isInfoMessage, + timelineRenderingType: this.context.timelineRenderingType, + continuation: this.props.continuation, + eventType, + isBubbleMessage, + layout: this.props.layout, + }); if (this.props.mxEvent.sender && avatarSize !== null) { - let member: RoomMember | null = null; - // set member to receiver (target) if it is a 3PID invite - // so that the correct avatar is shown as the text is - // `$target accepted the invitation for $email` - if (this.props.mxEvent.getContent().third_party_invite) { - member = this.props.mxEvent.target; - } else { - member = this.props.mxEvent.sender; - } - // In the ThreadsList view we use the entire EventTile as a click target to open the thread instead - const viewUserOnClick = - !this.props.inhibitInteraction && - ![TimelineRenderingType.ThreadsList, TimelineRenderingType.Notification].includes( - this.context.timelineRenderingType, - ); + const member = getEventTileAvatarMember(this.props.mxEvent); + const viewUserOnClick = getShouldViewUserOnClick( + this.props.inhibitInteraction, + this.context.timelineRenderingType, + ); avatar = (
); } - if (needsSenderProfile && this.props.hideSender !== true) { - if ( - this.context.timelineRenderingType === TimelineRenderingType.Room || - this.context.timelineRenderingType === TimelineRenderingType.Search || - this.context.timelineRenderingType === TimelineRenderingType.Pinned || - this.context.timelineRenderingType === TimelineRenderingType.Thread - ) { - sender = ; - } else if (this.context.timelineRenderingType === TimelineRenderingType.ThreadsList) { - sender = ; - } else { - sender = ; - } + const senderProfileMode = getSenderProfileMode({ + needsSenderProfile, + hideSender: this.props.hideSender, + timelineRenderingType: this.context.timelineRenderingType, + }); + if (senderProfileMode === "clickable") { + sender = ; + } else if (senderProfileMode === "tooltip") { + sender = ; + } else if (senderProfileMode === "default") { + sender = ; } - const showMessageActionBar = - !isEditing && - !this.props.forExport && - (this.state.hover || - this.state.showActionBarFromFocus || - (this.state.actionBarFocused && !this.state.contextMenu)); + 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 ? ( /> ) : undefined; - const showTimestamp = - this.props.mxEvent.getTs() && - // Don't show timestamp for the CallStarted tile because - // the tile content already renders a timestamp. - this.props.mxEvent.getType() !== EventType.RTCNotification && - !this.props.hideTimestamp && - (this.props.alwaysShowTimestamps || - this.props.last || - this.state.hover || - this.state.focusWithin || - this.state.actionBarFocused || - Boolean(this.state.contextMenu)); + 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 - let ts = - this.context.timelineRenderingType !== TimelineRenderingType.ThreadsList - ? this.props.mxEvent.getTs() - : this.state.thread?.replyToEvent?.getTs(); - if (typeof ts !== "number") { - // Fall back to something we can use - ts = this.props.mxEvent.getTs(); - } + const ts = getEventTileTimestamp({ + timelineRenderingType: this.context.timelineRenderingType, + eventTs: this.props.mxEvent.getTs(), + threadReplyEventTs: this.state.thread?.replyToEvent?.getTs(), + }); const messageTimestampProps: MessageTimestampViewModelProps = { showRelative: this.context.timelineRenderingType === TimelineRenderingType.ThreadsList, @@ -1285,12 +1251,16 @@ export class UnwrappedEventTile extends React.Component /> ); - const useIRCLayout = this.props.layout === Layout.IRC; + const { useIRCLayout, showRealTimestamp, showLinkedTimestamp } = getTimestampDisplayState({ + layout: this.props.layout, + showTimestamp, + timestamp: ts, + hideTimestamp: this.props.hideTimestamp, + }); // Used to simplify the UI layout where necessary by not conditionally rendering an element at the start const dummyTimestamp = useIRCLayout ? : null; - const timestamp = showTimestamp && ts ? messageTimestamp : dummyTimestamp; - const linkedTimestamp = - timestamp !== dummyTimestamp && !this.props.hideTimestamp ? linkedMessageTimestamp : dummyTimestamp; + const timestamp = showRealTimestamp ? messageTimestamp : dummyTimestamp; + const linkedTimestamp = showLinkedTimestamp ? linkedMessageTimestamp : dummyTimestamp; let pinnedMessageBadge: JSX.Element | undefined; if (PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent)) { @@ -1308,9 +1278,6 @@ export class UnwrappedEventTile extends React.Component ); } - // If we have reactions or a pinned message badge, we need a footer - const hasFooter = Boolean((reactionsRow && this.state.reactions) || pinnedMessageBadge); - const groupTimestamp = !useIRCLayout ? linkedTimestamp : null; const ircTimestamp = useIRCLayout ? linkedTimestamp : null; const groupPadlock = !useIRCLayout && !isBubbleMessage && this.renderE2EPadlock(); @@ -1343,7 +1310,11 @@ export class UnwrappedEventTile extends React.Component forExport={this.props.forExport} permalinkCreator={this.props.permalinkCreator} layout={this.props.layout} - alwaysShowTimestamps={this.props.alwaysShowTimestamps || this.state.hover || this.state.focusWithin} + alwaysShowTimestamps={getReplyChainAlwaysShowTimestamps({ + alwaysShowTimestamps: this.props.alwaysShowTimestamps, + hover: this.state.hover, + focusWithin: this.state.focusWithin, + })} isQuoteExpanded={isQuoteExpanded} setQuoteExpanded={this.setQuoteExpanded} getRelationsForEvent={this.props.getRelationsForEvent} @@ -1354,6 +1325,14 @@ 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, + }); + switch (this.context.timelineRenderingType) { case TimelineRenderingType.Thread: { return React.createElement( @@ -1406,9 +1385,9 @@ export class UnwrappedEventTile extends React.Component
, hasFooter && (
- {(this.props.layout === Layout.Group || !isOwnEvent) && pinnedMessageBadge} + {showMainPinnedMessageBadge && pinnedMessageBadge} {reactionsRow} - {this.props.layout === Layout.Bubble && isOwnEvent && pinnedMessageBadge} + {showBubblePinnedMessageBadge && pinnedMessageBadge}
), ], @@ -1614,9 +1593,9 @@ export class UnwrappedEventTile extends React.Component <> {hasFooter && (
- {(this.props.layout === Layout.Group || !isOwnEvent) && pinnedMessageBadge} + {showMainPinnedMessageBadge && pinnedMessageBadge} {reactionsRow} - {this.props.layout === Layout.Bubble && isOwnEvent && pinnedMessageBadge} + {showBubblePinnedMessageBadge && pinnedMessageBadge}
)} {this.renderThreadInfo()} diff --git a/apps/web/src/components/views/rooms/EventTile/eventTileDerivedState.ts b/apps/web/src/components/views/rooms/EventTile/eventTileDerivedState.ts new file mode 100644 index 0000000000..e728dba55b --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/eventTileDerivedState.ts @@ -0,0 +1,489 @@ +/* +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, type RoomMember } from "matrix-js-sdk/src/matrix"; + +import { ElementCallEventType } from "../../../../call-types"; +import { TimelineRenderingType } from "../../../../contexts/RoomContext"; +import { Layout } from "../../../../settings/enums/Layout"; + +/** + * Pure EventTile derivations extracted ahead of EventTileViewModel. + * Keep this module free of React lifecycle, DOM access, dispatch, and MatrixClientPeg lookups. + */ + +/** Whether the event send status represents a pending send state. */ +export function isSendingStatus(eventSendStatus?: EventStatus): boolean { + return [EventStatus.SENDING, EventStatus.QUEUED, EventStatus.ENCRYPTING].includes(eventSendStatus!); +} + +/** The aria-live setting used by EventTile for the current send status. */ +export function getAriaLive(eventSendStatus?: EventStatus | null): "off" | undefined { + return eventSendStatus === null ? undefined : "off"; +} + +/** The stable scroll token for a non-local-echo event. */ +export function getScrollToken(mxEvent: MatrixEvent): string | undefined { + return mxEvent.status ? undefined : mxEvent.getId(); +} + +/** Whether EventTile should render as a continuation in the current layout/rendering mode. */ +export function getIsContinuation( + continuation: boolean | undefined, + timelineRenderingType: TimelineRenderingType, + layout: Layout | undefined, +): boolean | undefined { + if ( + timelineRenderingType !== TimelineRenderingType.Room && + timelineRenderingType !== TimelineRenderingType.Search && + timelineRenderingType !== TimelineRenderingType.Thread && + layout !== Layout.Bubble + ) { + return false; + } + + return continuation; +} + +/** Inputs for EventTile line CSS class derivation. */ +export interface EventTileLineClassState { + /** Whether the event body is likely to render media content. */ + isProbablyMedia: boolean; + /** The Matrix event type for event-type class derivation. */ + eventType: string; + /** The Matrix message type for message-type class derivation. */ + msgtype?: string; +} + +/** The EventTile line CSS class flags for the current derived state. */ +export function getEventTileLineClassState({ + isProbablyMedia, + eventType, + msgtype, +}: EventTileLineClassState): Record { + return { + mx_EventTile_mediaLine: isProbablyMedia, + mx_EventTile_image: eventType === EventType.RoomMessage && msgtype === MsgType.Image, + mx_EventTile_sticker: eventType === EventType.Sticker, + mx_EventTile_emote: eventType === EventType.RoomMessage && msgtype === MsgType.Emote, + }; +} + +/** Inputs for EventTile avatar and sender profile derivation. */ +export interface EventTileSenderProfileStateInput { + /** Whether the tile is rendering as a notification. */ + isRenderingNotification: boolean; + /** Whether the event renders as an informational timeline item. */ + isInfoMessage: boolean; + /** The current timeline rendering mode. */ + timelineRenderingType: TimelineRenderingType; + /** Whether the tile is a continuation of the previous event. */ + continuation?: boolean; + /** The Matrix event type for event-type sender profile derivation. */ + eventType: string; + /** Whether the tile should use bubble container styling. */ + isBubbleMessage: boolean; + /** The current timeline layout. */ + layout?: Layout; +} + +/** EventTile avatar and sender profile display state. */ +export interface EventTileSenderProfileState { + /** The avatar size to render, or null when no avatar should render. */ + avatarSize: string | null; + /** Whether sender profile details should render. */ + needsSenderProfile: boolean; +} + +/** The EventTile avatar and sender profile state for the current derived state. */ +export function getEventTileSenderProfileState({ + isRenderingNotification, + isInfoMessage, + timelineRenderingType, + continuation, + eventType, + isBubbleMessage, + layout, +}: EventTileSenderProfileStateInput): EventTileSenderProfileState { + if (isRenderingNotification) { + return { avatarSize: "24px", needsSenderProfile: true }; + } + + if (isInfoMessage) { + return { avatarSize: "14px", needsSenderProfile: false }; + } + + if ( + timelineRenderingType === TimelineRenderingType.ThreadsList || + (timelineRenderingType === TimelineRenderingType.Thread && !continuation) + ) { + return { avatarSize: "32px", needsSenderProfile: true }; + } + + if (eventType === EventType.RoomCreate || isBubbleMessage) { + return { avatarSize: null, needsSenderProfile: false }; + } + + if (layout === Layout.IRC) { + return { avatarSize: "14px", needsSenderProfile: true }; + } + + if ( + (continuation && timelineRenderingType !== TimelineRenderingType.File) || + eventType === EventType.CallInvite || + ElementCallEventType.matches(eventType) || + eventType === EventType.RTCNotification + ) { + return { avatarSize: null, needsSenderProfile: false }; + } + + if (timelineRenderingType === TimelineRenderingType.File) { + return { avatarSize: "20px", needsSenderProfile: true }; + } + + return { avatarSize: "30px", needsSenderProfile: true }; +} + +/** The room member whose avatar should render for the EventTile. */ +export function getEventTileAvatarMember(mxEvent: MatrixEvent): RoomMember | null { + if (mxEvent.getContent().third_party_invite) { + return mxEvent.target; + } + + return mxEvent.sender; +} + +/** Whether clicking the avatar should open the user profile. */ +export function getShouldViewUserOnClick( + inhibitInteraction: boolean | undefined, + timelineRenderingType: TimelineRenderingType, +): boolean { + return ( + !inhibitInteraction && + ![TimelineRenderingType.ThreadsList, TimelineRenderingType.Notification].includes(timelineRenderingType) + ); +} + +/** SenderProfile rendering mode for EventTile. */ +export type SenderProfileMode = "hidden" | "clickable" | "tooltip" | "default"; + +/** Inputs for EventTile sender profile mode derivation. */ +export interface SenderProfileModeInput { + /** Whether sender profile details should render. */ + needsSenderProfile: boolean; + /** Whether sender details should be hidden. */ + hideSender?: boolean; + /** The current timeline rendering mode. */ + timelineRenderingType: TimelineRenderingType; +} + +/** The SenderProfile rendering mode for the current EventTile state. */ +export function getSenderProfileMode({ + needsSenderProfile, + hideSender, + timelineRenderingType, +}: SenderProfileModeInput): SenderProfileMode { + if (!needsSenderProfile || hideSender === true) { + return "hidden"; + } + + if ( + timelineRenderingType === TimelineRenderingType.Room || + timelineRenderingType === TimelineRenderingType.Search || + timelineRenderingType === TimelineRenderingType.Pinned || + timelineRenderingType === TimelineRenderingType.Thread + ) { + return "clickable"; + } + + if (timelineRenderingType === TimelineRenderingType.ThreadsList) { + return "tooltip"; + } + + return "default"; +} + +/** Inputs for EventTile message action bar visibility derivation. */ +export interface ShouldShowMessageActionBarInput { + /** Whether the event is currently being edited. */ + isEditing: boolean; + /** Whether the tile is rendering for export. */ + forExport?: boolean; + /** Whether the tile is currently hovered. */ + hover: boolean; + /** Whether focus should force the action bar visible. */ + showActionBarFromFocus: boolean; + /** Whether the action bar currently has focus. */ + actionBarFocused: boolean; + /** Whether an EventTile context menu is currently open. */ + hasContextMenu: boolean; +} + +/** Whether EventTile should render the message action bar. */ +export function getShouldShowMessageActionBar({ + isEditing, + forExport, + hover, + showActionBarFromFocus, + actionBarFocused, + hasContextMenu, +}: ShouldShowMessageActionBarInput): boolean { + return !isEditing && !forExport && (hover || showActionBarFromFocus || (actionBarFocused && !hasContextMenu)); +} + +/** Inputs for EventTile timestamp visibility derivation. */ +export interface ShouldShowTimestampInput { + /** The event origin timestamp. */ + eventTs: number; + /** The Matrix event type for event-type timestamp derivation. */ + eventType: string; + /** Whether timestamp rendering is disabled. */ + hideTimestamp?: boolean; + /** Whether timestamps should always show. */ + alwaysShowTimestamps?: boolean; + /** Whether the tile is the last event in the timeline. */ + last?: boolean; + /** Whether the tile is currently hovered. */ + hover: boolean; + /** Whether focus is currently inside the tile. */ + focusWithin: boolean; + /** Whether the action bar currently has focus. */ + actionBarFocused: boolean; + /** Whether an EventTile context menu is currently open. */ + hasContextMenu: boolean; +} + +/** Whether EventTile should render the message timestamp. */ +export function getShouldShowTimestamp({ + eventTs, + eventType, + hideTimestamp, + alwaysShowTimestamps, + last, + hover, + focusWithin, + actionBarFocused, + hasContextMenu, +}: ShouldShowTimestampInput): boolean { + return ( + !!eventTs && + eventType !== EventType.RTCNotification && + !hideTimestamp && + (alwaysShowTimestamps || last || hover || focusWithin || actionBarFocused || hasContextMenu) + ); +} + +/** Inputs for EventTile timestamp value derivation. */ +export interface EventTileTimestampInput { + /** The current timeline rendering mode. */ + timelineRenderingType: TimelineRenderingType; + /** The event origin timestamp. */ + eventTs: number; + /** The latest thread reply timestamp, when available. */ + threadReplyEventTs?: number; +} + +/** The timestamp EventTile should display for the current rendering mode. */ +export function getEventTileTimestamp({ + timelineRenderingType, + eventTs, + threadReplyEventTs, +}: EventTileTimestampInput): number { + if (timelineRenderingType === TimelineRenderingType.ThreadsList && typeof threadReplyEventTs === "number") { + return threadReplyEventTs; + } + + return eventTs; +} + +/** Inputs for EventTile timestamp display state derivation. */ +export interface TimestampDisplayStateInput { + /** The current timeline layout. */ + layout?: Layout; + /** Whether the timestamp should render. */ + showTimestamp: boolean; + /** The timestamp EventTile may display. */ + timestamp: number; + /** Whether timestamp rendering is disabled. */ + hideTimestamp?: boolean; +} + +/** EventTile timestamp display state. */ +export interface TimestampDisplayState { + /** Whether the current layout is IRC. */ + useIRCLayout: boolean; + /** Whether EventTile should render the real timestamp element. */ + showRealTimestamp: boolean; + /** Whether EventTile should render the linked timestamp element. */ + showLinkedTimestamp: boolean; +} + +/** The EventTile timestamp display state for the current derived state. */ +export function getTimestampDisplayState({ + layout, + showTimestamp, + timestamp, + hideTimestamp, +}: TimestampDisplayStateInput): TimestampDisplayState { + const showRealTimestamp = showTimestamp && !!timestamp; + + return { + useIRCLayout: layout === Layout.IRC, + showRealTimestamp, + showLinkedTimestamp: showRealTimestamp && !hideTimestamp, + }; +} + +/** Inputs for ReplyChain timestamp visibility derivation. */ +export interface ReplyChainTimestampInput { + /** Whether timestamps should always show. */ + alwaysShowTimestamps?: boolean; + /** Whether the tile is currently hovered. */ + hover: boolean; + /** Whether focus is currently inside the tile. */ + focusWithin: boolean; +} + +/** Whether ReplyChain should always show timestamps. */ +export function getReplyChainAlwaysShowTimestamps({ + alwaysShowTimestamps, + hover, + focusWithin, +}: ReplyChainTimestampInput): boolean { + return !!alwaysShowTimestamps || hover || focusWithin; +} + +/** Inputs for EventTile footer display state derivation. */ +export interface FooterDisplayStateInput { + /** 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; + /** The current timeline layout. */ + layout?: Layout; + /** Whether the event was sent by the current user. */ + isOwnEvent: boolean; +} + +/** EventTile footer display state. */ +export interface FooterDisplayState { + /** Whether EventTile should render a footer. */ + hasFooter: boolean; + /** Whether the main footer position should render the pinned message badge. */ + showMainPinnedMessageBadge: boolean; + /** Whether the bubble footer position should render the pinned message badge. */ + showBubblePinnedMessageBadge: boolean; +} + +/** The EventTile footer display state for the current derived state. */ +export function getFooterDisplayState({ + hasReactionsRow, + hasReactions, + hasPinnedMessageBadge, + layout, + isOwnEvent, +}: FooterDisplayStateInput): FooterDisplayState { + return { + hasFooter: (hasReactionsRow && hasReactions) || hasPinnedMessageBadge, + showMainPinnedMessageBadge: hasPinnedMessageBadge && (layout === Layout.Group || !isOwnEvent), + showBubblePinnedMessageBadge: hasPinnedMessageBadge && layout === Layout.Bubble && isOwnEvent, + }; +} + +/** Inputs for EventTile root CSS class derivation. */ +export interface EventTileClassState { + /** Whether the tile should use bubble container styling. */ + isBubbleMessage: boolean; + /** Whether the bubble tile is left-aligned. */ + isLeftAlignedBubbleMessage: boolean; + /** Whether the event is currently being edited. */ + isEditing: boolean; + /** Whether the event renders as an informational timeline item. */ + isInfoMessage: boolean; + /** Whether timestamps use twelve-hour formatting. */ + isTwelveHour?: boolean; + /** Whether the event is in a pending send state. */ + isSending: 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 a continuation of the previous event. */ + isContinuation?: boolean; + /** The Matrix event type for event-type class derivation. */ + eventType: string; + /** 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; + /** Whether the action bar currently has focus. */ + isActionBarFocused: boolean; + /** Whether the event failed decryption. */ + isEncryptionFailure: boolean; + /** The Matrix message type for message-type class derivation. */ + msgtype?: string; + /** Whether sender details should be hidden. */ + hideSender?: boolean; + /** The current timeline rendering mode. */ + timelineRenderingType: TimelineRenderingType; + /** Whether the tile is rendering as a notification. */ + isRenderingNotification: boolean; + /** Whether bubble styling should be suppressed for this event. */ + noBubbleEvent: boolean; +} + +/** The EventTile root CSS class flags for the current derived state. */ +export function getEventTileClassState({ + isBubbleMessage, + isLeftAlignedBubbleMessage, + isEditing, + isInfoMessage, + isTwelveHour, + isSending, + isHighlighted, + isSelected, + isContinuation, + eventType, + isLast, + isLastInSection, + isContextual, + isActionBarFocused, + isEncryptionFailure, + msgtype, + hideSender, + timelineRenderingType, + isRenderingNotification, + noBubbleEvent, +}: EventTileClassState): Record { + return { + mx_EventTile_bubbleContainer: isBubbleMessage, + mx_EventTile_leftAlignedBubble: isLeftAlignedBubbleMessage, + mx_EventTile: true, + mx_EventTile_isEditing: isEditing, + mx_EventTile_info: isInfoMessage, + mx_EventTile_12hr: isTwelveHour, + // Note: we keep the `sending` state class for tests, not for our styles + mx_EventTile_sending: !isEditing && isSending, + mx_EventTile_highlight: isHighlighted, + mx_EventTile_selected: isSelected, + mx_EventTile_continuation: + isContinuation || eventType === EventType.CallInvite || ElementCallEventType.matches(eventType), + mx_EventTile_last: isLast, + mx_EventTile_lastInSection: isLastInSection, + mx_EventTile_contextual: isContextual, + mx_EventTile_actionBarFocused: isActionBarFocused, + mx_EventTile_bad: isEncryptionFailure, + mx_EventTile_emote: msgtype === MsgType.Emote, + mx_EventTile_noSender: hideSender, + mx_EventTile_clamp: timelineRenderingType === TimelineRenderingType.ThreadsList || isRenderingNotification, + mx_EventTile_noBubble: noBubbleEvent, + }; +} diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx index 26346f321a..4677b6bdea 100644 --- a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx @@ -19,6 +19,8 @@ import { MsgType, NotificationCountType, PendingEventOrdering, + RelationType, + type Relations, Room, TweakName, } from "matrix-js-sdk/src/matrix"; @@ -101,6 +103,41 @@ function makeThreadReplyEvent(roomId: string): MatrixEvent { }); } +function makeReactionEvent(roomId: string, targetEventId: string, sender: string, key: string): MatrixEvent { + return mkEvent({ + event: true, + type: EventType.Reaction, + room: roomId, + user: sender, + content: { + "m.relates_to": { + rel_type: RelationType.Annotation, + event_id: targetEventId, + key, + }, + }, + }); +} + +function makeRelations( + reactionsByKey: Map, + reactionsBySender: Record = {}, +): Relations { + return { + getSortedAnnotationsByKey: () => + [...reactionsByKey.entries()].map(([key, events]) => [key, new Set(events)] as [string, Set]), + getAnnotationsBySender: () => + Object.fromEntries( + Object.entries(reactionsBySender).map(([sender, events]) => [ + sender, + new Map(events.map((ev) => [ev.getId(), ev])), + ]), + ), + on: jest.fn(), + off: jest.fn(), + } as unknown as Relations; +} + describe("EventTile", () => { const ROOM_ID = "!roomId:example.org"; let mxEvent: MatrixEvent; @@ -259,6 +296,18 @@ describe("EventTile", () => { expect(getTile(container)).not.toHaveAttribute("data-scroll-tokens"); }); + + it("sets aria-live to off when the send status is undefined", () => { + const { container } = getComponent(); + + expect(getTile(container)).toHaveAttribute("aria-live", "off"); + }); + + it("does not set aria-live when the send status is explicitly null", () => { + const { container } = getComponent({ eventSendStatus: null as unknown as EventStatus }); + + expect(getTile(container)).not.toHaveAttribute("aria-live"); + }); }); describe("rendering root attributes", () => { @@ -507,6 +556,68 @@ describe("EventTile", () => { expect(senderDetails).toContainElement(container.querySelector(".mx_DisambiguatedProfile")); expect(senderDetails).toContainElement(container.querySelector(".mx_EventTile_avatar")); }); + + it("keeps sender and avatar when only the layout prop is set to bubble", () => { + const { container } = getComponent({ layout: Layout.Bubble }); + + expect(container.querySelector(".mx_DisambiguatedProfile")).not.toBeNull(); + expect(container.querySelector(".mx_EventTile_avatar")).not.toBeNull(); + }); + + it("hides the sender but keeps the info-message avatar for room create events", () => { + const createEvent = mkEvent({ + event: true, + type: EventType.RoomCreate, + room: room.roomId, + user: "@alice:example.org", + content: { creator: "@alice:example.org", room_version: "1" }, + }); + const { container } = getComponent({ mxEvent: createEvent }, TimelineRenderingType.Room, { + showHiddenEvents: true, + }); + + expect(container.querySelector(".mx_DisambiguatedProfile")).toBeNull(); + expect(container.querySelector(".mx_EventTile_avatar")).not.toBeNull(); + }); + + it("renders the notification avatar independently from the sender details", () => { + const { container } = getComponent({}, TimelineRenderingType.Notification); + const details = container.querySelector(".mx_EventTile_details"); + const avatar = container.querySelector(".mx_EventTile_avatar"); + + expect(details).not.toBeNull(); + expect(avatar).not.toBeNull(); + expect(details).not.toContainElement(avatar); + }); + }); + + describe("continuation rendering", () => { + it.each([TimelineRenderingType.Room, TimelineRenderingType.Search, TimelineRenderingType.Thread])( + "keeps continuation styling in %s timelines", + (renderingType) => { + const { container } = getComponent({ continuation: true }, renderingType); + + expect(getTile(container)).toHaveClass("mx_EventTile_continuation"); + }, + ); + + it.each([TimelineRenderingType.File, TimelineRenderingType.Notification, TimelineRenderingType.ThreadsList])( + "drops continuation styling in %s timelines when not using bubble layout", + (renderingType) => { + const { container } = getComponent({ continuation: true }, renderingType); + + expect(getTile(container)).not.toHaveClass("mx_EventTile_continuation"); + }, + ); + + it.each([TimelineRenderingType.File, TimelineRenderingType.Notification, TimelineRenderingType.ThreadsList])( + "keeps continuation styling in %s timelines when using bubble layout", + (renderingType) => { + const { container } = getComponent({ continuation: true, layout: Layout.Bubble }, renderingType); + + expect(getTile(container)).toHaveClass("mx_EventTile_continuation"); + }, + ); }); describe("read receipt option", () => { @@ -614,6 +725,55 @@ describe("EventTile", () => { expect(container.querySelector(".mx_EventTile_footer")).not.toBeNull(); expect(screen.getByText("Pinned message")).toBeInTheDocument(); }); + + it("renders the IRC footer inside the event line", () => { + jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true); + const { container } = getComponent({ layout: Layout.IRC }); + + expect(getLine(container).querySelector(".mx_EventTile_footer")).not.toBeNull(); + expect(getTile(container).querySelector(":scope > .mx_EventTile_footer")).toBeNull(); + }); + + it("renders a bubble footer for an own pinned message", () => { + jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true); + const ownEvent = makeOwnMessage(); + const { container } = getComponent({ mxEvent: ownEvent, layout: Layout.Bubble }); + const footer = container.querySelector(".mx_EventTile_footer"); + + expect(footer).not.toBeNull(); + expect(footer).toHaveTextContent("Pinned message"); + }); + + it("renders relation groups and deduplicates reactions from the same sender", () => { + const bobReaction1 = makeReactionEvent(room.roomId, mxEvent.getId()!, "@bob:example.org", "👍"); + const bobReaction2 = makeReactionEvent(room.roomId, mxEvent.getId()!, "@bob:example.org", "👍"); + const getRelationsForEvent = jest + .fn() + .mockReturnValue(makeRelations(new Map([["👍", [bobReaction1, bobReaction2]]]))); + + getComponent({ showReactions: true, getRelationsForEvent }, TimelineRenderingType.Room, { + canReact: true, + }); + + const reactionButton = screen.getByRole("button", { name: /@bob:example\.org reacted with 👍/ }); + expect(reactionButton).toHaveTextContent("👍1"); + }); + + it("detects the current user's reaction when rendering relation groups", () => { + const ownReaction = makeReactionEvent(room.roomId, mxEvent.getId()!, client.getSafeUserId(), "👍"); + const getRelationsForEvent = jest.fn().mockReturnValue( + makeRelations(new Map([["👍", [ownReaction]]]), { + [client.getSafeUserId()]: [ownReaction], + }), + ); + + getComponent({ showReactions: true, getRelationsForEvent }, TimelineRenderingType.Room, { + canReact: true, + canSelfRedact: false, + }); + + expect(screen.getByRole("button", { name: /reacted with 👍/ })).toHaveAttribute("aria-disabled", "true"); + }); }); describe("action bar", () => { @@ -907,6 +1067,20 @@ describe("EventTile", () => { }); }); + it("renders a notice when the event has no renderer", () => { + const unsupportedEvent = mkEvent({ + event: true, + type: "org.example.unsupported", + room: room.roomId, + user: "@alice:example.org", + content: {}, + }); + + getComponent({ mxEvent: unsupportedEvent }); + + expect(screen.getByText("This event could not be displayed")).toBeInTheDocument(); + }); + it("updates msgtype-derived tile classes when an edit changes msgtype to m.emote", async () => { const { container } = getComponent(); expect(container.querySelector(".mx_EventTile_emote")).toBeNull(); @@ -1251,6 +1425,57 @@ describe("EventTile", () => { expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1); expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0]).toHaveAccessibleName("Not encrypted"); }); + + it.each([EventStatus.ENCRYPTING, EventStatus.NOT_SENT])( + "does not show the unencrypted warning for %s events in encrypted rooms", + (status) => { + const event = makeOwnMessage(); + event.setStatus(status); + const { container } = getComponent( + { mxEvent: event, eventSendStatus: status }, + TimelineRenderingType.Room, + { + isRoomEncrypted: true, + }, + ); + + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); + }, + ); + + it("does not show the unencrypted warning for state events in encrypted rooms", () => { + const stateEvent = mkEvent({ + event: true, + type: EventType.RoomTopic, + room: room.roomId, + user: "@alice:example.org", + skey: "", + content: { topic: "Topic" }, + }); + const { container } = getComponent({ mxEvent: stateEvent }, TimelineRenderingType.Room, { + isRoomEncrypted: true, + }); + + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); + }); + + it("does not show the unencrypted warning for redacted events in encrypted rooms", () => { + jest.spyOn(mxEvent, "isRedacted").mockReturnValue(true); + const { container } = getComponent({}, TimelineRenderingType.Room, { + isRoomEncrypted: true, + }); + + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); + }); + + it("does not show the unencrypted warning for local-room events in encrypted rooms", () => { + const localEvent = makeTimestampedMessage({ room: "local+room" }); + const { container } = getComponent({ mxEvent: localEvent }, TimelineRenderingType.Room, { + isRoomEncrypted: true, + }); + + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); + }); }); describe("event highlighting", () => { diff --git a/apps/web/test/viewmodels/audio/AudioPlayerViewModel-test.tsx b/apps/web/test/viewmodels/audio/AudioPlayerViewModel-test.tsx index 4e4f6c6c91..5c0b474460 100644 --- a/apps/web/test/viewmodels/audio/AudioPlayerViewModel-test.tsx +++ b/apps/web/test/viewmodels/audio/AudioPlayerViewModel-test.tsx @@ -65,6 +65,18 @@ describe("AudioPlayerViewModel", () => { expect(playback.skipTo).toHaveBeenCalledWith(10 + 5); // 5 seconds forward }); + it("does not stop propagation for unhandled key down events", () => { + const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" }); + const event = new KeyboardEvent("keydown", { key: "a" }); + const stopPropagationSpy = jest.spyOn(event, "stopPropagation"); + + vm.onKeyDown(event as unknown as ReactKeyboardEvent); + + expect(stopPropagationSpy).not.toHaveBeenCalled(); + expect(playback.toggle).not.toHaveBeenCalled(); + expect(playback.skipTo).not.toHaveBeenCalled(); + }); + it("should update snapshot when setProps is called with new mediaName", () => { const vm = new AudioPlayerViewModel({ playback, mediaName: "oldName" }); expect(vm.getSnapshot().mediaName).toBe("oldName"); diff --git a/apps/web/test/viewmodels/message-body/MessageTimestampViewModel-test.tsx b/apps/web/test/viewmodels/message-body/MessageTimestampViewModel-test.tsx index de8aaa1643..2ec046c446 100644 --- a/apps/web/test/viewmodels/message-body/MessageTimestampViewModel-test.tsx +++ b/apps/web/test/viewmodels/message-body/MessageTimestampViewModel-test.tsx @@ -119,4 +119,68 @@ describe("MessageTimestampViewModel", () => { href: "https://example.test", }); }); + + it("updates the timestamp and received timestamp", () => { + const vm = new MessageTimestampViewModel({ + ts: nowDate.getTime(), + }); + + vm.setTimestamp(nowDate.getTime() + HOUR_MS); + vm.setReceivedTimestamp(nowDate.getTime() + DAY_MS); + + expect(vm.getSnapshot()).toMatchObject({ + ts: "09:09", + tsSentAt: "Fri, Dec 17, 2021, 09:09:00", + tsReceivedAt: "Sat, Dec 18, 2021, 08:09:00", + }); + }); + + it("updates display options", () => { + const vm = new MessageTimestampViewModel({ + ts: nowDate.getTime(), + }); + + vm.setDisplayOptions({ + showTwelveHour: true, + showSeconds: true, + }); + + expect(vm.getSnapshot()).toMatchObject({ + ts: "8:09:00 AM", + tsSentAt: "Fri, Dec 17, 2021, 8:09:00 AM", + }); + }); + + it("updates tooltip, href, and handlers", () => { + const onClick = jest.fn(); + const onContextMenu = jest.fn(); + const vm = new MessageTimestampViewModel({ + ts: nowDate.getTime(), + }); + + vm.setTooltipInhibited(true); + vm.setHref("https://example.test/event"); + vm.setHandlers({ onClick, onContextMenu }); + + expect(vm.getSnapshot()).toMatchObject({ + inhibitTooltip: true, + href: "https://example.test/event", + }); + expect(vm.onClick).toBe(onClick); + expect(vm.onContextMenu).toBe(onContextMenu); + }); + + it("does not emit an update when props are unchanged", () => { + const vm = new MessageTimestampViewModel({ + ts: nowDate.getTime(), + href: "https://example.test/event", + }); + const listener = jest.fn(); + vm.subscribe(listener); + + vm.setTimestamp(nowDate.getTime()); + vm.setHref("https://example.test/event"); + + expect(listener).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/test/viewmodels/message-body/ReactionsRowButtonViewModel-test.tsx b/apps/web/test/viewmodels/message-body/ReactionsRowButtonViewModel-test.tsx index 2c14e003fb..e12b97d43e 100644 --- a/apps/web/test/viewmodels/message-body/ReactionsRowButtonViewModel-test.tsx +++ b/apps/web/test/viewmodels/message-body/ReactionsRowButtonViewModel-test.tsx @@ -16,6 +16,9 @@ import { createTestClient, mkEvent, mkStubRoom } from "../../test-utils"; import dis from "../../../src/dispatcher/dispatcher"; jest.mock("../../../src/dispatcher/dispatcher"); +jest.mock("../../../src/customisations/Media", () => ({ + mediaFromMxc: jest.fn(() => ({ srcHttp: "https://example.org/_matrix/media/reaction.png" })), +})); describe("ReactionsRowButtonViewModel", () => { let client: MatrixClient; @@ -91,6 +94,35 @@ describe("ReactionsRowButtonViewModel", () => { expect(getAriaLabel(vm)).toContain("reacted with 👍"); }); + it("falls back when no room is available", () => { + jest.spyOn(client, "getRoom").mockReturnValue(null); + + const vm = new ReactionsRowButtonViewModel(createProps()); + + expect(getAriaLabel(vm)).toBeUndefined(); + expect(vm.getSnapshot().content).toBe("👍"); + expect(vm.getSnapshot().count).toBe(2); + }); + + it("renders custom reaction images with shortcode labels when enabled", () => { + const reactionEvent = createReactionEvent("@alice:example.org", "mxc://example.org/reaction"); + reactionEvent.getContent()["shortcode"] = "party"; + + const vm = new ReactionsRowButtonViewModel( + createProps({ + content: "mxc://example.org/reaction", + reactionEvents: [reactionEvent], + customReactionImagesEnabled: true, + }), + ); + + expect(vm.getSnapshot()).toMatchObject({ + imageSrc: "https://example.org/_matrix/media/reaction.png", + imageAlt: "party", + }); + expect(getAriaLabel(vm)).toContain("reacted with party"); + }); + it("updates selected state with myReactionEvent without touching tooltip props", () => { const vm = new ReactionsRowButtonViewModel(createProps()); const tooltipSetPropsSpy = jest.spyOn(getTooltipVm(vm), "setProps"); diff --git a/apps/web/test/viewmodels/room/ThreadListActionBarViewModel-test.ts b/apps/web/test/viewmodels/room/ThreadListActionBarViewModel-test.ts new file mode 100644 index 0000000000..64b00aa056 --- /dev/null +++ b/apps/web/test/viewmodels/room/ThreadListActionBarViewModel-test.ts @@ -0,0 +1,57 @@ +/* + * 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 { ActionBarAction } from "@element-hq/web-shared-components"; + +import { ThreadListActionBarViewModel } from "../../../src/viewmodels/room/ThreadListActionBarViewModel"; + +describe("ThreadListActionBarViewModel", () => { + it("builds the thread-list action bar snapshot", () => { + const vm = new ThreadListActionBarViewModel({}); + + expect(vm.getSnapshot()).toMatchObject({ + actions: [ActionBarAction.ViewInRoom, ActionBarAction.CopyLink], + presentation: "icon", + isDownloadEncrypted: false, + isDownloadLoading: false, + isPinned: false, + isQuoteExpanded: false, + isThreadReplyAllowed: true, + }); + }); + + it("forwards actions to the configured handlers", () => { + const onViewInRoomClick = jest.fn(); + const onCopyLinkClick = jest.fn(); + const vm = new ThreadListActionBarViewModel({ + onViewInRoomClick, + onCopyLinkClick, + }); + const anchor = document.createElement("button"); + + vm.onViewInRoomClick(anchor); + vm.onCopyLinkClick(anchor); + + expect(onViewInRoomClick).toHaveBeenCalledWith(anchor); + expect(onCopyLinkClick).toHaveBeenCalledWith(anchor); + }); + + it("uses updated handlers after setProps", () => { + const initialOnViewInRoomClick = jest.fn(); + const nextOnViewInRoomClick = jest.fn(); + const vm = new ThreadListActionBarViewModel({ + onViewInRoomClick: initialOnViewInRoomClick, + }); + const anchor = document.createElement("button"); + + vm.setProps({ onViewInRoomClick: nextOnViewInRoomClick }); + vm.onViewInRoomClick(anchor); + + expect(initialOnViewInRoomClick).not.toHaveBeenCalled(); + expect(nextOnViewInRoomClick).toHaveBeenCalledWith(anchor); + }); +});