diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 18e2a57a10..756b8e33ea 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -18,8 +18,6 @@ import React, { } from "react"; import { EventStatus, - EventType, - MsgType, type MatrixEvent, MatrixEventEvent, type Relations, @@ -50,19 +48,13 @@ import { type ComposerInsertPayload } from "../../../dispatcher/payloads/Compose import { Action } from "../../../dispatcher/actions"; import PlatformPeg from "../../../PlatformPeg"; import { type IReadReceiptPosition } from "./ReadReceiptMarker"; -import { getEventDisplayInfo } from "../../../utils/EventRenderingUtils"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { copyPlaintext } from "../../../utils/strings"; import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import PosthogTrackers from "../../../PosthogTrackers"; -import { - haveRendererForEvent, - isMessageEvent, - renderTile, - type EventTileTypeProps, -} from "../../../events/EventTileFactory"; +import { isMessageEvent, renderTile, type EventTileTypeProps } from "../../../events/EventTileFactory"; import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge"; import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper"; @@ -80,7 +72,9 @@ import { EventTileThreadInfo, EventTileThreadPanelSummary } from "./EventTile/Ev import { EventTileTimestampSlot } from "./EventTile/EventTileTimestampSlot"; import { EventTileViewModel, + type EventTileRenderState, type EventTileViewModelProps, + type EventTileViewModelDependencies, } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; import { getEventTileReceiptState, @@ -91,7 +85,6 @@ import { getEventTileThreadState, type EventTileThreadState, } from "../../../viewmodels/room/timeline/event-tile/EventTileThreadState"; -import { getEventTileReplyChainState } from "../../../viewmodels/room/timeline/event-tile/EventTileReplyChainState"; import { eventTileActionBarFocusChange, eventTileBlurWithin, @@ -279,19 +272,12 @@ interface IState { } interface EventTileRenderInputs { - displayInfo: ReturnType; hasPinnedMessageBadge: boolean; hasReactionsRow: boolean; threadState: EventTileThreadState; isOwnEvent: boolean; } -interface EventTileRootRenderState { - tileClasses: string; - tileAriaLive?: "off"; - scrollToken?: string; -} - /** EventTile implementation rendered inside a RoomContext with `timelineRenderingType` set. */ export class UnwrappedEventTile extends React.Component { private suppressReadReceiptAnimation: boolean; @@ -332,7 +318,7 @@ export class UnwrappedEventTile extends React.Component thread, }; - this.viewModel = new EventTileViewModel(this.createViewModelProps()); + this.viewModel = new EventTileViewModel(this.createViewModelDependencies(), this.createViewModelProps()); this.e2eViewModel = new EventTileE2eViewModel({ cli: MatrixClientPeg.safeGet(), @@ -795,19 +781,19 @@ export class UnwrappedEventTile extends React.Component } private createRootAttributes({ - tileClasses, - tileAriaLive, + className, + ariaLive, scrollToken, - }: EventTileRootRenderState): Record { + }: EventTileRenderState["root"]): Record { return { - "className": tileClasses, - "aria-live": tileAriaLive, + "className": className, + "aria-live": ariaLive, "aria-atomic": true, "data-scroll-tokens": scrollToken, }; } - private createInteractiveRootAttributes(rootRenderState: EventTileRootRenderState): Record { + private createInteractiveRootAttributes(rootRenderState: EventTileRenderState["root"]): Record { return { ...this.createRootAttributes(rootRenderState), ref: this.ref, @@ -839,14 +825,16 @@ export class UnwrappedEventTile extends React.Component }; } - private createRenderInputs( - displayInfo = getEventDisplayInfo( - MatrixClientPeg.safeGet(), - this.props.mxEvent, - this.context.showHiddenEvents, - shouldHideEventTile({ callEventGrouper: this.props.callEventGrouper }), - ), - ): EventTileRenderInputs { + private createViewModelDependencies(): EventTileViewModelDependencies { + return { + mxEvent: this.props.mxEvent, + matrixClient: MatrixClientPeg.safeGet(), + showHiddenEvents: this.context.showHiddenEvents, + hideEvent: shouldHideEventTile({ callEventGrouper: this.props.callEventGrouper }), + }; + } + + private createRenderInputs(): EventTileRenderInputs { const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted; const hasPinnedMessageBadge = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent); const hasReactionsRow = !isRedacted; @@ -855,7 +843,6 @@ export class UnwrappedEventTile extends React.Component const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.safeGet().getUserId(); return { - displayInfo, hasPinnedMessageBadge, hasReactionsRow, threadState, @@ -864,12 +851,9 @@ export class UnwrappedEventTile extends React.Component } private createViewModelProps(inputs: EventTileRenderInputs = this.createRenderInputs()): EventTileViewModelProps { - const { displayInfo, hasPinnedMessageBadge, hasReactionsRow, threadState, isOwnEvent } = inputs; + const { hasPinnedMessageBadge, hasReactionsRow, threadState, isOwnEvent } = inputs; const isProbablyMedia = MediaEventHelper.isEligible(this.props.mxEvent); - const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure(); const isEditing = !!this.props.editState; - const eventType = this.props.mxEvent.getType(); - const msgtype = this.props.mxEvent.getContent().msgtype; const isSending = this.props.eventSendStatus === EventStatus.SENDING || this.props.eventSendStatus === EventStatus.QUEUED || @@ -877,18 +861,9 @@ export class UnwrappedEventTile extends React.Component return { event: { - eventType, - msgtype, - eventTs: this.props.mxEvent.getTs(), - eventId: this.props.mxEvent.getId() ?? undefined, - isLocalEcho: !!this.props.mxEvent.status, isSending, ariaLive: this.props.eventSendStatus === null ? undefined : "off", - isRoomCreate: eventType === EventType.RoomCreate, - isCallInvite: eventType === EventType.CallInvite, - isRtcNotification: eventType === EventType.RTCNotification, isEditing, - isEncryptionFailure, forExport: this.props.forExport, }, display: { @@ -896,11 +871,6 @@ export class UnwrappedEventTile extends React.Component layout: this.props.layout, continuation: this.props.continuation, isProbablyMedia, - isBubbleMessage: displayInfo.isBubbleMessage, - isLeftAlignedBubbleMessage: displayInfo.isLeftAlignedBubbleMessage, - isAlignedBetweenBubbles: displayInfo.isAlignedBetweenBubbles, - isInfoMessage: displayInfo.isInfoMessage, - noBubbleEvent: displayInfo.noBubbleEvent, isTwelveHour: this.props.isTwelveHour, isHighlighted: shouldHighlightEventTile({ cli: MatrixClientPeg.safeGet(), @@ -923,7 +893,6 @@ export class UnwrappedEventTile extends React.Component inhibitInteraction: this.props.inhibitInteraction, }, sender: { - senderId: this.props.mxEvent.getSender() ?? undefined, member: roomMemberToMemberInfo( this.props.useEventSenderSnapshot ? this.getAvatarMember() @@ -936,7 +905,6 @@ export class UnwrappedEventTile extends React.Component }), ), hideSender: this.props.hideSender, - isEmote: this.props.mxEvent.getContent().msgtype === MsgType.Emote, }, timestamp: { alwaysShowTimestamps: this.props.alwaysShowTimestamps, @@ -977,22 +945,20 @@ export class UnwrappedEventTile extends React.Component } public render(): ReactNode { - const eventType = this.props.mxEvent.getType(); - const replacingEventId = this.props.mxEvent.replacingEventId(); - - const displayInfo = getEventDisplayInfo( - MatrixClientPeg.safeGet(), - this.props.mxEvent, - this.context.showHiddenEvents, - shouldHideEventTile({ callEventGrouper: this.props.callEventGrouper }), - ); - const { hasRenderer, isSeeingThroughMessageHiddenForModeration } = displayInfo; const { isQuoteExpanded } = this.state; + const renderInputs = this.createRenderInputs(); + const { hasPinnedMessageBadge, hasReactionsRow, threadState } = renderInputs; + + this.viewModel.setInputs(this.createViewModelDependencies(), this.createViewModelProps(renderInputs)); + const eventTileRenderState = this.viewModel.getSnapshot(); + const eventTileSnapshot = eventTileRenderState.snapshot; + // This shouldn't happen: the caller should check we support this type // before trying to instantiate us - if (!hasRenderer) { - const { mxEvent } = this.props; - logger.warn(`Event type not supported: type:${eventType} isState:${mxEvent.isState()}`); + if (!eventTileSnapshot.event.hasRenderer) { + logger.warn( + `Event type not supported: type:${eventTileSnapshot.event.eventType} isState:${eventTileSnapshot.event.isState}`, + ); return (
{_t("timeline|error_no_renderer")}
@@ -1000,22 +966,13 @@ export class UnwrappedEventTile extends React.Component ); } - const renderInputs = this.createRenderInputs(displayInfo); - const { hasPinnedMessageBadge, hasReactionsRow, threadState, isOwnEvent } = renderInputs; - - this.viewModel.setProps(this.createViewModelProps(renderInputs)); - const eventTileRenderState = this.viewModel.getSnapshot(); - const eventTileSnapshot = eventTileRenderState.snapshot; - const lineClasses = eventTileRenderState.line.className; - const tileClasses = eventTileRenderState.root.className; - const tileAriaLive = eventTileRenderState.root.ariaLive; - const isRenderingNotification = eventTileRenderState.root.isRenderingNotification; + const isRenderingNotification = eventTileSnapshot.event.isRenderingNotification; + const isSeeingThroughMessageHiddenForModeration = + eventTileSnapshot.event.isSeeingThroughMessageHiddenForModeration; const permalink = this.getPermalink(); - - const scrollToken = eventTileRenderState.root.scrollToken; - const rootRenderState = { tileClasses, tileAriaLive, scrollToken }; + const rootRenderState = eventTileRenderState.root; const avatarMember = this.getAvatarMember(); const avatar = ; @@ -1083,16 +1040,8 @@ export class UnwrappedEventTile extends React.Component /> ); - const replyChainState = getEventTileReplyChainState({ - mxEvent: this.props.mxEvent, - hasRenderer: haveRendererForEvent( - this.props.mxEvent, - MatrixClientPeg.safeGet(), - this.context.showHiddenEvents, - ), - }); let replyChain: JSX.Element | undefined; - if (replyChainState.shouldShowReplyChain) { + if (eventTileSnapshot.root.data.hasReply) { replyChain = ( this.props.as || "li", { ...this.createInteractiveRootAttributes(rootRenderState), - "data-has-reply": !!replyChain, - "data-layout": this.props.layout, - "data-self": isOwnEvent, - "data-event-id": this.props.mxEvent.getId(), + "data-has-reply": eventTileSnapshot.root.data.hasReply, + "data-layout": eventTileSnapshot.root.data.layout, + "data-self": eventTileSnapshot.root.data.isOwnEvent, + "data-event-id": eventTileSnapshot.root.data.eventId, }, [
@@ -1136,7 +1085,7 @@ export class UnwrappedEventTile extends React.Component {renderTile( TimelineRenderingType.Thread, this.createRenderTileProps({ - replacingEventId, + replacingEventId: eventTileSnapshot.event.replacingEventId, isSeeingThroughMessageHiddenForModeration, permalinkCreator: this.props.permalinkCreator!, }), @@ -1169,10 +1118,10 @@ export class UnwrappedEventTile extends React.Component { ...this.createInteractiveRootAttributes(rootRenderState), "tabIndex": -1, - "data-layout": this.props.layout, - "data-shape": this.context.timelineRenderingType, - "data-self": isOwnEvent, - "data-has-reply": !!replyChain, + "data-layout": eventTileSnapshot.root.data.layout, + "data-shape": eventTileSnapshot.root.data.shape, + "data-self": eventTileSnapshot.root.data.isOwnEvent, + "data-has-reply": eventTileSnapshot.root.data.hasReply, "onClick": (ev: MouseEvent) => { const target = ev.currentTarget as HTMLElement; let index = -1; @@ -1210,7 +1159,7 @@ export class UnwrappedEventTile extends React.Component {timestamp}
@@ -1274,10 +1223,10 @@ export class UnwrappedEventTile extends React.Component { ...this.createInteractiveRootAttributes(rootRenderState), "tabIndex": -1, - "data-layout": this.props.layout, - "data-self": isOwnEvent, - "data-event-id": this.props.mxEvent.getId(), - "data-has-reply": !!replyChain, + "data-layout": eventTileSnapshot.root.data.layout, + "data-self": eventTileSnapshot.root.data.isOwnEvent, + "data-event-id": eventTileSnapshot.root.data.eventId, + "data-has-reply": eventTileSnapshot.root.data.hasReply, }, <> {ircTimestamp} @@ -1295,7 +1244,7 @@ export class UnwrappedEventTile extends React.Component {groupPadlock} {replyChain} {renderTile( - this.context.timelineRenderingType, + eventTileSnapshot.root.data.shape, this.createRenderTileProps({ isSeeingThroughMessageHiddenForModeration, }), 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 8128beb3f3..00a26e38b9 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileViewModel.ts @@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details. import classNames from "classnames"; import { BaseViewModel } from "@element-hq/web-shared-components"; +import { EventType, MsgType, type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { type EventTileSenderProfileState, @@ -42,6 +43,9 @@ import { type E2eMessageSharedIconViewModelProps, } from "./E2eMessageSharedIconViewModel"; import { EventPreviewViewModel, type EventPreviewViewModelProps } from "./EventPreviewViewModel"; +import { getEventTileReplyChainState } from "./EventTileReplyChainState"; +import { getEventDisplayInfo } from "../../../../utils/EventRenderingUtils"; +import { haveRendererForEvent } from "../../../../events/EventTileFactory"; import { ThreadListActionBarViewModel, type ThreadListActionBarViewModelProps, @@ -51,6 +55,18 @@ import { ReactionsRowViewModel, type ReactionsRowViewModelProps } from "./reacti /** Event-level inputs for deriving the EventTile snapshot. */ export interface EventTileEventInput { + /** Whether the event is in a pending send state. */ + isSending: boolean; + /** Whether EventTile should announce updates in an aria-live region. */ + ariaLive?: "off"; + /** Whether the event is currently being edited. */ + isEditing: boolean; + /** Whether the tile is rendering for export. */ + forExport?: boolean; +} + +/** Event-level inputs after SDK data has been converted to pure values. */ +export interface EventTileDerivedEventInput extends EventTileEventInput { /** The event type rendered by the tile. */ eventType: string; /** The Matrix message type rendered by the tile. */ @@ -59,24 +75,26 @@ export interface EventTileEventInput { eventTs: number; /** The stable event identifier, when available. */ eventId?: string; + /** The event identifier replaced by this event, when available. */ + replacingEventId?: string; + /** Whether the event is a state event. */ + isState: boolean; /** Whether the event is a local echo. */ isLocalEcho: boolean; - /** Whether the event is in a pending send state. */ - isSending: boolean; - /** Whether EventTile should announce updates in an aria-live region. */ - ariaLive?: "off"; /** Whether the event is a room create event. */ isRoomCreate: boolean; /** Whether the event is a call invite. */ isCallInvite: boolean; /** Whether the event is an RTC notification. */ isRtcNotification: boolean; - /** Whether the event is currently being edited. */ - isEditing: boolean; /** Whether the event failed decryption. */ isEncryptionFailure: boolean; - /** Whether the tile is rendering for export. */ - forExport?: boolean; + /** Whether a renderer is available for the event. */ + hasRenderer: boolean; + /** Whether the event should be rendered through the moderation fallback. */ + isSeeingThroughMessageHiddenForModeration: boolean; + /** Whether EventTile should render the reply chain. */ + hasReplyChain: boolean; } /** Display inputs for deriving the EventTile snapshot. */ @@ -90,15 +108,15 @@ export interface EventTileDisplayInput { /** Whether the event body is likely to render media content. */ isProbablyMedia: boolean; /** Whether the tile should use bubble container styling. */ - isBubbleMessage: boolean; + isBubbleMessage?: boolean; /** Whether the bubble tile is left-aligned. */ - isLeftAlignedBubbleMessage: boolean; + isLeftAlignedBubbleMessage?: boolean; /** Whether the event is aligned between bubble columns. */ - isAlignedBetweenBubbles: boolean; + isAlignedBetweenBubbles?: boolean; /** Whether the event renders as an informational timeline item. */ - isInfoMessage: boolean; + isInfoMessage?: boolean; /** Whether bubble styling should be suppressed for this event. */ - noBubbleEvent: boolean; + noBubbleEvent?: boolean; /** Whether timestamps use twelve-hour formatting. */ isTwelveHour?: boolean; /** Whether the event should be highlighted. */ @@ -113,6 +131,15 @@ export interface EventTileDisplayInput { isContextual?: boolean; } +/** Event display inputs after renderer and event display information has been normalized. */ +export interface EventTileDerivedDisplayInput extends EventTileDisplayInput { + isBubbleMessage: boolean; + isLeftAlignedBubbleMessage: boolean; + isAlignedBetweenBubbles: boolean; + isInfoMessage: boolean; + noBubbleEvent: boolean; +} + /** Interaction inputs for deriving the EventTile snapshot. */ export interface EventTileInteractionInput { /** Whether the tile is currently hovered. */ @@ -138,7 +165,7 @@ export interface EventTileSenderInput { /** Whether sender details should be hidden. */ hideSender?: boolean; /** Whether the event body renders as an emote. */ - isEmote: boolean; + isEmote?: boolean; } /** Timestamp inputs for deriving the EventTile snapshot. */ @@ -179,12 +206,44 @@ export interface EventTileViewModelProps { footer: EventTileFooterInput; } +/** Pure EventTile inputs after event and sender data has been normalized. */ +export interface NormalizedEventTileViewModelProps { + event: EventTileDerivedEventInput; + display: EventTileDerivedDisplayInput; + interaction: EventTileInteractionInput; + sender: EventTileSenderInput; + timestamp: EventTileTimestampInput; + footer: EventTileFooterInput; +} + +/** Application dependencies used by EventTileViewModel to derive render data. */ +export interface EventTileViewModelDependencies { + /** The Matrix event being rendered. */ + mxEvent: MatrixEvent; + /** Matrix client used to select the event renderer. */ + matrixClient: MatrixClient; + /** Whether hidden events should use their fallback renderer. */ + showHiddenEvents: boolean; + /** Whether the event is hidden by the current tile context. */ + hideEvent?: boolean; +} + /** Event-level state derived for the EventTile snapshot. */ export interface EventTileEventSnapshot { /** The Matrix event type. */ eventType: string; /** The Matrix message type. */ msgtype?: string; + /** The stable event identifier, when available. */ + eventId?: string; + /** The event identifier replaced by this event, when available. */ + replacingEventId?: string; + /** Whether the event is a state event. */ + isState: boolean; + /** The event origin timestamp. */ + eventTs: number; + /** Whether the event is a local echo. */ + isLocalEcho: boolean; /** Whether the event is in a pending send state. */ isSending: boolean; /** Whether the event is currently being edited. */ @@ -193,6 +252,26 @@ export interface EventTileEventSnapshot { isContinuation?: boolean; /** Whether the tile is rendering as a notification. */ isRenderingNotification: boolean; + /** Whether the event failed decryption. */ + isEncryptionFailure: boolean; + /** Whether a renderer is available for the event. */ + hasRenderer: boolean; + /** Whether the event should be rendered through the moderation fallback. */ + isSeeingThroughMessageHiddenForModeration: boolean; +} + +/** Plain data attributes rendered on the EventTile root element. */ +export interface EventTileRootData { + /** The event identifier exposed through `data-event-id`. */ + eventId?: string; + /** The configured tile layout exposed through `data-layout`. */ + layout?: Layout; + /** The timeline rendering mode exposed through `data-shape`. */ + shape: TimelineRenderingType; + /** Whether the event belongs to the current user, exposed through `data-self`. */ + isOwnEvent: boolean; + /** Whether EventTile renders a reply chain, exposed through `data-has-reply`. */ + hasReply: boolean; } /** Root state derived for the EventTile snapshot. */ @@ -201,6 +280,8 @@ export interface EventTileRootSnapshot { ariaLive?: "off"; /** The stable scroll token for the event. */ scrollToken?: string; + /** Plain data attributes used by the EventTile root element. */ + data: EventTileRootData; /** EventTile root CSS class flags. */ classState: ReturnType; } @@ -288,6 +369,8 @@ export interface EventTileRenderState { scrollToken?: string; /** Whether the tile is rendering as a notification. */ isRenderingNotification: boolean; + /** Plain data attributes used by the EventTile root element. */ + data: EventTileRootData; }; /** EventTile line render state. */ line: { @@ -319,7 +402,11 @@ export interface EventTileRenderState { }; } -/** Derives the current EventTile snapshot from component-owned inputs. */ +/** + * Aggregate application-side render-state boundary for EventTile. + * + * SDK objects are converted to plain render data here before the existing render tree consumes it. + */ export class EventTileViewModel extends BaseViewModel { private messageTimestampViewModel?: MessageTimestampViewModel; private linkedMessageTimestampViewModel?: MessageTimestampViewModel; @@ -331,16 +418,18 @@ export class EventTileViewModel extends BaseViewModel { expect(getTile(container)).toContainElement(getLine(container)); }); + it("preserves the existing root and line markup", () => { + const { container } = getComponent(); + const tile = getTile(container); + + expect(tile.tagName).toBe("LI"); + expect(tile).toContainElement(getLine(container)); + expect(getLine(container)).toHaveClass("mx_EventTile_line"); + }); + it("does not expose a scroll token for local echo events", () => { const localEcho = makeOwnMessage(); localEcho.setStatus(EventStatus.SENDING); diff --git a/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts b/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts index 32a26d9938..823b66ef86 100644 --- a/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts +++ b/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts @@ -7,14 +7,36 @@ import { TimelineRenderingType } from "../../../src/contexts/RoomContext"; import { Layout } from "../../../src/settings/enums/Layout"; +import { mkEvent, stubClient } from "../../test-utils"; import { EventTileViewModel, + type EventTileViewModelDependencies, + type NormalizedEventTileViewModelProps, type EventTileViewModelProps, } from "../../../src/viewmodels/room/timeline/event-tile/EventTileViewModel"; describe("EventTileViewModel", () => { + const matrixClient = stubClient(); + + const makeEvent = () => + mkEvent({ + event: true, + id: "$event", + room: "!room:example.org", + ts: 123, + type: "m.room.message", + user: "@alice:example.org", + content: { msgtype: "m.text" }, + }); + + const makeDependencies = (mxEvent = makeEvent()): EventTileViewModelDependencies => ({ + mxEvent, + matrixClient, + showHiddenEvents: false, + }); + type EventTileViewModelPropsOverrides = { - event?: Partial; + event?: Partial; display?: Partial; interaction?: Partial; sender?: Partial; @@ -22,13 +44,15 @@ describe("EventTileViewModel", () => { footer?: Partial; }; - function makeProps(overrides: EventTileViewModelPropsOverrides = {}): EventTileViewModelProps { + function makeProps(overrides: EventTileViewModelPropsOverrides = {}): NormalizedEventTileViewModelProps { return { event: { eventType: "m.room.message", msgtype: "m.text", eventTs: 123, eventId: "$event", + isState: false, + hasReplyChain: false, isLocalEcho: false, isSending: false, ariaLive: "off", @@ -37,6 +61,8 @@ describe("EventTileViewModel", () => { isRtcNotification: false, isEditing: false, isEncryptionFailure: false, + hasRenderer: true, + isSeeingThroughMessageHiddenForModeration: false, forExport: false, ...overrides.event, }, @@ -91,8 +117,21 @@ describe("EventTileViewModel", () => { ); expect(snapshot.event.isSending).toBe(true); + expect(snapshot.event).toMatchObject({ + eventId: "$event", + eventTs: 123, + isLocalEcho: true, + isEncryptionFailure: false, + }); expect(snapshot.root.ariaLive).toBe("off"); expect(snapshot.root.scrollToken).toBeUndefined(); + expect(snapshot.root.data).toEqual({ + eventId: "$event", + layout: Layout.Group, + shape: TimelineRenderingType.Room, + isOwnEvent: false, + hasReply: false, + }); expect(snapshot.root.classState.mx_EventTile_sending).toBe(true); }); @@ -468,13 +507,13 @@ describe("EventTileViewModel", () => { }); it("updates an instance snapshot when inputs change", () => { - const vm = new EventTileViewModel(makeProps()); + const vm = new EventTileViewModel(makeDependencies(), makeProps()); const listener = jest.fn(); const unsubscribe = vm.subscribe(listener); expect(vm.getSnapshot().snapshot.timestamp.show).toBe(false); - vm.setProps(makeProps({ interaction: { hover: true } })); + vm.setInputs(makeDependencies(), makeProps({ interaction: { hover: true } })); expect(vm.getSnapshot().snapshot.timestamp.show).toBe(true); expect(listener).toHaveBeenCalled(); @@ -483,8 +522,162 @@ describe("EventTileViewModel", () => { vm.dispose(); }); + it("emits once when dependencies and inputs are updated together", () => { + const vm = new EventTileViewModel(makeDependencies(), makeProps()); + const listener = jest.fn(); + const unsubscribe = vm.subscribe(listener); + + vm.setInputs(makeDependencies(), makeProps({ interaction: { hover: true } })); + + expect(listener).toHaveBeenCalledTimes(1); + + unsubscribe(); + vm.dispose(); + }); + + it("normalizes event and sender state from its SDK dependency", () => { + const event = mkEvent({ + event: true, + id: "$member-event", + room: "!room:example.org", + ts: 456, + type: "m.room.member", + user: "@bob:example.org", + content: { membership: "join" }, + }); + const vm = new EventTileViewModel( + makeDependencies(event), + makeProps({ + event: { + eventType: "m.room.message", + eventId: "$wrong-event", + eventTs: 123, + }, + sender: { + senderId: "@wrong:example.org", + isEmote: false, + }, + }), + ); + + expect(vm.getSnapshot().snapshot.event).toMatchObject({ + eventType: "m.room.member", + eventId: "$member-event", + eventTs: 456, + isState: true, + }); + expect(vm.getSnapshot().snapshot.sender).toMatchObject({ + senderId: "@bob:example.org", + forceHistoricalAvatar: true, + isEmote: false, + }); + + vm.setInputs( + makeDependencies( + mkEvent({ + event: true, + id: "$updated-event", + room: "!room:example.org", + ts: 789, + type: "m.call.invite", + user: "@carol:example.org", + content: { msgtype: "m.call.invite" }, + }), + ), + makeProps(), + ); + + expect(vm.getSnapshot().snapshot.event).toMatchObject({ + eventType: "m.call.invite", + eventId: "$updated-event", + eventTs: 789, + }); + expect(vm.getSnapshot().snapshot.sender.senderId).toBe("@carol:example.org"); + + vm.dispose(); + }); + + it("normalizes event identity, replacement, renderer, and decryption state", () => { + const event = makeEvent(); + jest.spyOn(event, "replacingEventId").mockReturnValue("$replaced-event"); + jest.spyOn(event, "isDecryptionFailure").mockReturnValue(true); + + const vm = new EventTileViewModel(makeDependencies(event), makeProps()); + const snapshot = vm.getSnapshot().snapshot; + + expect(snapshot.event).toMatchObject({ + eventType: "m.room.message", + eventId: "$event", + replacingEventId: "$replaced-event", + isEncryptionFailure: true, + hasRenderer: true, + }); + + vm.dispose(); + }); + + it("does not show a reply chain for replacement events", () => { + const event = mkEvent({ + event: true, + id: "$replacement-event", + room: "!room:example.org", + ts: 123, + type: "m.room.message", + user: "@alice:example.org", + content: { + "msgtype": "m.text", + "m.relates_to": { + "rel_type": "m.replace", + "event_id": "$original-event", + "m.in_reply_to": { + event_id: "$parent-event", + }, + }, + }, + }); + const vm = new EventTileViewModel(makeDependencies(event), makeProps()); + + expect(vm.getSnapshot().snapshot.root.data.hasReply).toBe(false); + + vm.dispose(); + }); + + it("derives an unavailable renderer for unsupported events", () => { + const event = mkEvent({ + event: true, + room: "!room:example.org", + type: "org.example.unsupported", + user: "@alice:example.org", + content: {}, + }); + const vm = new EventTileViewModel(makeDependencies(event), makeProps()); + + expect(vm.getSnapshot().snapshot.event.hasRenderer).toBe(false); + + vm.dispose(); + }); + + it("recalculates renderer state when dependencies change", () => { + const event = mkEvent({ + event: true, + room: "!room:example.org", + type: "org.example.unsupported", + user: "@alice:example.org", + content: {}, + }); + const vm = new EventTileViewModel(makeDependencies(event), makeProps()); + + expect(vm.getSnapshot().snapshot.event.hasRenderer).toBe(false); + + vm.setInputs(makeDependencies(makeEvent()), makeProps()); + + expect(vm.getSnapshot().snapshot.event.hasRenderer).toBe(true); + + vm.dispose(); + }); + it("lazily owns timestamp child view models", () => { - const vm = new EventTileViewModel(makeProps()); + const vm = new EventTileViewModel(makeDependencies(), makeProps()); const messageTimestampViewModel = vm.getMessageTimestampViewModel({ ts: 123 }); const linkedMessageTimestampViewModel = vm.getLinkedMessageTimestampViewModel({ ts: 456 }); @@ -496,6 +689,7 @@ describe("EventTileViewModel", () => { it("does not initialize timestamp child view models for events without an origin timestamp", () => { const vm = new EventTileViewModel( + makeDependencies(), makeProps({ event: { eventTs: 0, @@ -512,7 +706,7 @@ describe("EventTileViewModel", () => { }); it("owns and updates the thread-list action bar child view model", () => { - const vm = new EventTileViewModel(makeProps()); + const vm = new EventTileViewModel(makeDependencies(), makeProps()); const onViewInRoomClick = jest.fn(); const onCopyLinkClick = jest.fn();