Refactor EventTile using the MVVM pattern - #7a (#33640)

* Make EventTileViewModel an owned root VM

* Move timestamp sub-VMs under EventTileViewModel

* Move thread-list action bar VM under EventTileViewModel

* Clean up for improved readability

* Clean up to avoid duplicate EventTile render derivations

* Avoid mutating EventTileViewModel during render

* Move EventTile child VM syncing into adapters

* Replace timestamp VM field setters with batched setProps

* Component wrappers at the end of the file

* Lazy-create EventTile child view models
This commit is contained in:
rbondesson
2026-05-29 07:56:31 +02:00
committed by GitHub
parent 0ed98941bd
commit 3aa3678c2d
6 changed files with 392 additions and 273 deletions
@@ -647,15 +647,7 @@ export const DownloadButton: React.FC<DownloadButtonProps> = ({ 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 <MessageTimestampView vm={vm} className="mx_MessageTimestamp" />;
}
+222 -174
View File
@@ -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<E2eMessageSharedIconWrapperProps>): 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 (
<E2eMessageSharedIconView
vm={vm}
className={
// Timeline PCSS uses this app class as a layout hook for positioning and layout variants.
"mx_EventTile_e2eIcon"
}
/>
);
interface EventTileRenderInputs {
displayInfo: ReturnType<typeof getEventDisplayInfo>;
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<EventTileProps, IState>
private isListeningForReceipts: boolean;
private tile = createRef<IEventTileType>();
private replyChain = createRef<ReplyChain>();
private readonly viewModel: EventTileViewModel;
private readonly e2eViewModel: EventTileE2eViewModel;
private e2eViewModelSubscription?: () => void;
@@ -383,6 +351,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
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<EventTileProps, IState>
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<EventTileProps, IState>
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 (
<MessageContextMenu
{...aboveRightOf(this.state.interaction.contextMenu.position)}
mxEvent={this.props.mxEvent}
permalinkCreator={this.props.permalinkCreator}
eventTileOps={eventTileOps}
collapseReplyChain={collapseReplyChain}
onFinished={this.onCloseMenu}
rightClick={true}
reactions={this.state.reactions}
link={this.state.interaction.contextMenu.link}
getRelationsForEvent={this.props.getRelationsForEvent}
/>
);
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 (
<div className="mx_EventTile mx_EventTile_info mx_MNoticeBody">
<div className="mx_EventTile_line">{_t("timeline|error_no_renderer")}</div>
</div>
);
}
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<EventTileProps, IState>
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<EventTileProps, IState>
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 (
<MessageContextMenu
{...aboveRightOf(this.state.interaction.contextMenu.position)}
mxEvent={this.props.mxEvent}
permalinkCreator={this.props.permalinkCreator}
eventTileOps={eventTileOps}
collapseReplyChain={collapseReplyChain}
onFinished={this.onCloseMenu}
rightClick={true}
reactions={this.state.reactions}
link={this.state.interaction.contextMenu.link}
getRelationsForEvent={this.props.getRelationsForEvent}
/>
);
}
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 (
<div className="mx_EventTile mx_EventTile_info mx_MNoticeBody">
<div className="mx_EventTile_line">{_t("timeline|error_no_renderer")}</div>
</div>
);
}
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<EventTileProps, IState>
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<EventTileProps, IState>
// 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 = <MessageTimestampWrapper {...messageTimestampProps} />;
const linkedMessageTimestamp = (
<MessageTimestampWrapper
{...messageTimestampProps}
href={permalink}
onClick={this.onPermalinkClicked}
onContextMenu={this.onTimestampContextMenu}
/>
);
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 ? (
<span className="mx_MessageTimestamp" />
) : null;
const timestamp = eventTileRenderState.timestamp.displayState.showRealTimestamp
? messageTimestamp
: dummyTimestamp;
const linkedTimestamp = eventTileRenderState.timestamp.displayState.showLinkedTimestamp
? linkedMessageTimestamp
: dummyTimestamp;
const timestamp = eventTileRenderState.timestamp.displayState.showRealTimestamp ? (
<MessageTimestampAdapter
vm={this.viewModel.getMessageTimestampViewModel(messageTimestampProps)}
timestampProps={messageTimestampProps}
/>
) : (
dummyTimestamp
);
const linkedTimestamp = eventTileRenderState.timestamp.displayState.showLinkedTimestamp ? (
<MessageTimestampAdapter
vm={this.viewModel.getLinkedMessageTimestampViewModel(linkedMessageTimestampProps)}
timestampProps={linkedMessageTimestampProps}
/>
) : (
dummyTimestamp
);
let pinnedMessageBadge: JSX.Element | undefined;
if (hasPinnedMessageBadge) {
@@ -1303,9 +1314,14 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{this.renderThreadPanelSummary(threadState)}
</div>
{this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && (
<ThreadListActionBarWrapper
<ThreadListActionBarAdapter
vm={this.viewModel.getThreadListActionBarViewModel({
onViewInRoomClick: this.onViewInRoomClick,
onCopyLinkClick: this.onCopyLinkToThreadClick,
})}
onViewInRoomClick={this.onViewInRoomClick}
onCopyLinkClick={this.onCopyLinkToThreadClick}
className="mx_ThreadActionBar"
/>
)}
@@ -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<MessageTimestampAdapterProps>): 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 ? (
<LateIcon className="mx_MessageTimestamp_lateIcon" width="16" height="16" />
) : undefined}
<MessageTimestampView vm={vm} className="mx_MessageTimestamp" />
@@ -1657,6 +1665,29 @@ function ThreadSummaryWrapper({
return <ThreadSummaryView {...props} vm={vm} className={classNames("mx_ThreadSummary", className)} />;
}
interface ThreadListActionBarAdapterProps {
vm: ThreadListActionBarViewModel;
onViewInRoomClick: (anchor: HTMLElement | null) => void;
onCopyLinkClick: (anchor: HTMLElement | null) => void | Promise<void>;
className?: string;
}
function ThreadListActionBarAdapter({
vm,
onViewInRoomClick,
onCopyLinkClick,
className,
}: Readonly<ThreadListActionBarAdapterProps>): JSX.Element {
useEffect(() => {
vm.setProps({
onViewInRoomClick,
onCopyLinkClick,
});
}, [vm, onViewInRoomClick, onCopyLinkClick]);
return <ActionBarView vm={vm} className={className} />;
}
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<void>;
}
function ThreadListActionBarWrapper({
onViewInRoomClick,
onCopyLinkClick,
}: Readonly<ThreadListActionBarWrapperProps>): JSX.Element {
const vm = useCreateAutoDisposedViewModel(
() =>
new ThreadListActionBarViewModel({
onViewInRoomClick,
onCopyLinkClick,
}),
);
useEffect(() => {
vm.setProps({
onViewInRoomClick,
onCopyLinkClick,
});
}, [vm, onViewInRoomClick, onCopyLinkClick]);
return <ActionBarView vm={vm} className="mx_ThreadActionBar" />;
}
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<E2eMessageSharedIconWrapperProps>): 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 (
<E2eMessageSharedIconView
vm={vm}
className={
// Timeline PCSS uses this app class as a layout hook for positioning and layout variants.
"mx_EventTile_e2eIcon"
}
/>
);
}
@@ -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<EventTileRenderState, EventTileViewModelProps> {
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() {}
}
@@ -100,8 +100,7 @@ export class MessageTimestampViewModel
};
};
private updateProps(newProps: Partial<MessageTimestampViewModelProps>): 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<HTMLElement>;
onContextMenu?: MouseEventHandler<HTMLElement>;
}): void {
this.updateProps(handlers);
public setProps(props: MessageTimestampViewModelProps): void {
this.replaceProps(props);
}
}
@@ -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();
});
});
@@ -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();
});