Refactor MessageTimestamp using MVVM and move to shared-components (#31988)

* Create a MessageTimestampView in shared components

* Switching to use shared component and view model in element-web

* Add .mx_MessageTimestamp tp _common.pcss since it is used extensively in element-web

* Added comments to view model

* Updating after Add options for consistent screenshots

* Moved rendering of late icon to EventTile

* Update shared component snaps

* Added I18nContext.Provider to Modal.tsx and HtmlExport.tsx to make them work with shared components

* Avoid circular dependencies for ModuleApi

* Adjust role and wire handlers in view model

* Change to role="link"

* Revert I18nContext.Provider changes

* Updated snapshot

* Provide I18nContext for shared-components used inside dialogs and html-export rendered in a separate root.

* Add patch for react-sdk-module-api to shared components

* Add setProps to MessageTimeViewModel and useEffect on wrappers

* Added more tests to improve coverage

* Changes after PR review

* Use specific setters in the viewmodel more relating to the business logic.

* Remove unused CSS properties

* New snapshot after merge

* Removed aria-hidden logic and display tooltips in stories

* Remove await for toolitp in HasInhibitTooltip story

* Add screenshots with visible tooltips

* Fixes after merge and review comments

* Updated snapshots for unit tests

* Removed one test since tooltips are not rendered to snapshots
This commit is contained in:
rbondesson
2026-02-20 09:29:26 +00:00
committed by GitHub
parent f90b329490
commit ca3bc30f90
33 changed files with 1072 additions and 265 deletions
+31 -24
View File
@@ -12,6 +12,7 @@ import { createRoot, type Root } from "react-dom/client";
import classNames from "classnames";
import { TypedEventEmitter } from "matrix-js-sdk/src/matrix";
import { Glass, TooltipProvider } from "@vector-im/compound-web";
import { I18nContext } from "@element-hq/web-shared-components";
import defaultDispatcher from "./dispatcher/dispatcher";
import AsyncWrapper from "./AsyncWrapper";
@@ -436,18 +437,21 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
const staticDialog = (
<StrictMode>
<TooltipProvider>
<div className={classes}>
<Glass className="mx_Dialog_border">
<div className="mx_Dialog">{this.staticModal.elem}</div>
</Glass>
<div
data-testid="dialog-background"
className="mx_Dialog_background mx_Dialog_staticBackground"
onClick={this.onBackgroundClick}
/>
</div>
</TooltipProvider>
{/* Provide I18nContext for shared-components used inside dialogs rendered in a separate root. */}
<I18nContext.Provider value={window.mxModuleApi.i18n}>
<TooltipProvider>
<div className={classes}>
<Glass className="mx_Dialog_border">
<div className="mx_Dialog">{this.staticModal.elem}</div>
</Glass>
<div
data-testid="dialog-background"
className="mx_Dialog_background mx_Dialog_staticBackground"
onClick={this.onBackgroundClick}
/>
</div>
</TooltipProvider>
</I18nContext.Provider>
</StrictMode>
);
@@ -465,18 +469,21 @@ export class ModalManager extends TypedEventEmitter<ModalManagerEvent, HandlerMa
const dialog = (
<StrictMode>
<TooltipProvider>
<div className={classes}>
<Glass className="mx_Dialog_border">
<div className="mx_Dialog">{modal.elem}</div>
</Glass>
<div
data-testid="dialog-background"
className="mx_Dialog_background"
onClick={this.onBackgroundClick}
/>
</div>
</TooltipProvider>
{/* Provide I18nContext for shared-components used inside dialogs rendered in a separate root. */}
<I18nContext.Provider value={window.mxModuleApi.i18n}>
<TooltipProvider>
<div className={classes}>
<Glass className="mx_Dialog_border">
<div className="mx_Dialog">{modal.elem}</div>
</Glass>
<div
data-testid="dialog-background"
className="mx_Dialog_background"
onClick={this.onBackgroundClick}
/>
</div>
</TooltipProvider>
</I18nContext.Provider>
</StrictMode>
);
+26 -2
View File
@@ -20,13 +20,13 @@ import {
ZoomInIcon,
ZoomOutIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useCreateAutoDisposedViewModel, MessageTimestampView } from "@element-hq/web-shared-components";
import { _t } from "../../../languageHandler";
import MemberAvatar from "../avatars/MemberAvatar";
import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton";
import MessageContextMenu from "../context_menus/MessageContextMenu";
import { aboveLeftOf } from "../../structures/ContextMenu";
import MessageTimestamp from "../messages/MessageTimestamp";
import SettingsStore from "../../../settings/SettingsStore";
import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
@@ -39,6 +39,10 @@ import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { presentableTextForFile } from "../../../utils/FileUtils";
import AccessibleButton from "./AccessibleButton";
import { useDownloadMedia } from "../../../hooks/useDownloadMedia.ts";
import {
MessageTimestampViewModel,
type MessageTimestampViewModelProps,
} from "../../../viewmodels/message-body/MessageTimestampViewModel.ts";
// Max scale to keep gaps around the image
const MAX_SCALE = 0.95;
@@ -465,7 +469,7 @@ export default class ImageView extends React.Component<IProps, IState> {
const senderName = mxEvent.sender?.name ?? mxEvent.getSender();
const sender = <div className="mx_ImageView_info_sender">{senderName}</div>;
const messageTimestamp = (
<MessageTimestamp
<MessageTimestampWrapper
href={permalink}
onClick={this.onPermalinkClicked}
showFullDate={true}
@@ -635,3 +639,23 @@ export const DownloadButton: React.FC<DownloadButtonProps> = ({ url, fileName, m
</AccessibleButton>
);
};
/**
* Wraps MessageTimestampView with a view model synced to the provided props.
* This wrapper can be removed after ImageView has been changed to a function component.
*/
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, props]);
return <MessageTimestampView vm={vm} />;
}
@@ -1,111 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type ReactNode } from "react";
import { Tooltip } from "@vector-im/compound-web";
import { formatFullDate, formatTime, formatFullTime, formatRelativeTime } from "../../../DateUtils";
import { _t } from "../../../languageHandler";
import { Icon as LateIcon } from "../../../../res/img/sensor.svg";
interface IProps {
ts: number;
/**
* If specified will render both the sent-at and received-at timestamps in the tooltip
*/
receivedTs?: number;
showTwelveHour?: boolean;
showFullDate?: boolean;
showSeconds?: boolean;
showRelative?: boolean;
/**
* If set to true then no tooltip will be shown
*/
inhibitTooltip?: boolean;
/**
* If specified, will be rendered as an anchor bearing the href, a `span` element will be used otherwise
*/
href?: string;
/**
* Optional onClick handler to attach to the DOM element
*/
onClick?: React.MouseEventHandler<HTMLElement>;
/**
* Optional onContextMenu handler to attach to the DOM element
*/
onContextMenu?: React.MouseEventHandler<HTMLElement>;
}
export default class MessageTimestamp extends React.Component<IProps> {
public render(): React.ReactNode {
const date = new Date(this.props.ts);
let timestamp: string;
if (this.props.showRelative) {
timestamp = formatRelativeTime(date, this.props.showTwelveHour);
} else if (this.props.showFullDate) {
timestamp = formatFullDate(date, this.props.showTwelveHour, this.props.showSeconds);
} else if (this.props.showSeconds) {
timestamp = formatFullTime(date, this.props.showTwelveHour);
} else {
timestamp = formatTime(date, this.props.showTwelveHour);
}
let label = formatFullDate(date, this.props.showTwelveHour);
let caption: string | undefined;
let icon: ReactNode | undefined;
if (this.props.receivedTs !== undefined) {
label = _t("timeline|message_timestamp_sent_at", { dateTime: label });
const receivedDate = new Date(this.props.receivedTs);
caption = _t("timeline|message_timestamp_received_at", {
dateTime: formatFullDate(receivedDate, this.props.showTwelveHour),
});
icon = <LateIcon className="mx_MessageTimestamp_lateIcon" width="16" height="16" />;
}
let content;
if (this.props.href) {
content = (
<a
href={this.props.href}
onClick={this.props.onClick}
onContextMenu={this.props.onContextMenu}
className="mx_MessageTimestamp"
aria-live="off"
>
{icon}
{timestamp}
</a>
);
} else {
content = (
<span
onClick={this.props.onClick}
onContextMenu={this.props.onContextMenu}
className="mx_MessageTimestamp"
aria-hidden={true}
aria-live="off"
tabIndex={this.props.onClick || !this.props.inhibitTooltip ? 0 : undefined}
>
{icon}
{timestamp}
</span>
);
}
if (this.props.inhibitTooltip) return content;
return (
<Tooltip description={label} caption={caption}>
{content}
</Tooltip>
);
}
}
+41 -5
View File
@@ -36,7 +36,11 @@ import {
import { Tooltip } from "@vector-im/compound-web";
import { uniqueId } from "lodash";
import { CircleIcon, CheckCircleIcon, ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useCreateAutoDisposedViewModel, DecryptionFailureBodyView } from "@element-hq/web-shared-components";
import {
useCreateAutoDisposedViewModel,
DecryptionFailureBodyView,
MessageTimestampView,
} from "@element-hq/web-shared-components";
import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext";
import ReplyChain from "../elements/ReplyChain";
@@ -58,7 +62,6 @@ import { Action } from "../../../dispatcher/actions";
import PlatformPeg from "../../../PlatformPeg";
import MemberAvatar from "../avatars/MemberAvatar";
import SenderProfile from "../messages/SenderProfile";
import MessageTimestamp from "../messages/MessageTimestamp";
import { type IReadReceiptPosition } from "./ReadReceiptMarker";
import MessageActionBar from "../messages/MessageActionBar";
import ReactionsRow from "../messages/ReactionsRow";
@@ -81,6 +84,7 @@ import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom";
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
import { EventTileThreadToolbar } from "./EventTile/EventTileThreadToolbar";
import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
import { Icon as LateIcon } from "../../../../res/img/sensor.svg";
import PinningUtils from "../../../utils/PinningUtils";
import { PinnedMessageBadge } from "../messages/PinnedMessageBadge";
import { EventPreview } from "./EventPreview";
@@ -88,6 +92,10 @@ import { ElementCallEventType } from "../../../call-types";
import { DecryptionFailureBodyViewModel } from "../../../viewmodels/message-body/DecryptionFailureBodyViewModel";
import { E2eMessageSharedIcon } from "./EventTile/E2eMessageSharedIcon.tsx";
import { E2ePadlock, E2ePadlockIcon } from "./EventTile/E2ePadlock.tsx";
import {
MessageTimestampViewModel,
type MessageTimestampViewModelProps,
} from "../../../viewmodels/message-body/MessageTimestampViewModel.ts";
export type GetRelationsForEvent = (
eventId: string,
@@ -1157,15 +1165,15 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
ts = this.props.mxEvent.getTs();
}
const messageTimestampProps = {
const messageTimestampProps: MessageTimestampViewModelProps = {
showRelative: this.context.timelineRenderingType === TimelineRenderingType.ThreadsList,
showTwelveHour: this.props.isTwelveHour,
ts,
receivedTs: getLateEventInfo(this.props.mxEvent)?.received_ts,
};
const messageTimestamp = <MessageTimestamp {...messageTimestampProps} />;
const messageTimestamp = <MessageTimestampWrapper {...messageTimestampProps} />;
const linkedMessageTimestamp = (
<MessageTimestamp
<MessageTimestampWrapper
{...messageTimestampProps}
href={permalink}
onClick={this.onPermalinkClicked}
@@ -1591,3 +1599,31 @@ function DecryptionFailureBodyWrapper({ mxEvent }: { mxEvent: MatrixEvent }): JS
return <DecryptionFailureBodyView vm={vm} />;
}
/**
* 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));
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]);
return (
<>
{/* Render icon as described in, https://github.com/matrix-org/matrix-react-sdk/pull/11760 */}
{props.receivedTs ? (
<LateIcon className="mx_MessageTimestamp_lateIcon" width="16" height="16" />
) : undefined}
<MessageTimestampView vm={vm} />
</>
);
}
-2
View File
@@ -3553,8 +3553,6 @@
"label": "Message Actions",
"view_in_room": "View in room"
},
"message_timestamp_received_at": "Received at: %(dateTime)s",
"message_timestamp_sent_at": "Sent at: %(dateTime)s",
"mjolnir": {
"changed_rule_glob": "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s",
"changed_rule_rooms": "%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching %(newGlob)s for %(reason)s",
+31 -27
View File
@@ -13,6 +13,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { logger } from "matrix-js-sdk/src/logger";
import escapeHtml from "escape-html";
import { TooltipProvider } from "@vector-im/compound-web";
import { I18nContext } from "@element-hq/web-shared-components";
import Exporter from "./Exporter";
import { mediaFromMxc } from "../../customisations/Media";
@@ -267,33 +268,36 @@ export default class HTMLExporter extends Exporter {
public getEventTile(mxEv: MatrixEvent, continuation: boolean, ref?: () => void): JSX.Element {
return (
<div className="mx_Export_EventWrapper" id={mxEv.getId()}>
<MatrixClientContext.Provider value={this.room.client}>
<SDKContext.Provider value={SdkContextClass.instance}>
<TooltipProvider>
<EventTile
mxEvent={mxEv}
continuation={continuation}
isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()}
forExport={true}
alwaysShowTimestamps={true}
showUrlPreview={false}
checkUnmounting={() => false}
isTwelveHour={false}
last={false}
lastInSection={false}
permalinkCreator={this.permalinkCreator}
lastSuccessful={false}
isSelectedEvent={false}
showReactions={true}
layout={Layout.Group}
showReadReceipts={false}
getRelationsForEvent={this.getRelationsForEvent}
ref={ref}
/>
</TooltipProvider>
</SDKContext.Provider>
</MatrixClientContext.Provider>
{/* Export rendering uses an isolated root, so provide I18nContext explicitly. */}
<I18nContext.Provider value={window.mxModuleApi.i18n}>
<MatrixClientContext.Provider value={this.room.client}>
<SDKContext.Provider value={SdkContextClass.instance}>
<TooltipProvider>
<EventTile
mxEvent={mxEv}
continuation={continuation}
isRedacted={mxEv.isRedacted()}
replacingEventId={mxEv.replacingEventId()}
forExport={true}
alwaysShowTimestamps={true}
showUrlPreview={false}
checkUnmounting={() => false}
isTwelveHour={false}
last={false}
lastInSection={false}
permalinkCreator={this.permalinkCreator}
lastSuccessful={false}
isSelectedEvent={false}
showReactions={true}
layout={Layout.Group}
showReadReceipts={false}
getRelationsForEvent={this.getRelationsForEvent}
ref={ref}
/>
</TooltipProvider>
</SDKContext.Provider>
</MatrixClientContext.Provider>
</I18nContext.Provider>
</div>
);
}
@@ -0,0 +1,173 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type MouseEventHandler } from "react";
import {
BaseViewModel,
type MessageTimestampViewSnapshot as MessageTimestampViewSnapshotInterface,
type MessageTimestampViewModel as MessageTimestampViewModelInterface,
} from "@element-hq/web-shared-components";
import { formatFullDate, formatTime, formatFullTime, formatRelativeTime } from "../../DateUtils";
import { objectHasDiff } from "../../utils/objects";
export interface MessageTimestampViewModelProps {
/**
* Message timestamp in milliseconds since the Unix epoch.
*/
ts: number;
/**
* If specified will render both the sent-at and received-at timestamps in the tooltip
*/
receivedTs?: number;
/**
* If set, use a 12-hour clock for formatted times.
*/
showTwelveHour?: boolean;
/**
* If set, include the full date in the displayed timestamp.
*/
showFullDate?: boolean;
/**
* If set, include seconds in the displayed timestamp.
*/
showSeconds?: boolean;
/**
* If set, display a relative timestamp (e.g. "5 minutes ago").
*/
showRelative?: boolean;
/**
* If set to true then no tooltip will be shown
*/
inhibitTooltip?: boolean;
/**
* If specified, will be rendered as an anchor bearing the href, a `span` element will be used otherwise
*/
href?: string;
/**
* Optional onClick handler to attach to the DOM element
*/
onClick?: MouseEventHandler<HTMLElement>;
/**
* Optional onContextMenu handler to attach to the DOM element
*/
onContextMenu?: MouseEventHandler<HTMLElement>;
}
/**
* ViewModel for the message timestamp, providing the current state of the component.
*/
export class MessageTimestampViewModel
extends BaseViewModel<MessageTimestampViewSnapshotInterface, MessageTimestampViewModelProps>
implements MessageTimestampViewModelInterface
{
public onClick?: MouseEventHandler<HTMLElement>;
public onContextMenu?: MouseEventHandler<HTMLElement>;
private static readonly computeSnapshot = (
props: MessageTimestampViewModelProps,
): MessageTimestampViewSnapshotInterface => {
const date = new Date(props.ts);
const sentAt = formatFullDate(date, props.showTwelveHour);
let timestamp: string;
if (props.showRelative) {
timestamp = formatRelativeTime(date, props.showTwelveHour);
} else if (props.showFullDate) {
timestamp = formatFullDate(date, props.showTwelveHour, props.showSeconds);
} else if (props.showSeconds) {
timestamp = formatFullTime(date, props.showTwelveHour);
} else {
timestamp = formatTime(date, props.showTwelveHour);
}
let receivedAt: string | undefined;
if (props.receivedTs !== undefined) {
const receivedDate = new Date(props.receivedTs);
receivedAt = formatFullDate(receivedDate, props.showTwelveHour);
}
// Keep mx_MessageTimestamp for compatibility with the existing timeline and layout.
return {
ts: timestamp,
tsSentAt: sentAt,
tsReceivedAt: receivedAt,
inhibitTooltip: props.inhibitTooltip,
href: props.href,
className: "mx_MessageTimestamp",
};
};
private updateProps(newProps: Partial<MessageTimestampViewModelProps>): void {
const nextProps = { ...this.props, ...newProps };
if (!objectHasDiff(this.props, nextProps)) return;
this.props = nextProps;
this.onClick = this.props.onClick;
this.onContextMenu = this.props.onContextMenu;
this.snapshot.set(MessageTimestampViewModel.computeSnapshot(this.props));
}
/**
* Create a timestamp view model with initial props and snapshot.
*/
public constructor(props: MessageTimestampViewModelProps) {
super(props, MessageTimestampViewModel.computeSnapshot(props));
this.onClick = props.onClick;
this.onContextMenu = props.onContextMenu;
}
/**
* Update the base timestamp (milliseconds since Unix epoch).
*/
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);
}
}