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:
@@ -647,15 +647,7 @@ export const DownloadButton: React.FC<DownloadButtonProps> = ({ url, fileName, m
|
|||||||
function MessageTimestampWrapper(props: MessageTimestampViewModelProps): JSX.Element {
|
function MessageTimestampWrapper(props: MessageTimestampViewModelProps): JSX.Element {
|
||||||
const vm = useCreateAutoDisposedViewModel(() => new MessageTimestampViewModel(props));
|
const vm = useCreateAutoDisposedViewModel(() => new MessageTimestampViewModel(props));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
vm.setTimestamp(props.ts);
|
vm.setProps(props);
|
||||||
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, props]);
|
}, [vm, props]);
|
||||||
return <MessageTimestampView vm={vm} className="mx_MessageTimestamp" />;
|
return <MessageTimestampView vm={vm} className="mx_MessageTimestamp" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,10 @@ import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon";
|
|||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import { CardContext } from "../right_panel/context";
|
import { CardContext } from "../right_panel/context";
|
||||||
import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx";
|
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 { E2eMessageSharedIconViewModel } from "../../../viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel";
|
||||||
import {
|
import {
|
||||||
getEventTileReceiptState,
|
getEventTileReceiptState,
|
||||||
@@ -119,7 +122,7 @@ import {
|
|||||||
type EventTileInteractionState,
|
type EventTileInteractionState,
|
||||||
} from "../../../viewmodels/room/timeline/event-tile/EventTileInteractionState";
|
} from "../../../viewmodels/room/timeline/event-tile/EventTileInteractionState";
|
||||||
import {
|
import {
|
||||||
MessageTimestampViewModel,
|
type MessageTimestampViewModel,
|
||||||
type MessageTimestampViewModelProps,
|
type MessageTimestampViewModelProps,
|
||||||
} from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts";
|
} from "../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel.ts";
|
||||||
import {
|
import {
|
||||||
@@ -138,7 +141,7 @@ import {
|
|||||||
} from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState";
|
} from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState";
|
||||||
import { TileErrorViewModel } from "../../../viewmodels/message-body/TileErrorViewModel";
|
import { TileErrorViewModel } from "../../../viewmodels/message-body/TileErrorViewModel";
|
||||||
import { EventTileActionBarViewModel } from "../../../viewmodels/room/EventTileActionBarViewModel";
|
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 { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||||
import { useSettingValue } from "../../../hooks/useSettings";
|
import { useSettingValue } from "../../../hooks/useSettings";
|
||||||
import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory";
|
import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory";
|
||||||
@@ -300,48 +303,12 @@ interface IState {
|
|||||||
thread: Thread | null;
|
thread: Thread | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface E2eMessageSharedIconWrapperProps {
|
interface EventTileRenderInputs {
|
||||||
/**
|
displayInfo: ReturnType<typeof getEventDisplayInfo>;
|
||||||
* The ID of the room containing the event whose keys were shared.
|
hasPinnedMessageBadge: boolean;
|
||||||
*/
|
hasReactionsRow: boolean;
|
||||||
roomId: string;
|
threadState: EventTileThreadState;
|
||||||
/**
|
isOwnEvent: boolean;
|
||||||
* 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"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MUST be rendered within a RoomContext with a set timelineRenderingType
|
// 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 isListeningForReceipts: boolean;
|
||||||
private tile = createRef<IEventTileType>();
|
private tile = createRef<IEventTileType>();
|
||||||
private replyChain = createRef<ReplyChain>();
|
private replyChain = createRef<ReplyChain>();
|
||||||
|
private readonly viewModel: EventTileViewModel;
|
||||||
private readonly e2eViewModel: EventTileE2eViewModel;
|
private readonly e2eViewModel: EventTileE2eViewModel;
|
||||||
private e2eViewModelSubscription?: () => void;
|
private e2eViewModelSubscription?: () => void;
|
||||||
|
|
||||||
@@ -383,6 +351,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
thread,
|
thread,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.viewModel = new EventTileViewModel(this.createViewModelProps());
|
||||||
|
|
||||||
this.e2eViewModel = new EventTileE2eViewModel({
|
this.e2eViewModel = new EventTileE2eViewModel({
|
||||||
cli: MatrixClientPeg.safeGet(),
|
cli: MatrixClientPeg.safeGet(),
|
||||||
mxEvent: this.props.mxEvent,
|
mxEvent: this.props.mxEvent,
|
||||||
@@ -476,6 +446,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
this.e2eViewModelSubscription?.();
|
this.e2eViewModelSubscription?.();
|
||||||
this.e2eViewModelSubscription = undefined;
|
this.e2eViewModelSubscription = undefined;
|
||||||
this.e2eViewModel.dispose();
|
this.e2eViewModel.dispose();
|
||||||
|
this.viewModel.dispose();
|
||||||
if (this.props.resizeObserver && this.ref.current) this.props.resizeObserver.unobserve(this.ref.current);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderContextMenu(): ReactNode {
|
private createMessageTimestampProps(ts: number): MessageTimestampViewModelProps {
|
||||||
if (!this.state.interaction.contextMenu) return null;
|
return {
|
||||||
|
showRelative: this.context.timelineRenderingType === TimelineRenderingType.ThreadsList,
|
||||||
const tile = this.getTile();
|
showTwelveHour: this.props.isTwelveHour,
|
||||||
const replyChain = this.getReplyChain();
|
ts,
|
||||||
const eventTileOps = tile?.getEventTileOps ? tile.getEventTileOps() : undefined;
|
receivedTs: getLateEventInfo(this.props.mxEvent)?.received_ts,
|
||||||
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 {
|
private createLinkedMessageTimestampProps(
|
||||||
const eventType = this.props.mxEvent.getType();
|
messageTimestampProps: MessageTimestampViewModelProps,
|
||||||
const replacingEventId = this.props.mxEvent.replacingEventId();
|
): MessageTimestampViewModelProps {
|
||||||
|
return {
|
||||||
|
...messageTimestampProps,
|
||||||
|
href: this.getPermalink(),
|
||||||
|
onClick: this.onPermalinkClicked,
|
||||||
|
onContextMenu: this.onTimestampContextMenu,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
private getPermalink(): string {
|
||||||
hasRenderer,
|
if (this.props.permalinkCreator) {
|
||||||
isBubbleMessage,
|
return this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()!);
|
||||||
isInfoMessage,
|
}
|
||||||
isLeftAlignedBubbleMessage,
|
|
||||||
noBubbleEvent,
|
return "#";
|
||||||
isSeeingThroughMessageHiddenForModeration,
|
}
|
||||||
isAlignedBetweenBubbles,
|
|
||||||
} = getEventDisplayInfo(
|
private createRenderInputs(
|
||||||
|
displayInfo = getEventDisplayInfo(
|
||||||
MatrixClientPeg.safeGet(),
|
MatrixClientPeg.safeGet(),
|
||||||
this.props.mxEvent,
|
this.props.mxEvent,
|
||||||
this.context.showHiddenEvents,
|
this.context.showHiddenEvents,
|
||||||
this.shouldHideEvent(),
|
this.shouldHideEvent(),
|
||||||
);
|
),
|
||||||
const { isQuoteExpanded } = this.state;
|
): EventTileRenderInputs {
|
||||||
// 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);
|
|
||||||
|
|
||||||
const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted;
|
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 hasPinnedMessageBadge = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
|
||||||
const hasReactionsRow = !isRedacted;
|
const hasReactionsRow = !isRedacted;
|
||||||
const threadState = this.threadState;
|
const threadState = this.threadState;
|
||||||
// Use `getSender()` because searched events might not have a proper `sender`.
|
// Use `getSender()` because searched events might not have a proper `sender`.
|
||||||
const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.safeGet().getUserId();
|
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: {
|
event: {
|
||||||
mxEvent: this.props.mxEvent,
|
mxEvent: this.props.mxEvent,
|
||||||
eventSendStatus: this.props.eventSendStatus,
|
eventSendStatus: this.props.eventSendStatus,
|
||||||
@@ -976,11 +939,11 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
layout: this.props.layout,
|
layout: this.props.layout,
|
||||||
continuation: this.props.continuation,
|
continuation: this.props.continuation,
|
||||||
isProbablyMedia,
|
isProbablyMedia,
|
||||||
isBubbleMessage,
|
isBubbleMessage: displayInfo.isBubbleMessage,
|
||||||
isLeftAlignedBubbleMessage,
|
isLeftAlignedBubbleMessage: displayInfo.isLeftAlignedBubbleMessage,
|
||||||
isAlignedBetweenBubbles,
|
isAlignedBetweenBubbles: displayInfo.isAlignedBetweenBubbles,
|
||||||
isInfoMessage,
|
isInfoMessage: displayInfo.isInfoMessage,
|
||||||
noBubbleEvent,
|
noBubbleEvent: displayInfo.noBubbleEvent,
|
||||||
isTwelveHour: this.props.isTwelveHour,
|
isTwelveHour: this.props.isTwelveHour,
|
||||||
isHighlighted: this.shouldHighlight(),
|
isHighlighted: this.shouldHighlight(),
|
||||||
isSelected: this.props.isSelectedEvent || !!this.state.interaction.contextMenu,
|
isSelected: this.props.isSelectedEvent || !!this.state.interaction.contextMenu,
|
||||||
@@ -1010,7 +973,61 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
hasReactions: !!this.state.reactions,
|
hasReactions: !!this.state.reactions,
|
||||||
hasPinnedMessageBadge,
|
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 eventTileSnapshot = eventTileRenderState.snapshot;
|
||||||
|
|
||||||
const lineClasses = eventTileRenderState.line.className;
|
const lineClasses = eventTileRenderState.line.className;
|
||||||
@@ -1018,10 +1035,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
const tileAriaLive = eventTileRenderState.root.ariaLive;
|
const tileAriaLive = eventTileRenderState.root.ariaLive;
|
||||||
const isRenderingNotification = eventTileRenderState.root.isRenderingNotification;
|
const isRenderingNotification = eventTileRenderState.root.isRenderingNotification;
|
||||||
|
|
||||||
let permalink = "#";
|
const permalink = this.getPermalink();
|
||||||
if (this.props.permalinkCreator) {
|
|
||||||
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()!);
|
|
||||||
}
|
|
||||||
|
|
||||||
// we can't use local echoes as scroll tokens, because their event IDs change.
|
// we can't use local echoes as scroll tokens, because their event IDs change.
|
||||||
// Local echos have a send "status".
|
// 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
|
// Thread panel shows the timestamp of the last reply in that thread
|
||||||
const ts = eventTileRenderState.timestamp.value;
|
const ts = eventTileRenderState.timestamp.value;
|
||||||
|
|
||||||
const messageTimestampProps: MessageTimestampViewModelProps = {
|
const messageTimestampProps = this.createMessageTimestampProps(ts);
|
||||||
showRelative: this.context.timelineRenderingType === TimelineRenderingType.ThreadsList,
|
const linkedMessageTimestampProps = this.createLinkedMessageTimestampProps(messageTimestampProps);
|
||||||
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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Used to simplify the UI layout where necessary by not conditionally rendering an element at the start
|
// Used to simplify the UI layout where necessary by not conditionally rendering an element at the start
|
||||||
const dummyTimestamp = eventTileRenderState.timestamp.showDummy ? (
|
const dummyTimestamp = eventTileRenderState.timestamp.showDummy ? (
|
||||||
<span className="mx_MessageTimestamp" />
|
<span className="mx_MessageTimestamp" />
|
||||||
) : null;
|
) : null;
|
||||||
const timestamp = eventTileRenderState.timestamp.displayState.showRealTimestamp
|
const timestamp = eventTileRenderState.timestamp.displayState.showRealTimestamp ? (
|
||||||
? messageTimestamp
|
<MessageTimestampAdapter
|
||||||
: dummyTimestamp;
|
vm={this.viewModel.getMessageTimestampViewModel(messageTimestampProps)}
|
||||||
const linkedTimestamp = eventTileRenderState.timestamp.displayState.showLinkedTimestamp
|
timestampProps={messageTimestampProps}
|
||||||
? linkedMessageTimestamp
|
/>
|
||||||
: dummyTimestamp;
|
) : (
|
||||||
|
dummyTimestamp
|
||||||
|
);
|
||||||
|
const linkedTimestamp = eventTileRenderState.timestamp.displayState.showLinkedTimestamp ? (
|
||||||
|
<MessageTimestampAdapter
|
||||||
|
vm={this.viewModel.getLinkedMessageTimestampViewModel(linkedMessageTimestampProps)}
|
||||||
|
timestampProps={linkedMessageTimestampProps}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
dummyTimestamp
|
||||||
|
);
|
||||||
|
|
||||||
let pinnedMessageBadge: JSX.Element | undefined;
|
let pinnedMessageBadge: JSX.Element | undefined;
|
||||||
if (hasPinnedMessageBadge) {
|
if (hasPinnedMessageBadge) {
|
||||||
@@ -1303,9 +1314,14 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
{this.renderThreadPanelSummary(threadState)}
|
{this.renderThreadPanelSummary(threadState)}
|
||||||
</div>
|
</div>
|
||||||
{this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && (
|
{this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && (
|
||||||
<ThreadListActionBarWrapper
|
<ThreadListActionBarAdapter
|
||||||
|
vm={this.viewModel.getThreadListActionBarViewModel({
|
||||||
|
onViewInRoomClick: this.onViewInRoomClick,
|
||||||
|
onCopyLinkClick: this.onCopyLinkToThreadClick,
|
||||||
|
})}
|
||||||
onViewInRoomClick={this.onViewInRoomClick}
|
onViewInRoomClick={this.onViewInRoomClick}
|
||||||
onCopyLinkClick={this.onCopyLinkToThreadClick}
|
onCopyLinkClick={this.onCopyLinkToThreadClick}
|
||||||
|
className="mx_ThreadActionBar"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1535,27 +1551,19 @@ function SentReceipt({ messageState }: ISentReceiptProps): JSX.Element {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
interface MessageTimestampAdapterProps {
|
||||||
* Wraps MessageTimestampView with a view model synced to the provided props.
|
vm: MessageTimestampViewModel;
|
||||||
* This wrapper can be removed after EventTile has been changed to a function component.
|
timestampProps: MessageTimestampViewModelProps;
|
||||||
*/
|
}
|
||||||
function MessageTimestampWrapper(props: MessageTimestampViewModelProps): JSX.Element {
|
|
||||||
const vm = useCreateAutoDisposedViewModel(() => new MessageTimestampViewModel(props));
|
function MessageTimestampAdapter({ vm, timestampProps }: Readonly<MessageTimestampAdapterProps>): JSX.Element {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
vm.setTimestamp(props.ts);
|
vm.setProps(timestampProps);
|
||||||
vm.setReceivedTimestamp(props.receivedTs);
|
}, [vm, timestampProps]);
|
||||||
vm.setDisplayOptions({
|
|
||||||
showTwelveHour: props.showTwelveHour,
|
|
||||||
showRelative: props.showRelative,
|
|
||||||
});
|
|
||||||
vm.setHref(props.href);
|
|
||||||
vm.setHandlers({ onClick: props.onClick, onContextMenu: props.onContextMenu });
|
|
||||||
}, [vm, props]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Render icon as described in, https://github.com/matrix-org/matrix-react-sdk/pull/11760 */}
|
{timestampProps.receivedTs ? (
|
||||||
{props.receivedTs ? (
|
|
||||||
<LateIcon className="mx_MessageTimestamp_lateIcon" width="16" height="16" />
|
<LateIcon className="mx_MessageTimestamp_lateIcon" width="16" height="16" />
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<MessageTimestampView vm={vm} className="mx_MessageTimestamp" />
|
<MessageTimestampView vm={vm} className="mx_MessageTimestamp" />
|
||||||
@@ -1657,6 +1665,29 @@ function ThreadSummaryWrapper({
|
|||||||
return <ThreadSummaryView {...props} vm={vm} className={classNames("mx_ThreadSummary", className)} />;
|
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 {
|
interface ReactionsRowButtonItemProps {
|
||||||
mxEvent: MatrixEvent;
|
mxEvent: MatrixEvent;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -1903,33 +1934,6 @@ interface ActionBarWrapperProps {
|
|||||||
getRelationsForEvent?: GetRelationsForEvent;
|
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({
|
function ActionBarWrapper({
|
||||||
mxEvent,
|
mxEvent,
|
||||||
reactions,
|
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 { type EventStatus, type MatrixEvent, type RoomMember } from "matrix-js-sdk/src/matrix";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { BaseViewModel } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type EventTileSenderProfileState,
|
type EventTileSenderProfileState,
|
||||||
@@ -32,6 +33,11 @@ import {
|
|||||||
} from "./EventTileDerivedState";
|
} from "./EventTileDerivedState";
|
||||||
import { TimelineRenderingType } from "../../../../contexts/RoomContext";
|
import { TimelineRenderingType } from "../../../../contexts/RoomContext";
|
||||||
import { type Layout } from "../../../../settings/enums/Layout";
|
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. */
|
/** Event-level inputs for deriving the EventTile snapshot. */
|
||||||
export interface EventTileEventInput {
|
export interface EventTileEventInput {
|
||||||
@@ -276,7 +282,48 @@ export interface EventTileRenderState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Derives the current EventTile snapshot from component-owned inputs. */
|
/** 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. */
|
/** Derives render-ready EventTile state from component-owned inputs. */
|
||||||
public static createRenderState(props: EventTileViewModelProps): EventTileRenderState {
|
public static createRenderState(props: EventTileViewModelProps): EventTileRenderState {
|
||||||
const snapshot = EventTileViewModel.createSnapshot(props);
|
const snapshot = EventTileViewModel.createSnapshot(props);
|
||||||
@@ -473,6 +520,4 @@ export class EventTileViewModel {
|
|||||||
noBubbleEvent: display.noBubbleEvent,
|
noBubbleEvent: display.noBubbleEvent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private constructor() {}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-48
@@ -100,8 +100,7 @@ export class MessageTimestampViewModel
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
private updateProps(newProps: Partial<MessageTimestampViewModelProps>): void {
|
private replaceProps(nextProps: MessageTimestampViewModelProps): void {
|
||||||
const nextProps = { ...this.props, ...newProps };
|
|
||||||
if (!objectHasDiff(this.props, nextProps)) return;
|
if (!objectHasDiff(this.props, nextProps)) return;
|
||||||
|
|
||||||
this.props = nextProps;
|
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 {
|
public setProps(props: MessageTimestampViewModelProps): void {
|
||||||
this.updateProps({ ts });
|
this.replaceProps(props);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* 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 { mkEvent } from "../../test-utils";
|
||||||
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
|
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
|
||||||
@@ -450,4 +450,74 @@ describe("EventTileViewModel", () => {
|
|||||||
showInDefaultLayout: false,
|
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", () => {
|
it("updates all props in one batch", () => {
|
||||||
const vm = new MessageTimestampViewModel({
|
|
||||||
ts: nowDate.getTime(),
|
|
||||||
});
|
|
||||||
|
|
||||||
vm.setTimestamp(nowDate.getTime() + HOUR_MS);
|
|
||||||
vm.setReceivedTimestamp(nowDate.getTime() + DAY_MS);
|
|
||||||
|
|
||||||
expect(vm.getSnapshot()).toMatchObject({
|
|
||||||
ts: "09:09",
|
|
||||||
tsSentAt: "Fri, Dec 17, 2021, 09:09:00",
|
|
||||||
tsReceivedAt: "Sat, Dec 18, 2021, 08:09:00",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates display options", () => {
|
|
||||||
const vm = new MessageTimestampViewModel({
|
|
||||||
ts: nowDate.getTime(),
|
|
||||||
});
|
|
||||||
|
|
||||||
vm.setDisplayOptions({
|
|
||||||
showTwelveHour: true,
|
|
||||||
showSeconds: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(vm.getSnapshot()).toMatchObject({
|
|
||||||
ts: "8:09:00 AM",
|
|
||||||
tsSentAt: "Fri, Dec 17, 2021, 8:09:00 AM",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates tooltip, href, and handlers", () => {
|
|
||||||
const onClick = jest.fn();
|
const onClick = jest.fn();
|
||||||
const onContextMenu = jest.fn();
|
const onContextMenu = jest.fn();
|
||||||
const vm = new MessageTimestampViewModel({
|
const vm = new MessageTimestampViewModel({
|
||||||
ts: nowDate.getTime(),
|
ts: nowDate.getTime(),
|
||||||
});
|
});
|
||||||
|
const listener = jest.fn();
|
||||||
|
vm.subscribe(listener);
|
||||||
|
|
||||||
vm.setTooltipInhibited(true);
|
vm.setProps({
|
||||||
vm.setHref("https://example.test/event");
|
ts: nowDate.getTime() + HOUR_MS,
|
||||||
vm.setHandlers({ onClick, onContextMenu });
|
receivedTs: nowDate.getTime() + DAY_MS,
|
||||||
|
showTwelveHour: true,
|
||||||
|
inhibitTooltip: true,
|
||||||
|
href: "https://example.test/event",
|
||||||
|
onClick,
|
||||||
|
onContextMenu,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
expect(vm.getSnapshot()).toMatchObject({
|
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,
|
inhibitTooltip: true,
|
||||||
href: "https://example.test/event",
|
href: "https://example.test/event",
|
||||||
});
|
});
|
||||||
@@ -170,16 +151,43 @@ describe("MessageTimestampViewModel", () => {
|
|||||||
expect(vm.onContextMenu).toBe(onContextMenu);
|
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({
|
const vm = new MessageTimestampViewModel({
|
||||||
ts: nowDate.getTime(),
|
ts: nowDate.getTime(),
|
||||||
|
receivedTs: nowDate.getTime() + DAY_MS,
|
||||||
|
inhibitTooltip: true,
|
||||||
href: "https://example.test/event",
|
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();
|
const listener = jest.fn();
|
||||||
vm.subscribe(listener);
|
vm.subscribe(listener);
|
||||||
|
|
||||||
vm.setTimestamp(nowDate.getTime());
|
vm.setProps(props);
|
||||||
vm.setHref("https://example.test/event");
|
|
||||||
|
|
||||||
expect(listener).not.toHaveBeenCalled();
|
expect(listener).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user