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 {
|
||||
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" />;
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
+4
-48
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user