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
@@ -171,7 +171,9 @@
"parameters_changed": "Some encryption parameters have been changed.",
"state_enabled": "Messages and state events in this room are end-to-end encrypted. When people join, you can verify them in their profile, just tap on their profile picture.",
"unsupported": "The encryption used by this room isn't supported."
}
},
"message_timestamp_received_at": "Received at: %(dateTime)s",
"message_timestamp_sent_at": "Sent at: %(dateTime)s"
},
"widget": {
"context_menu": {
+1
View File
@@ -17,6 +17,7 @@ export * from "./event-tiles/EncryptionEventView";
export * from "./event-tiles/EventTileBubble";
export * from "./event-tiles/TextualEventView";
export * from "./message-body/MediaBody";
export * from "./message-body/MessageTimestampView";
export * from "./message-body/DecryptionFailureBodyView";
export * from "./message-body/ReactionsRowButtonTooltip";
export * from "./message-body/TimelineSeparator/";
@@ -0,0 +1,16 @@
/*
* 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.
*/
.content {
color: var(--cpd-color-text-secondary) !important; /* override anchor color */
font-size: var(--cpd-font-size-body-xs);
font-variant-numeric: tabular-nums;
display: inline-block;
white-space: nowrap;
user-select: none;
text-decoration: none;
}
@@ -0,0 +1,80 @@
/*
* 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 React, { type ReactNode } from "react";
import { expect, userEvent, within } from "storybook/test";
import type { Meta, StoryFn } from "@storybook/react-vite";
import {
MessageTimestampView,
type MessageTimestampViewActions,
type MessageTimestampViewSnapshot,
} from "./MessageTimestampView";
import { useMockedViewModel } from "../../viewmodel/useMockedViewModel";
type MessageTimestampProps = MessageTimestampViewSnapshot & MessageTimestampViewActions;
const MessageTimestampWrapper = ({ onClick, onContextMenu, ...rest }: MessageTimestampProps): ReactNode => {
const vm = useMockedViewModel(rest, {
onClick,
onContextMenu,
});
return <MessageTimestampView vm={vm} />;
};
export default {
title: "MessageBody/MessageTimestamp",
component: MessageTimestampWrapper,
tags: ["autodocs"],
args: {
ts: "04:58",
tsSentAt: "Thu, 17 Nov 2022, 4:58:32 pm",
tsReceivedAt: "",
inhibitTooltip: false,
className: "",
href: "",
},
} as Meta<typeof MessageTimestampWrapper>;
const Template: StoryFn<typeof MessageTimestampWrapper> = (args) => <MessageTimestampWrapper {...args} />;
export const Default = Template.bind({});
Default.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.hover(canvas.getByText("04:58"));
await expect(within(canvasElement.ownerDocument.body).findByRole("tooltip")).resolves.toBeInTheDocument();
};
export const HasTsReceivedAt = Template.bind({});
HasTsReceivedAt.args = {
tsReceivedAt: "Thu, 17 Nov 2022, 4:58:33 pm",
};
HasTsReceivedAt.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.hover(canvas.getByText("04:58"));
await expect(within(canvasElement.ownerDocument.body).findByRole("tooltip")).resolves.toBeInTheDocument();
};
export const HasInhibitTooltip = Template.bind({});
HasInhibitTooltip.args = {
inhibitTooltip: true,
};
export const HasExtraClassNames = Template.bind({});
HasExtraClassNames.args = {
className: "extra_class_1 extra_class_2",
};
export const HasHref = Template.bind({});
HasHref.args = {
href: "~",
};
export const HasActions = Template.bind({});
HasActions.args = {
onClick: () => console.log("Clicked message timestamp"),
onContextMenu: () => console.log("Context menu on message timestamp"),
};
@@ -0,0 +1,231 @@
/*
* 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 { render, screen, fireEvent } from "@test-utils";
import { composeStories } from "@storybook/react-vite";
import React from "react";
import userEvent from "@testing-library/user-event";
import { describe, it, vi, afterEach, expect } from "vitest";
import * as stories from "./MessageTimestampView.stories.tsx";
import {
MessageTimestampView,
type MessageTimestampViewActions,
type MessageTimestampViewSnapshot,
} from "./MessageTimestampView";
import { MockViewModel } from "../../viewmodel/MockViewModel.ts";
import { I18nContext } from "../../utils/i18nContext.ts";
import { I18nApi } from "../../index.ts";
const { Default, HasHref, HasExtraClassNames } = composeStories(stories);
const renderWithI18n = (ui: React.ReactElement): ReturnType<typeof render> =>
render(ui, {
wrapper: ({ children }) => <I18nContext.Provider value={new I18nApi()}>{children}</I18nContext.Provider>,
});
describe("MessageTimestampView", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("renders the message timestamp in default state", async () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the message timestamp with extra class names", async () => {
const { container } = render(<HasExtraClassNames />);
expect(container).toMatchSnapshot();
});
it("renders the message timestamp with href", async () => {
const { container } = render(<HasHref />);
expect(container).toMatchSnapshot();
});
const onClick = vi.fn((event: React.MouseEvent<HTMLElement>) => event.preventDefault());
const onContextMenu = vi.fn((event: React.MouseEvent<HTMLElement>) => event.preventDefault());
class MessageTimestampViewModel
extends MockViewModel<MessageTimestampViewSnapshot>
implements MessageTimestampViewActions
{
public onClick = onClick;
public onContextMenu = onContextMenu;
}
it("should attach vm methods with href", async () => {
const vm = new MessageTimestampViewModel({
ts: "04:58",
tsSentAt: "Thu, 17 Nov 2022, 4:58:32 pm",
href: "~",
});
renderWithI18n(<MessageTimestampView vm={vm} />);
const target = screen.getByRole("link");
fireEvent.click(target);
expect(onClick).toHaveBeenCalled();
fireEvent.contextMenu(target);
expect(onContextMenu).toHaveBeenCalled();
});
it("should attach vm methods without href", async () => {
const user = userEvent.setup();
const vm = new MessageTimestampViewModel({
ts: "04:58",
tsSentAt: "Thu, 17 Nov 2022, 4:58:32 pm",
});
renderWithI18n(<MessageTimestampView vm={vm} />);
const target = screen.getByRole("link", { hidden: true });
await user.click(target);
expect(onClick).toHaveBeenCalled();
await user.pointer({ target, keys: "[MouseRight]" });
expect(onContextMenu).toHaveBeenCalled();
});
it("should show full date & time on hover", async () => {
const user = userEvent.setup();
const vm = new MessageTimestampViewModel({
ts: "08:09",
tsSentAt: "Fri, Dec 17, 2021, 08:09:00",
});
renderWithI18n(<MessageTimestampView vm={vm} />);
await user.hover(screen.getByRole("link"));
expect((await screen.findByRole("tooltip")).textContent).toMatchInlineSnapshot(`"Fri, Dec 17, 2021, 08:09:00"`);
});
it("should show sent & received time on hover if passed", async () => {
const user = userEvent.setup();
const vm = new MessageTimestampViewModel({
ts: "08:09",
tsSentAt: "Fri, Dec 17, 2021, 08:09:00",
tsReceivedAt: "Received at: Sat, Dec 18, 2021, 08:09:00",
});
renderWithI18n(<MessageTimestampView vm={vm} />);
await user.hover(screen.getByRole("link"));
expect((await screen.findByRole("tooltip")).textContent).toMatchInlineSnapshot(
`"Sent at: Fri, Dec 17, 2021, 08:09:00Received at: Received at: Sat, Dec 18, 2021, 08:09:00"`,
);
});
it("handles keyboard activation on span when click handler is set", async () => {
const vm = new MessageTimestampViewModel({
ts: "12:34",
tsSentAt: "Mon, Jan 1, 2024, 12:34:00",
});
renderWithI18n(<MessageTimestampView vm={vm} />);
const target = screen.getByRole("link");
fireEvent.keyDown(target, { key: "Enter" });
fireEvent.keyDown(target, { key: " " });
expect(onClick).toHaveBeenCalledTimes(2);
});
it("ignores other keys when click handler is set", async () => {
const vm = new MessageTimestampViewModel({
ts: "13:14",
tsSentAt: "Tue, Jun 6, 2023, 13:14:00",
});
renderWithI18n(<MessageTimestampView vm={vm} />);
const target = screen.getByRole("link");
fireEvent.keyDown(target, { key: "Escape" });
expect(onClick).not.toHaveBeenCalled();
});
it("ignores keyboard activation when no click handler is provided", async () => {
const vm = new MessageTimestampViewModelNoActions({
ts: "15:16",
tsSentAt: "Wed, Jul 7, 2021, 15:16:00",
});
renderWithI18n(<MessageTimestampView vm={vm} />);
const target = screen.getByText("15:16");
fireEvent.keyDown(target, { key: "Enter" });
expect(onClick).not.toHaveBeenCalled();
});
it("does not wrap tooltip labels when received timestamp is empty", async () => {
const user = userEvent.setup();
const vm = new MessageTimestampViewModel({
ts: "09:10",
tsSentAt: "Tue, Feb 2, 2021, 09:10:00",
tsReceivedAt: "",
});
renderWithI18n(<MessageTimestampView vm={vm} />);
await user.hover(screen.getByRole("link"));
expect((await screen.findByRole("tooltip")).textContent).toMatchInlineSnapshot(`"Tue, Feb 2, 2021, 09:10:00"`);
});
class MessageTimestampViewModelNoActions extends MockViewModel<MessageTimestampViewSnapshot> {}
it("renders without tooltip when inhibited and no click handler is provided", async () => {
const vm = new MessageTimestampViewModelNoActions({
ts: "07:08",
tsSentAt: "Wed, Mar 3, 2021, 07:08:00",
inhibitTooltip: true,
});
renderWithI18n(<MessageTimestampView vm={vm} />);
const target = screen.getByText("07:08");
expect(target).not.toHaveAttribute("role");
expect(target).not.toHaveAttribute("tabindex");
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("keeps link semantics when inhibited but click handler exists", async () => {
const vm = new MessageTimestampViewModel({
ts: "11:12",
tsSentAt: "Thu, Apr 4, 2024, 11:12:00",
inhibitTooltip: true,
});
renderWithI18n(<MessageTimestampView vm={vm} />);
const target = screen.getByRole("link");
expect(target).toHaveAttribute("tabindex", "0");
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("exposes focusable span when tooltip is enabled without click handler", async () => {
const user = userEvent.setup();
const vm = new MessageTimestampViewModelNoActions({
ts: "03:04",
tsSentAt: "Fri, May 5, 2023, 03:04:00",
});
renderWithI18n(<MessageTimestampView vm={vm} />);
const target = screen.getByText("03:04");
expect(target).toHaveAttribute("tabindex", "0");
expect(target).not.toHaveAttribute("role");
await user.hover(target);
expect((await screen.findByRole("tooltip")).textContent).toMatchInlineSnapshot(`"Fri, May 5, 2023, 03:04:00"`);
});
});
@@ -0,0 +1,140 @@
/*
* 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 React, { type JSX, type MouseEventHandler, type KeyboardEvent, type MouseEvent } from "react";
import classNames from "classnames";
import { Tooltip } from "@vector-im/compound-web";
import styles from "./MessageTimestampView.module.css";
import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../viewmodel/useViewModel";
import { useI18n } from "../../utils/i18nContext";
export interface MessageTimestampViewSnapshot {
/**
* The localized timestamp to render in the component
*/
ts: string;
/**
* The localized sent timestamp formatted as full date
*/
tsSentAt: string;
/**
* The localized received timestamp formatted as full date
* If specified will render both the sent-at and received-at timestamps in the tooltip
*/
tsReceivedAt?: string;
/**
* If set to true then no tooltip will be shown
*/
inhibitTooltip?: boolean;
/**
* Extra class name to apply to the component
*/
className?: string;
/**
* If specified, will be rendered as an anchor bearing the href, a `span` element will be used otherwise
*/
href?: string;
}
export interface MessageTimestampViewActions {
/**
* Optional onClick handler to attach to the DOM element
*/
onClick?: MouseEventHandler<HTMLElement>;
/**
* Optional onContextMenu handler to attach to the DOM element
*/
onContextMenu?: MouseEventHandler<HTMLElement>;
}
/**
* The view model for the message timestamp.
*/
export type MessageTimestampViewModel = ViewModel<MessageTimestampViewSnapshot> & MessageTimestampViewActions;
interface MessageTimestampViewProps {
/**
* The view model for the message timestamp.
*/
vm: MessageTimestampViewModel;
}
/**
* Displays a message timestamp with optional tooltip details.
*
* The view model provides the timestamp values and display options. The component
* can render as a link when `href` is set, and can show both sent-at and received-at
* times in the tooltip when `tsReceivedAt` is provided.
*
* @example
* ```tsx
* <MessageTimestampView vm={messageTimestampViewModel} />
* ```
*/
export function MessageTimestampView({ vm }: Readonly<MessageTimestampViewProps>): JSX.Element {
const { translate: _t } = useI18n();
const { ts, tsSentAt, tsReceivedAt, inhibitTooltip, className, href } = useViewModel(vm);
const onKeyDown = (event: KeyboardEvent<HTMLElement>): void => {
if (vm.onClick) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
vm.onClick?.(event as unknown as MouseEvent<HTMLElement>);
}
}
};
let label = tsSentAt;
let caption: string | undefined;
if (tsReceivedAt && tsReceivedAt?.length > 0) {
label = _t("timeline|message_timestamp_sent_at", { dateTime: label });
caption = _t("timeline|message_timestamp_received_at", {
dateTime: tsReceivedAt,
});
}
let content;
if (href) {
content = (
<a
href={href}
onClick={vm.onClick}
onKeyDown={onKeyDown}
onContextMenu={vm.onContextMenu}
className={classNames(className, styles.content)}
aria-live="off"
>
{ts}
</a>
);
} else {
content = (
<span
onClick={vm.onClick}
onKeyDown={onKeyDown}
onContextMenu={vm.onContextMenu}
className={classNames(className, styles.content)}
role={vm.onClick ? "link" : undefined}
aria-live="off"
tabIndex={vm.onClick || !inhibitTooltip ? 0 : undefined}
>
{ts}
</span>
);
}
if (inhibitTooltip) return content;
return (
<Tooltip description={label} caption={caption}>
{content}
</Tooltip>
);
}
@@ -0,0 +1,37 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`MessageTimestampView > renders the message timestamp in default state 1`] = `
<div>
<span
aria-live="off"
class="content"
tabindex="0"
>
04:58
</span>
</div>
`;
exports[`MessageTimestampView > renders the message timestamp with extra class names 1`] = `
<div>
<span
aria-live="off"
class="extra_class_1 extra_class_2 content"
tabindex="0"
>
04:58
</span>
</div>
`;
exports[`MessageTimestampView > renders the message timestamp with href 1`] = `
<div>
<a
aria-live="off"
class="content"
href="~"
>
04:58
</a>
</div>
`;
@@ -0,0 +1,14 @@
/*
* 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.
*/
export type {
MessageTimestampViewModel,
MessageTimestampViewSnapshot,
MessageTimestampViewActions,
} from "./MessageTimestampView";
export { MessageTimestampView } from "./MessageTimestampView";