diff --git a/apps/web/src/components/views/elements/ImageView.tsx b/apps/web/src/components/views/elements/ImageView.tsx index ee24a87eb1..d5de5c2bba 100644 --- a/apps/web/src/components/views/elements/ImageView.tsx +++ b/apps/web/src/components/views/elements/ImageView.tsx @@ -647,15 +647,7 @@ export const DownloadButton: React.FC = ({ url, fileName, m function MessageTimestampWrapper(props: MessageTimestampViewModelProps): JSX.Element { const vm = useCreateAutoDisposedViewModel(() => new MessageTimestampViewModel(props)); useEffect(() => { - vm.setTimestamp(props.ts); - vm.setDisplayOptions({ - showTwelveHour: props.showTwelveHour, - showFullDate: props.showFullDate, - showSeconds: props.showSeconds, - }); - vm.setTooltipInhibited(props.inhibitTooltip); - vm.setHref(props.href); - vm.setHandlers({ onClick: props.onClick }); + vm.setProps(props); }, [vm, props]); return ; } diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index dd6e32ee0e..907612b0ce 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -94,7 +94,10 @@ import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon"; import SettingsStore from "../../../settings/SettingsStore"; import { CardContext } from "../right_panel/context"; import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; -import { EventTileViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; +import { + EventTileViewModel, + type EventTileViewModelProps, +} from "../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; import { E2eMessageSharedIconViewModel } from "../../../viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel"; import { getEventTileReceiptState, @@ -119,7 +122,7 @@ import { type EventTileInteractionState, } from "../../../viewmodels/room/timeline/event-tile/EventTileInteractionState"; import { - MessageTimestampViewModel, + type MessageTimestampViewModel, type MessageTimestampViewModelProps, } from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts"; import { @@ -138,7 +141,7 @@ import { } from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState"; import { TileErrorViewModel } from "../../../viewmodels/message-body/TileErrorViewModel"; import { EventTileActionBarViewModel } from "../../../viewmodels/room/EventTileActionBarViewModel"; -import { ThreadListActionBarViewModel } from "../../../viewmodels/room/ThreadListActionBarViewModel"; +import { type ThreadListActionBarViewModel } from "../../../viewmodels/room/ThreadListActionBarViewModel"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useSettingValue } from "../../../hooks/useSettings"; import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory"; @@ -300,48 +303,12 @@ interface IState { thread: Thread | null; } -interface E2eMessageSharedIconWrapperProps { - /** - * The ID of the room containing the event whose keys were shared. - */ - roomId: string; - /** - * The ID of the user who shared the keys. - */ - keyForwardingUserId: string; -} - -function E2eMessageSharedIconWrapper({ - roomId, - keyForwardingUserId, -}: Readonly): JSX.Element { - const client = useMatrixClientContext(); - const vm = useCreateAutoDisposedViewModel( - () => - new E2eMessageSharedIconViewModel({ - client, - roomId, - keyForwardingUserId, - }), - ); - - useEffect(() => { - vm.setRoomId(roomId); - }, [roomId, vm]); - - useEffect(() => { - vm.setKeyForwardingUserId(keyForwardingUserId); - }, [keyForwardingUserId, vm]); - - return ( - - ); +interface EventTileRenderInputs { + displayInfo: ReturnType; + hasPinnedMessageBadge: boolean; + hasReactionsRow: boolean; + threadState: EventTileThreadState; + isOwnEvent: boolean; } // MUST be rendered within a RoomContext with a set timelineRenderingType @@ -350,6 +317,7 @@ export class UnwrappedEventTile extends React.Component private isListeningForReceipts: boolean; private tile = createRef(); private replyChain = createRef(); + private readonly viewModel: EventTileViewModel; private readonly e2eViewModel: EventTileE2eViewModel; private e2eViewModelSubscription?: () => void; @@ -383,6 +351,8 @@ export class UnwrappedEventTile extends React.Component thread, }; + this.viewModel = new EventTileViewModel(this.createViewModelProps()); + this.e2eViewModel = new EventTileE2eViewModel({ cli: MatrixClientPeg.safeGet(), mxEvent: this.props.mxEvent, @@ -476,6 +446,7 @@ export class UnwrappedEventTile extends React.Component this.e2eViewModelSubscription?.(); this.e2eViewModelSubscription = undefined; this.e2eViewModel.dispose(); + this.viewModel.dispose(); if (this.props.resizeObserver && this.ref.current) this.props.resizeObserver.unobserve(this.ref.current); } @@ -897,73 +868,65 @@ export class UnwrappedEventTile extends React.Component return false; } - private renderContextMenu(): ReactNode { - if (!this.state.interaction.contextMenu) return null; - - const tile = this.getTile(); - const replyChain = this.getReplyChain(); - const eventTileOps = tile?.getEventTileOps ? tile.getEventTileOps() : undefined; - const collapseReplyChain = replyChain?.canCollapse() ? replyChain.collapse : undefined; - - return ( - - ); + private createMessageTimestampProps(ts: number): MessageTimestampViewModelProps { + return { + showRelative: this.context.timelineRenderingType === TimelineRenderingType.ThreadsList, + showTwelveHour: this.props.isTwelveHour, + ts, + receivedTs: getLateEventInfo(this.props.mxEvent)?.received_ts, + }; } - public render(): ReactNode { - const eventType = this.props.mxEvent.getType(); - const replacingEventId = this.props.mxEvent.replacingEventId(); + private createLinkedMessageTimestampProps( + messageTimestampProps: MessageTimestampViewModelProps, + ): MessageTimestampViewModelProps { + return { + ...messageTimestampProps, + href: this.getPermalink(), + onClick: this.onPermalinkClicked, + onContextMenu: this.onTimestampContextMenu, + }; + } - const { - hasRenderer, - isBubbleMessage, - isInfoMessage, - isLeftAlignedBubbleMessage, - noBubbleEvent, - isSeeingThroughMessageHiddenForModeration, - isAlignedBetweenBubbles, - } = getEventDisplayInfo( + private getPermalink(): string { + if (this.props.permalinkCreator) { + return this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()!); + } + + return "#"; + } + + private createRenderInputs( + displayInfo = getEventDisplayInfo( MatrixClientPeg.safeGet(), this.props.mxEvent, this.context.showHiddenEvents, this.shouldHideEvent(), - ); - const { isQuoteExpanded } = this.state; - // 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()}`); - return ( -
-
{_t("timeline|error_no_renderer")}
-
- ); - } - - const isProbablyMedia = MediaEventHelper.isEligible(this.props.mxEvent); - + ), + ): EventTileRenderInputs { const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted; - const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure(); - const isEditing = !!this.props.editState; const hasPinnedMessageBadge = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent); const hasReactionsRow = !isRedacted; const threadState = this.threadState; // Use `getSender()` because searched events might not have a proper `sender`. const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.safeGet().getUserId(); - const eventTileRenderState = EventTileViewModel.createRenderState({ + return { + displayInfo, + hasPinnedMessageBadge, + hasReactionsRow, + threadState, + isOwnEvent, + }; + } + + private createViewModelProps(inputs: EventTileRenderInputs = this.createRenderInputs()): EventTileViewModelProps { + const { displayInfo, hasPinnedMessageBadge, hasReactionsRow, threadState, isOwnEvent } = inputs; + const isProbablyMedia = MediaEventHelper.isEligible(this.props.mxEvent); + const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure(); + const isEditing = !!this.props.editState; + + return { event: { mxEvent: this.props.mxEvent, eventSendStatus: this.props.eventSendStatus, @@ -976,11 +939,11 @@ export class UnwrappedEventTile extends React.Component layout: this.props.layout, continuation: this.props.continuation, isProbablyMedia, - isBubbleMessage, - isLeftAlignedBubbleMessage, - isAlignedBetweenBubbles, - isInfoMessage, - noBubbleEvent, + isBubbleMessage: displayInfo.isBubbleMessage, + isLeftAlignedBubbleMessage: displayInfo.isLeftAlignedBubbleMessage, + isAlignedBetweenBubbles: displayInfo.isAlignedBetweenBubbles, + isInfoMessage: displayInfo.isInfoMessage, + noBubbleEvent: displayInfo.noBubbleEvent, isTwelveHour: this.props.isTwelveHour, isHighlighted: this.shouldHighlight(), isSelected: this.props.isSelectedEvent || !!this.state.interaction.contextMenu, @@ -1010,7 +973,61 @@ export class UnwrappedEventTile extends React.Component hasReactions: !!this.state.reactions, hasPinnedMessageBadge, }, - }); + }; + } + + private renderContextMenu(): ReactNode { + if (!this.state.interaction.contextMenu) return null; + + const tile = this.getTile(); + const replyChain = this.getReplyChain(); + const eventTileOps = tile?.getEventTileOps ? tile.getEventTileOps() : undefined; + const collapseReplyChain = replyChain?.canCollapse() ? replyChain.collapse : undefined; + + return ( + + ); + } + + 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, + this.shouldHideEvent(), + ); + const { hasRenderer, isSeeingThroughMessageHiddenForModeration } = displayInfo; + const { isQuoteExpanded } = this.state; + // 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()}`); + return ( +
+
{_t("timeline|error_no_renderer")}
+
+ ); + } + + const renderInputs = this.createRenderInputs(displayInfo); + const { hasPinnedMessageBadge, hasReactionsRow, threadState, isOwnEvent } = renderInputs; + + const eventTileRenderState = EventTileViewModel.createRenderState(this.createViewModelProps(renderInputs)); const eventTileSnapshot = eventTileRenderState.snapshot; const lineClasses = eventTileRenderState.line.className; @@ -1018,10 +1035,7 @@ export class UnwrappedEventTile extends React.Component const tileAriaLive = eventTileRenderState.root.ariaLive; const isRenderingNotification = eventTileRenderState.root.isRenderingNotification; - let permalink = "#"; - if (this.props.permalinkCreator) { - permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()!); - } + const permalink = this.getPermalink(); // we can't use local echoes as scroll tokens, because their event IDs change. // Local echos have a send "status". @@ -1070,32 +1084,29 @@ export class UnwrappedEventTile extends React.Component // Thread panel shows the timestamp of the last reply in that thread const ts = eventTileRenderState.timestamp.value; - const messageTimestampProps: MessageTimestampViewModelProps = { - showRelative: this.context.timelineRenderingType === TimelineRenderingType.ThreadsList, - showTwelveHour: this.props.isTwelveHour, - ts, - receivedTs: getLateEventInfo(this.props.mxEvent)?.received_ts, - }; - const messageTimestamp = ; - const linkedMessageTimestamp = ( - - ); + const messageTimestampProps = this.createMessageTimestampProps(ts); + const linkedMessageTimestampProps = this.createLinkedMessageTimestampProps(messageTimestampProps); // Used to simplify the UI layout where necessary by not conditionally rendering an element at the start const dummyTimestamp = eventTileRenderState.timestamp.showDummy ? ( ) : null; - const timestamp = eventTileRenderState.timestamp.displayState.showRealTimestamp - ? messageTimestamp - : dummyTimestamp; - const linkedTimestamp = eventTileRenderState.timestamp.displayState.showLinkedTimestamp - ? linkedMessageTimestamp - : dummyTimestamp; + const timestamp = eventTileRenderState.timestamp.displayState.showRealTimestamp ? ( + + ) : ( + dummyTimestamp + ); + const linkedTimestamp = eventTileRenderState.timestamp.displayState.showLinkedTimestamp ? ( + + ) : ( + dummyTimestamp + ); let pinnedMessageBadge: JSX.Element | undefined; if (hasPinnedMessageBadge) { @@ -1303,9 +1314,14 @@ export class UnwrappedEventTile extends React.Component {this.renderThreadPanelSummary(threadState)} {this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && ( - )} @@ -1535,27 +1551,19 @@ function SentReceipt({ messageState }: ISentReceiptProps): JSX.Element { ); } -/** - * Wraps MessageTimestampView with a view model synced to the provided props. - * This wrapper can be removed after EventTile has been changed to a function component. - */ -function MessageTimestampWrapper(props: MessageTimestampViewModelProps): JSX.Element { - const vm = useCreateAutoDisposedViewModel(() => new MessageTimestampViewModel(props)); +interface MessageTimestampAdapterProps { + vm: MessageTimestampViewModel; + timestampProps: MessageTimestampViewModelProps; +} + +function MessageTimestampAdapter({ vm, timestampProps }: Readonly): JSX.Element { useEffect(() => { - vm.setTimestamp(props.ts); - vm.setReceivedTimestamp(props.receivedTs); - vm.setDisplayOptions({ - showTwelveHour: props.showTwelveHour, - showRelative: props.showRelative, - }); - vm.setHref(props.href); - vm.setHandlers({ onClick: props.onClick, onContextMenu: props.onContextMenu }); - }, [vm, props]); + vm.setProps(timestampProps); + }, [vm, timestampProps]); return ( <> - {/* Render icon as described in, https://github.com/matrix-org/matrix-react-sdk/pull/11760 */} - {props.receivedTs ? ( + {timestampProps.receivedTs ? ( ) : undefined} @@ -1657,6 +1665,29 @@ function ThreadSummaryWrapper({ return ; } +interface ThreadListActionBarAdapterProps { + vm: ThreadListActionBarViewModel; + onViewInRoomClick: (anchor: HTMLElement | null) => void; + onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise; + className?: string; +} + +function ThreadListActionBarAdapter({ + vm, + onViewInRoomClick, + onCopyLinkClick, + className, +}: Readonly): JSX.Element { + useEffect(() => { + vm.setProps({ + onViewInRoomClick, + onCopyLinkClick, + }); + }, [vm, onViewInRoomClick, onCopyLinkClick]); + + return ; +} + interface ReactionsRowButtonItemProps { mxEvent: MatrixEvent; content: string; @@ -1903,33 +1934,6 @@ interface ActionBarWrapperProps { getRelationsForEvent?: GetRelationsForEvent; } -interface ThreadListActionBarWrapperProps { - onViewInRoomClick: (anchor: HTMLElement | null) => void; - onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise; -} - -function ThreadListActionBarWrapper({ - onViewInRoomClick, - onCopyLinkClick, -}: Readonly): JSX.Element { - const vm = useCreateAutoDisposedViewModel( - () => - new ThreadListActionBarViewModel({ - onViewInRoomClick, - onCopyLinkClick, - }), - ); - - useEffect(() => { - vm.setProps({ - onViewInRoomClick, - onCopyLinkClick, - }); - }, [vm, onViewInRoomClick, onCopyLinkClick]); - - return ; -} - function ActionBarWrapper({ mxEvent, reactions, @@ -2047,3 +2051,47 @@ function ActionBarWrapper({ ); } + +interface E2eMessageSharedIconWrapperProps { + /** + * The ID of the room containing the event whose keys were shared. + */ + roomId: string; + /** + * The ID of the user who shared the keys. + */ + keyForwardingUserId: string; +} + +function E2eMessageSharedIconWrapper({ + roomId, + keyForwardingUserId, +}: Readonly): JSX.Element { + const client = useMatrixClientContext(); + const vm = useCreateAutoDisposedViewModel( + () => + new E2eMessageSharedIconViewModel({ + client, + roomId, + keyForwardingUserId, + }), + ); + + useEffect(() => { + vm.setRoomId(roomId); + }, [roomId, vm]); + + useEffect(() => { + vm.setKeyForwardingUserId(keyForwardingUserId); + }, [keyForwardingUserId, vm]); + + return ( + + ); +} 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 e1f8b8428f..ac6800bee9 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 { 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, @@ -32,6 +33,11 @@ import { } from "./EventTileDerivedState"; import { TimelineRenderingType } from "../../../../contexts/RoomContext"; import { type Layout } from "../../../../settings/enums/Layout"; +import { MessageTimestampViewModel, type MessageTimestampViewModelProps } from "./timestamp/MessageTimestampViewModel"; +import { + ThreadListActionBarViewModel, + type ThreadListActionBarViewModelProps, +} from "../../ThreadListActionBarViewModel"; /** Event-level inputs for deriving the EventTile snapshot. */ export interface EventTileEventInput { @@ -276,7 +282,48 @@ export interface EventTileRenderState { } /** Derives the current EventTile snapshot from component-owned inputs. */ -export class EventTileViewModel { +export class EventTileViewModel extends BaseViewModel { + private messageTimestampViewModel?: MessageTimestampViewModel; + private linkedMessageTimestampViewModel?: MessageTimestampViewModel; + private threadListActionBarViewModel?: ThreadListActionBarViewModel; + + public constructor(props: EventTileViewModelProps) { + const initialRenderState = EventTileViewModel.createRenderState(props); + + super(props, initialRenderState); + } + + /** Updates root EventTile inputs and refreshes the derived render state. */ + public setProps(props: EventTileViewModelProps): void { + this.props = props; + this.snapshot.set(EventTileViewModel.createRenderState(props)); + } + + public override dispose(): void { + this.messageTimestampViewModel?.dispose(); + this.linkedMessageTimestampViewModel?.dispose(); + this.threadListActionBarViewModel?.dispose(); + super.dispose(); + } + + /** Lazily creates and returns the plain timestamp child view model. */ + public getMessageTimestampViewModel(props: MessageTimestampViewModelProps): MessageTimestampViewModel { + this.messageTimestampViewModel ??= new MessageTimestampViewModel(props); + return this.messageTimestampViewModel; + } + + /** Lazily creates and returns the permalink timestamp child view model. */ + public getLinkedMessageTimestampViewModel(props: MessageTimestampViewModelProps): MessageTimestampViewModel { + this.linkedMessageTimestampViewModel ??= new MessageTimestampViewModel(props); + return this.linkedMessageTimestampViewModel; + } + + /** Lazily creates and returns the thread-list action bar child view model. */ + public getThreadListActionBarViewModel(props: ThreadListActionBarViewModelProps): ThreadListActionBarViewModel { + this.threadListActionBarViewModel ??= new ThreadListActionBarViewModel(props); + return this.threadListActionBarViewModel; + } + /** Derives render-ready EventTile state from component-owned inputs. */ public static createRenderState(props: EventTileViewModelProps): EventTileRenderState { const snapshot = EventTileViewModel.createSnapshot(props); @@ -473,6 +520,4 @@ export class EventTileViewModel { noBubbleEvent: display.noBubbleEvent, }); } - - private constructor() {} } diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts index 394404ca4b..2d5a8e2486 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts @@ -100,8 +100,7 @@ export class MessageTimestampViewModel }; }; - private updateProps(newProps: Partial): void { - const nextProps = { ...this.props, ...newProps }; + private replaceProps(nextProps: MessageTimestampViewModelProps): void { if (!objectHasDiff(this.props, nextProps)) return; this.props = nextProps; @@ -120,52 +119,9 @@ export class MessageTimestampViewModel } /** - * Update the base timestamp (milliseconds since Unix epoch). + * Replace all timestamp props in one update. */ - public setTimestamp(ts: number): void { - this.updateProps({ ts }); - } - - /** - * Update the optional received timestamp (milliseconds since Unix epoch). - */ - public setReceivedTimestamp(receivedTs?: number): void { - this.updateProps({ receivedTs }); - } - - /** - * Update display formatting options for the rendered timestamp. - */ - public setDisplayOptions(options: { - showTwelveHour?: boolean; - showFullDate?: boolean; - showSeconds?: boolean; - showRelative?: boolean; - }): void { - this.updateProps(options); - } - - /** - * Enable or disable the tooltip rendering. - */ - public setTooltipInhibited(inhibitTooltip?: boolean): void { - this.updateProps({ inhibitTooltip }); - } - - /** - * Update the optional href for link rendering. - */ - public setHref(href?: string): void { - this.updateProps({ href }); - } - - /** - * Update click and context-menu handlers for the rendered element. - */ - public setHandlers(handlers: { - onClick?: MouseEventHandler; - onContextMenu?: MouseEventHandler; - }): void { - this.updateProps(handlers); + public setProps(props: MessageTimestampViewModelProps): void { + this.replaceProps(props); } } diff --git a/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts b/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts index 75a9be8f02..defc7b59ab 100644 --- a/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts +++ b/apps/web/test/viewmodels/event-tiles/EventTileViewModel-test.ts @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -import { EventStatus, EventType, type MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix"; +import { EventStatus, EventType, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix"; import { mkEvent } from "../../test-utils"; import { TimelineRenderingType } from "../../../src/contexts/RoomContext"; @@ -450,4 +450,74 @@ describe("EventTileViewModel", () => { showInDefaultLayout: false, }); }); + + it("updates an instance snapshot when inputs change", () => { + const vm = new EventTileViewModel(makeProps()); + const listener = jest.fn(); + const unsubscribe = vm.subscribe(listener); + + expect(vm.getSnapshot().snapshot.timestamp.show).toBe(false); + + vm.setProps(makeProps({ interaction: { hover: true } })); + + expect(vm.getSnapshot().snapshot.timestamp.show).toBe(true); + expect(listener).toHaveBeenCalled(); + + unsubscribe(); + vm.dispose(); + }); + + it("lazily owns timestamp child view models", () => { + const vm = new EventTileViewModel(makeProps()); + const messageTimestampViewModel = vm.getMessageTimestampViewModel({ ts: 123 }); + const linkedMessageTimestampViewModel = vm.getLinkedMessageTimestampViewModel({ ts: 456 }); + + expect(messageTimestampViewModel.getSnapshot().href).toBeUndefined(); + expect(linkedMessageTimestampViewModel.getSnapshot().href).toBeUndefined(); + + vm.dispose(); + }); + + 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, + }, + timestamp: { + hideTimestamp: true, + }, + }), + ); + + expect(vm.getSnapshot().timestamp.displayState.showRealTimestamp).toBe(false); + + vm.dispose(); + }); + + it("owns and updates the thread-list action bar child view model", () => { + const vm = new EventTileViewModel(makeProps()); + const onViewInRoomClick = jest.fn(); + const onCopyLinkClick = jest.fn(); + + const threadListActionBarViewModel = vm.getThreadListActionBarViewModel({ + onViewInRoomClick, + onCopyLinkClick, + }); + + threadListActionBarViewModel.onViewInRoomClick(null); + threadListActionBarViewModel.onCopyLinkClick(null); + + expect(onViewInRoomClick).toHaveBeenCalledWith(null); + expect(onCopyLinkClick).toHaveBeenCalledWith(null); + + vm.dispose(); + }); }); diff --git a/apps/web/test/viewmodels/message-body/MessageTimestampViewModel-test.tsx b/apps/web/test/viewmodels/message-body/MessageTimestampViewModel-test.tsx index 2ec046c446..8e58d9911c 100644 --- a/apps/web/test/viewmodels/message-body/MessageTimestampViewModel-test.tsx +++ b/apps/web/test/viewmodels/message-body/MessageTimestampViewModel-test.tsx @@ -120,49 +120,30 @@ describe("MessageTimestampViewModel", () => { }); }); - 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", () => { + it("updates all props in one batch", () => { const onClick = jest.fn(); const onContextMenu = jest.fn(); const vm = new MessageTimestampViewModel({ ts: nowDate.getTime(), }); + const listener = jest.fn(); + vm.subscribe(listener); - vm.setTooltipInhibited(true); - vm.setHref("https://example.test/event"); - vm.setHandlers({ onClick, onContextMenu }); + vm.setProps({ + ts: nowDate.getTime() + HOUR_MS, + receivedTs: nowDate.getTime() + DAY_MS, + showTwelveHour: true, + inhibitTooltip: true, + href: "https://example.test/event", + onClick, + onContextMenu, + }); + expect(listener).toHaveBeenCalledTimes(1); expect(vm.getSnapshot()).toMatchObject({ + ts: "9:09 AM", + tsSentAt: "Fri, Dec 17, 2021, 9:09:00 AM", + tsReceivedAt: "Sat, Dec 18, 2021, 8:09:00 AM", inhibitTooltip: true, href: "https://example.test/event", }); @@ -170,16 +151,43 @@ describe("MessageTimestampViewModel", () => { expect(vm.onContextMenu).toBe(onContextMenu); }); - it("does not emit an update when props are unchanged", () => { + it("replaces props and clears omitted optional values", () => { + const onClick = jest.fn(); + const onContextMenu = jest.fn(); const vm = new MessageTimestampViewModel({ ts: nowDate.getTime(), + receivedTs: nowDate.getTime() + DAY_MS, + inhibitTooltip: true, href: "https://example.test/event", + onClick, + onContextMenu, }); + + vm.setProps({ + ts: nowDate.getTime() + HOUR_MS, + }); + + expect(vm.getSnapshot()).toMatchObject({ + ts: "09:09", + tsSentAt: "Fri, Dec 17, 2021, 09:09:00", + }); + expect(vm.getSnapshot().tsReceivedAt).toBeUndefined(); + expect(vm.getSnapshot().inhibitTooltip).toBeUndefined(); + expect(vm.getSnapshot().href).toBeUndefined(); + expect(vm.onClick).toBeUndefined(); + expect(vm.onContextMenu).toBeUndefined(); + }); + + it("does not emit an update when batched props are unchanged", () => { + const props = { + ts: nowDate.getTime(), + href: "https://example.test/event", + }; + const vm = new MessageTimestampViewModel(props); const listener = jest.fn(); vm.subscribe(listener); - vm.setTimestamp(nowDate.getTime()); - vm.setHref("https://example.test/event"); + vm.setProps(props); expect(listener).not.toHaveBeenCalled(); });