From a8f9c75fc1f4756244e636dde68c98e220360648 Mon Sep 17 00:00:00 2001 From: rbondesson Date: Thu, 11 Jun 2026 10:05:43 +0200 Subject: [PATCH] Make EventTileViewModel consume pure render data (#33805) --- .../src/components/views/rooms/EventTile.tsx | 34 +++++-- .../rooms/EventTile/SenderIdentityAdapter.tsx | 14 +-- .../event-tile/EventTileDerivedState.ts | 71 +++++++------- .../timeline/event-tile/EventTileViewModel.ts | 75 +++++++++------ .../event-tiles/EventTileViewModel-test.ts | 96 +++++++++---------- 5 files changed, 163 insertions(+), 127 deletions(-) diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 87af26a2a7..0be412a8da 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -17,7 +17,8 @@ import React, { type ReactNode, } from "react"; import { - type EventStatus, + EventStatus, + EventType, type MatrixEvent, MatrixEventEvent, type Relations, @@ -690,6 +691,14 @@ export class UnwrappedEventTile extends React.Component }); }; + private readonly getAvatarMember = (): RoomMember | null => { + if (this.props.mxEvent.getContent().third_party_invite) { + return this.props.mxEvent.target; + } + + return this.props.mxEvent.sender; + }; + private readonly onReactionsCreated = (relationType: string, eventType: string): void => { if (!isEventTileReactionRelation(relationType, eventType)) { return; @@ -852,11 +861,25 @@ export class UnwrappedEventTile extends React.Component const isProbablyMedia = MediaEventHelper.isEligible(this.props.mxEvent); const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure(); const isEditing = !!this.props.editState; + const eventType = this.props.mxEvent.getType(); + const msgtype = this.props.mxEvent.getContent().msgtype; + const isSending = + this.props.eventSendStatus === EventStatus.SENDING || + this.props.eventSendStatus === EventStatus.QUEUED || + this.props.eventSendStatus === EventStatus.ENCRYPTING; return { event: { - mxEvent: this.props.mxEvent, - eventSendStatus: this.props.eventSendStatus, + eventType, + msgtype, + eventTs: this.props.mxEvent.getTs(), + eventId: this.props.mxEvent.getId() ?? undefined, + isLocalEcho: !!this.props.mxEvent.status, + isSending, + ariaLive: this.props.eventSendStatus === null ? undefined : "off", + isRoomCreate: eventType === EventType.RoomCreate, + isCallInvite: eventType === EventType.CallInvite, + isRtcNotification: eventType === EventType.RTCNotification, isEditing, isEncryptionFailure, forExport: this.props.forExport, @@ -974,9 +997,8 @@ export class UnwrappedEventTile extends React.Component const scrollToken = eventTileRenderState.root.scrollToken; const rootRenderState = { tileClasses, tileAriaLive, scrollToken }; - const avatar = ( - - ); + const avatarMember = this.getAvatarMember(); + const avatar = ; const sender = ( ): JSX.Element | null { const { avatarSize } = senderSnapshot.profileState; - if (!mxEvent.sender || avatarSize === null) { + if (!avatarMember || avatarSize === null) { return null; } return (
); diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileDerivedState.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileDerivedState.ts index e2529243b7..3291f438ac 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileDerivedState.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileDerivedState.ts @@ -5,8 +5,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com 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"; @@ -16,19 +14,17 @@ import { Layout } from "../../../../settings/enums/Layout"; * 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"; +/** Inputs for the stable scroll token derivation. */ +export interface ScrollTokenInput { + /** The event identifier, when available. */ + eventId?: string; + /** Whether the event is a local echo. */ + isLocalEcho: boolean; } /** The stable scroll token for a non-local-echo event. */ -export function getScrollToken(mxEvent: MatrixEvent): string | undefined { - return mxEvent.status ? undefined : mxEvent.getId(); +export function getScrollToken({ eventId, isLocalEcho }: ScrollTokenInput): string | undefined { + return isLocalEcho ? undefined : eventId; } /** Whether EventTile should render as a continuation in the current layout/rendering mode. */ @@ -65,11 +61,16 @@ export function getEventTileLineClassState({ eventType, msgtype, }: EventTileLineClassState): Record { + const roomMessageEventType = "m.room.message"; + const stickerEventType = "m.sticker"; + const imageMsgtype = "m.image"; + const emoteMsgtype = "m.emote"; + 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, + mx_EventTile_image: eventType === roomMessageEventType && msgtype === imageMsgtype, + mx_EventTile_sticker: eventType === stickerEventType, + mx_EventTile_emote: eventType === roomMessageEventType && msgtype === emoteMsgtype, }; } @@ -89,6 +90,12 @@ export interface EventTileSenderProfileStateInput { isBubbleMessage: boolean; /** The current timeline layout. */ layout?: Layout; + /** Whether the event is a room create event. */ + isRoomCreate: boolean; + /** Whether the event is a call invite. */ + isCallInvite: boolean; + /** Whether the event is an RTC notification. */ + isRtcNotification: boolean; } /** EventTile avatar and sender profile display state. */ @@ -108,6 +115,9 @@ export function getEventTileSenderProfileState({ eventType, isBubbleMessage, layout, + isRoomCreate, + isCallInvite, + isRtcNotification, }: EventTileSenderProfileStateInput): EventTileSenderProfileState { if (isRenderingNotification) { return { avatarSize: "24px", needsSenderProfile: true }; @@ -124,7 +134,7 @@ export function getEventTileSenderProfileState({ return { avatarSize: "32px", needsSenderProfile: true }; } - if (eventType === EventType.RoomCreate || isBubbleMessage) { + if (isRoomCreate || isBubbleMessage) { return { avatarSize: null, needsSenderProfile: false }; } @@ -134,9 +144,9 @@ export function getEventTileSenderProfileState({ if ( (continuation && timelineRenderingType !== TimelineRenderingType.File) || - eventType === EventType.CallInvite || + isCallInvite || ElementCallEventType.matches(eventType) || - eventType === EventType.RTCNotification + isRtcNotification ) { return { avatarSize: null, needsSenderProfile: false }; } @@ -148,15 +158,6 @@ export function getEventTileSenderProfileState({ 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, @@ -239,8 +240,8 @@ export function getShouldShowMessageActionBar({ export interface ShouldShowTimestampInput { /** The event origin timestamp. */ eventTs: number; - /** The Matrix event type for event-type timestamp derivation. */ - eventType: string; + /** Whether the event is an RTC notification. */ + isRtcNotification: boolean; /** Whether timestamp rendering is disabled. */ hideTimestamp?: boolean; /** Whether timestamps should always show. */ @@ -260,7 +261,7 @@ export interface ShouldShowTimestampInput { /** Whether EventTile should render the message timestamp. */ export function getShouldShowTimestamp({ eventTs, - eventType, + isRtcNotification, hideTimestamp, alwaysShowTimestamps, last, @@ -271,7 +272,7 @@ export function getShouldShowTimestamp({ }: ShouldShowTimestampInput): boolean { return ( !!eventTs && - eventType !== EventType.RTCNotification && + !isRtcNotification && !hideTimestamp && (alwaysShowTimestamps || last || hover || focusWithin || actionBarFocused || hasContextMenu) ); @@ -420,6 +421,8 @@ export interface EventTileClassState { isContinuation?: boolean; /** The Matrix event type for event-type class derivation. */ eventType: string; + /** Whether the event is a call invite. */ + isCallInvite: boolean; /** Whether the tile is the last event in the timeline. */ isLast?: boolean; /** Whether the tile is the last event in its section. */ @@ -455,6 +458,7 @@ export function getEventTileClassState({ isSelected, isContinuation, eventType, + isCallInvite, isLast, isLastInSection, isContextual, @@ -478,14 +482,13 @@ export function getEventTileClassState({ 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_continuation: isContinuation || isCallInvite || 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_emote: msgtype === "m.emote", mx_EventTile_noSender: hideSender, mx_EventTile_clamp: timelineRenderingType === TimelineRenderingType.ThreadsList || isRenderingNotification, mx_EventTile_noBubble: noBubbleEvent, diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts index 44cfd90125..a1240dceeb 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts @@ -5,15 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com 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 classNames from "classnames"; import { BaseViewModel } from "@element-hq/web-shared-components"; import { type EventTileSenderProfileState, type FooterDisplayState, - getAriaLive, - getEventTileAvatarMember, getEventTileClassState, getEventTileLineClassState, getEventTileSenderProfileState, @@ -27,7 +24,6 @@ import { getShouldShowTimestamp, getShouldViewUserOnClick, getTimestampDisplayState, - isSendingStatus, type SenderProfileMode, type TimestampDisplayState, } from "./EventTileDerivedState"; @@ -54,10 +50,26 @@ import { ReactionsRowViewModel, type ReactionsRowViewModelProps } from "./reacti /** 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; + /** The event type rendered by the tile. */ + eventType: string; + /** The Matrix message type rendered by the tile. */ + msgtype?: string; + /** The event origin timestamp. */ + eventTs: number; + /** The stable event identifier, when available. */ + eventId?: string; + /** Whether the event is a local echo. */ + isLocalEcho: boolean; + /** Whether the event is in a pending send state. */ + isSending: boolean; + /** Whether EventTile should announce updates in an aria-live region. */ + ariaLive?: "off"; + /** Whether the event is a room create event. */ + isRoomCreate: boolean; + /** Whether the event is a call invite. */ + isCallInvite: boolean; + /** Whether the event is an RTC notification. */ + isRtcNotification: boolean; /** Whether the event is currently being edited. */ isEditing: boolean; /** Whether the event failed decryption. */ @@ -196,12 +208,12 @@ export interface EventTileLineSnapshot { 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; + /** Whether the avatar should use historical room member details. */ + forceHistoricalAvatar: boolean; } /** Action bar state derived for the EventTile snapshot. */ @@ -459,15 +471,12 @@ export class EventTileViewModel extends BaseViewModel { @@ -605,6 +623,7 @@ export class EventTileViewModel extends BaseViewModel { - const roomId = "!room:example.org"; - const userId = "@alice:example.org"; type EventTileViewModelPropsOverrides = { event?: Partial; display?: Partial; @@ -27,34 +22,22 @@ describe("EventTileViewModel", () => { 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(), + eventType: "m.room.message", + msgtype: "m.text", + eventTs: 123, + eventId: "$event", + isLocalEcho: false, + isSending: false, + ariaLive: "off", + isRoomCreate: false, + isCallInvite: false, + isRtcNotification: false, isEditing: false, isEncryptionFailure: false, + forExport: false, ...overrides.event, }, display: { @@ -94,14 +77,12 @@ describe("EventTileViewModel", () => { }; } - it("derives sending, aria-live, and local echo scroll state", () => { - const mxEvent = makeMessageEvent({ status: EventStatus.SENDING }); - + it("derives sending, aria-live, and scroll state from plain event data", () => { const snapshot = EventTileViewModel.createSnapshot( makeProps({ event: { - mxEvent, - eventSendStatus: EventStatus.SENDING, + isSending: true, + isLocalEcho: true, }, }), ); @@ -112,14 +93,25 @@ describe("EventTileViewModel", () => { expect(snapshot.root.classState.mx_EventTile_sending).toBe(true); }); - it("derives render-ready root and line state", () => { - const mxEvent = makeMessageEvent({ status: EventStatus.SENDING }); + it("derives a scroll token for non-local-echo events", () => { + const snapshot = EventTileViewModel.createSnapshot( + makeProps({ + event: { + eventId: "$remote-event", + isLocalEcho: false, + }, + }), + ); + expect(snapshot.root.scrollToken).toBe("$remote-event"); + }); + + it("derives render-ready root and line state", () => { const renderState = EventTileViewModel.createRenderState( makeProps({ event: { - mxEvent, - eventSendStatus: EventStatus.SENDING, + isSending: true, + isLocalEcho: true, }, display: { isHighlighted: true, @@ -269,9 +261,7 @@ describe("EventTileViewModel", () => { const snapshot = EventTileViewModel.createSnapshot( makeProps({ event: { - mxEvent: makeMessageEvent({ - content: { msgtype: MsgType.Image, body: "image" }, - }), + msgtype: "m.image", }, display: { isProbablyMedia: true, @@ -317,6 +307,18 @@ describe("EventTileViewModel", () => { expect(snapshot.sender.viewUserOnClick).toBe(true); }); + it("marks room member avatars as historical", () => { + const snapshot = EventTileViewModel.createSnapshot( + makeProps({ + event: { + eventType: "m.room.member", + }, + }), + ); + + expect(snapshot.sender.forceHistoricalAvatar).toBe(true); + }); + it("derives action bar visibility from interaction state", () => { const hoverSnapshot = EventTileViewModel.createSnapshot(makeProps({ interaction: { hover: true } })); const contextMenuSnapshot = EventTileViewModel.createSnapshot( @@ -356,10 +358,7 @@ describe("EventTileViewModel", () => { const snapshot = EventTileViewModel.createSnapshot( makeProps({ event: { - mxEvent: makeMessageEvent({ - type: EventType.RTCNotification, - content: {}, - }), + isRtcNotification: true, }, interaction: { hover: true, @@ -479,17 +478,10 @@ describe("EventTileViewModel", () => { }); it("does not initialize timestamp child view models for events without an origin timestamp", () => { - const mxEvent = new MatrixEvent({ - type: EventType.RoomMessage, - room_id: roomId, - sender: userId, - content: { msgtype: MsgType.Text, body: "Hello" }, - event_id: "$event", - }); const vm = new EventTileViewModel( makeProps({ event: { - mxEvent, + eventTs: 0, }, timestamp: { hideTimestamp: true,