diff --git a/.stylelintrc.cjs b/.stylelintrc.cjs index 2cbebc0472..97b6e53f31 100644 --- a/.stylelintrc.cjs +++ b/.stylelintrc.cjs @@ -55,7 +55,6 @@ module.exports = { { from: "res/css/views/rooms/_ReadReceiptGroup.pcss", type: "css" }, { from: "res/css/views/rooms/_EditMessageComposer.pcss", type: "css" }, { from: "res/css/views/right_panel/_BaseCard.pcss", type: "css" }, - { from: "res/css/views/messages/_MessageTimestamp.pcss", type: "css" }, { from: "res/css/views/messages/_MessageActionBar.pcss", type: "css" }, { from: "res/css/views/voip/LegacyCallView/_LegacyCallViewButtons.pcss", type: "css" }, { from: "res/css/views/elements/_ToggleSwitch.pcss", type: "css" }, diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/MessageTimestampView/MessageTimestampView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/MessageTimestampView/MessageTimestampView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..0b144acf0d Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/MessageTimestampView/MessageTimestampView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/MessageTimestampView/MessageTimestampView.stories.tsx/has-actions-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/MessageTimestampView/MessageTimestampView.stories.tsx/has-actions-auto.png new file mode 100644 index 0000000000..652780c8aa Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/MessageTimestampView/MessageTimestampView.stories.tsx/has-actions-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/MessageTimestampView/MessageTimestampView.stories.tsx/has-extra-class-names-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/MessageTimestampView/MessageTimestampView.stories.tsx/has-extra-class-names-auto.png new file mode 100644 index 0000000000..7aad2b67b5 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/MessageTimestampView/MessageTimestampView.stories.tsx/has-extra-class-names-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/MessageTimestampView/MessageTimestampView.stories.tsx/has-href-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/MessageTimestampView/MessageTimestampView.stories.tsx/has-href-auto.png new file mode 100644 index 0000000000..7aad2b67b5 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/MessageTimestampView/MessageTimestampView.stories.tsx/has-href-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/MessageTimestampView/MessageTimestampView.stories.tsx/has-inhibit-tooltip-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/MessageTimestampView/MessageTimestampView.stories.tsx/has-inhibit-tooltip-auto.png new file mode 100644 index 0000000000..7aad2b67b5 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/MessageTimestampView/MessageTimestampView.stories.tsx/has-inhibit-tooltip-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/MessageTimestampView/MessageTimestampView.stories.tsx/has-ts-received-at-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/MessageTimestampView/MessageTimestampView.stories.tsx/has-ts-received-at-auto.png new file mode 100644 index 0000000000..97b49233ff Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/MessageTimestampView/MessageTimestampView.stories.tsx/has-ts-received-at-auto.png differ diff --git a/packages/shared-components/patches/@matrix-org+react-sdk-module-api+2.5.0.patch b/packages/shared-components/patches/@matrix-org+react-sdk-module-api+2.5.0.patch new file mode 100644 index 0000000000..33338c0960 --- /dev/null +++ b/packages/shared-components/patches/@matrix-org+react-sdk-module-api+2.5.0.patch @@ -0,0 +1,99 @@ +diff --git a/node_modules/@matrix-org/react-sdk-module-api/lib/ModuleApi.d.ts b/node_modules/@matrix-org/react-sdk-module-api/lib/ModuleApi.d.ts +index 917a7fc..a2710c6 100644 +--- a/node_modules/@matrix-org/react-sdk-module-api/lib/ModuleApi.d.ts ++++ b/node_modules/@matrix-org/react-sdk-module-api/lib/ModuleApi.d.ts +@@ -37,7 +37,7 @@ export interface ModuleApi { + * @returns Whether the user submitted the dialog or closed it, and the model returned by the + * dialog component if submitted. + */ +- openDialog = DialogContent

>(initialTitleOrOptions: string | ModuleUiDialogOptions, body: (props: P, ref: React.RefObject) => React.ReactNode, props?: Omit): Promise<{ ++ openDialog = DialogContent

>(initialTitleOrOptions: string | ModuleUiDialogOptions, body: (props: P, ref: React.RefObject) => React.ReactNode, props?: Omit): Promise<{ + didOkOrSubmit: boolean; + model: M; + }>; +diff --git a/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.d.ts b/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.d.ts +index cb5f2e5..51daa51 100644 +--- a/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.d.ts ++++ b/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.d.ts +@@ -66,23 +66,23 @@ export interface SetupEncryptionStoreProjection { + export interface ProvideCryptoSetupExtensions { + examineLoginResponse(response: any, credentials: ExtendedMatrixClientCreds): void; + persistCredentials(credentials: ExtendedMatrixClientCreds): void; +- getSecretStorageKey(): Uint8Array | null; +- createSecretStorageKey(): Uint8Array | null; ++ getSecretStorageKey(): Uint8Array | null; ++ createSecretStorageKey(): Uint8Array | null; + catchAccessSecretStorageError(e: Error): void; + setupEncryptionNeeded: (args: CryptoSetupArgs) => boolean; + /** @deprecated This callback is no longer used by matrix-react-sdk */ +- getDehydrationKeyCallback(): ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array) => void) => Promise) | null; ++ getDehydrationKeyCallback(): ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array) => void) => Promise>) | null; + SHOW_ENCRYPTION_SETUP_UI: boolean; + } + export declare abstract class CryptoSetupExtensionsBase implements ProvideCryptoSetupExtensions { + abstract examineLoginResponse(response: any, credentials: ExtendedMatrixClientCreds): void; + abstract persistCredentials(credentials: ExtendedMatrixClientCreds): void; +- abstract getSecretStorageKey(): Uint8Array | null; +- abstract createSecretStorageKey(): Uint8Array | null; ++ abstract getSecretStorageKey(): Uint8Array | null; ++ abstract createSecretStorageKey(): Uint8Array | null; + abstract catchAccessSecretStorageError(e: Error): void; + abstract setupEncryptionNeeded(args: CryptoSetupArgs): boolean; + /** `getDehydrationKeyCallback` is no longer used; we provide an empty impl for type compatibility. */ +- getDehydrationKeyCallback(): ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array) => void) => Promise) | null; ++ getDehydrationKeyCallback(): ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array) => void) => Promise>) | null; + abstract SHOW_ENCRYPTION_SETUP_UI: boolean; + } + export interface CryptoSetupArgs { +@@ -98,9 +98,9 @@ export declare class DefaultCryptoSetupExtensions extends CryptoSetupExtensionsB + SHOW_ENCRYPTION_SETUP_UI: boolean; + examineLoginResponse(response: any, credentials: ExtendedMatrixClientCreds): void; + persistCredentials(credentials: ExtendedMatrixClientCreds): void; +- getSecretStorageKey(): Uint8Array | null; +- createSecretStorageKey(): Uint8Array | null; ++ getSecretStorageKey(): Uint8Array | null; ++ createSecretStorageKey(): Uint8Array | null; + catchAccessSecretStorageError(e: Error): void; + setupEncryptionNeeded(args: CryptoSetupArgs): boolean; +- getDehydrationKeyCallback(): ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array) => void) => Promise) | null; ++ getDehydrationKeyCallback(): ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array) => void) => Promise>) | null; + } +diff --git a/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.js b/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.js +index 5d422ed..011c19f 100644 +--- a/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.js ++++ b/node_modules/@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions.js +@@ -124,34 +124,28 @@ var DefaultCryptoSetupExtensions = /*#__PURE__*/function (_CryptoSetupExtension) + (0, _createClass2["default"])(DefaultCryptoSetupExtensions, [{ + key: "examineLoginResponse", + value: function examineLoginResponse(response, credentials) { +- console.log("Default empty examineLoginResponse() => void"); + } + }, { + key: "persistCredentials", + value: function persistCredentials(credentials) { +- console.log("Default empty persistCredentials() => void"); + } + }, { + key: "getSecretStorageKey", + value: function getSecretStorageKey() { +- console.log("Default empty getSecretStorageKey() => null"); + return null; + } + }, { + key: "createSecretStorageKey", + value: function createSecretStorageKey() { +- console.log("Default empty createSecretStorageKey() => null"); + return null; + } + }, { + key: "catchAccessSecretStorageError", + value: function catchAccessSecretStorageError(e) { +- console.log("Default catchAccessSecretStorageError() => void"); + } + }, { + key: "setupEncryptionNeeded", + value: function setupEncryptionNeeded(args) { +- console.log("Default setupEncryptionNeeded() => false"); + return false; + } + }, { diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index cab1f9b45b..b9bd1610ac 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -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": { diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 64b92ade5b..f1ed7d0c08 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -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/"; diff --git a/packages/shared-components/src/message-body/MessageTimestampView/MessageTimestampView.module.css b/packages/shared-components/src/message-body/MessageTimestampView/MessageTimestampView.module.css new file mode 100644 index 0000000000..6ee9d62aa1 --- /dev/null +++ b/packages/shared-components/src/message-body/MessageTimestampView/MessageTimestampView.module.css @@ -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; +} diff --git a/packages/shared-components/src/message-body/MessageTimestampView/MessageTimestampView.stories.tsx b/packages/shared-components/src/message-body/MessageTimestampView/MessageTimestampView.stories.tsx new file mode 100644 index 0000000000..e512012a57 --- /dev/null +++ b/packages/shared-components/src/message-body/MessageTimestampView/MessageTimestampView.stories.tsx @@ -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 ; +}; + +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; + +const Template: StoryFn = (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"), +}; diff --git a/packages/shared-components/src/message-body/MessageTimestampView/MessageTimestampView.test.tsx b/packages/shared-components/src/message-body/MessageTimestampView/MessageTimestampView.test.tsx new file mode 100644 index 0000000000..f51b7ecc8b --- /dev/null +++ b/packages/shared-components/src/message-body/MessageTimestampView/MessageTimestampView.test.tsx @@ -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 => + render(ui, { + wrapper: ({ children }) => {children}, + }); + +describe("MessageTimestampView", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders the message timestamp in default state", async () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the message timestamp with extra class names", async () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the message timestamp with href", async () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + const onClick = vi.fn((event: React.MouseEvent) => event.preventDefault()); + const onContextMenu = vi.fn((event: React.MouseEvent) => event.preventDefault()); + + class MessageTimestampViewModel + extends MockViewModel + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await user.hover(screen.getByRole("link")); + expect((await screen.findByRole("tooltip")).textContent).toMatchInlineSnapshot(`"Tue, Feb 2, 2021, 09:10:00"`); + }); + + class MessageTimestampViewModelNoActions extends MockViewModel {} + + 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(); + + 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(); + + 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(); + + 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"`); + }); +}); diff --git a/packages/shared-components/src/message-body/MessageTimestampView/MessageTimestampView.tsx b/packages/shared-components/src/message-body/MessageTimestampView/MessageTimestampView.tsx new file mode 100644 index 0000000000..7ddb4a082c --- /dev/null +++ b/packages/shared-components/src/message-body/MessageTimestampView/MessageTimestampView.tsx @@ -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; + /** + * Optional onContextMenu handler to attach to the DOM element + */ + onContextMenu?: MouseEventHandler; +} + +/** + * The view model for the message timestamp. + */ +export type MessageTimestampViewModel = ViewModel & 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 + * + * ``` + */ +export function MessageTimestampView({ vm }: Readonly): JSX.Element { + const { translate: _t } = useI18n(); + + const { ts, tsSentAt, tsReceivedAt, inhibitTooltip, className, href } = useViewModel(vm); + + const onKeyDown = (event: KeyboardEvent): void => { + if (vm.onClick) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + vm.onClick?.(event as unknown as MouseEvent); + } + } + }; + + 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 = ( + + {ts} + + ); + } else { + content = ( + + {ts} + + ); + } + + if (inhibitTooltip) return content; + + return ( + + {content} + + ); +} diff --git a/packages/shared-components/src/message-body/MessageTimestampView/__snapshots__/MessageTimestampView.test.tsx.snap b/packages/shared-components/src/message-body/MessageTimestampView/__snapshots__/MessageTimestampView.test.tsx.snap new file mode 100644 index 0000000000..67ae66820e --- /dev/null +++ b/packages/shared-components/src/message-body/MessageTimestampView/__snapshots__/MessageTimestampView.test.tsx.snap @@ -0,0 +1,37 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`MessageTimestampView > renders the message timestamp in default state 1`] = ` +

+ + 04:58 + +
+`; + +exports[`MessageTimestampView > renders the message timestamp with extra class names 1`] = ` +
+ + 04:58 + +
+`; + +exports[`MessageTimestampView > renders the message timestamp with href 1`] = ` +
+ + 04:58 + +
+`; diff --git a/packages/shared-components/src/message-body/MessageTimestampView/index.tsx b/packages/shared-components/src/message-body/MessageTimestampView/index.tsx new file mode 100644 index 0000000000..7c2e6fa440 --- /dev/null +++ b/packages/shared-components/src/message-body/MessageTimestampView/index.tsx @@ -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"; diff --git a/res/css/_common.pcss b/res/css/_common.pcss index 736c535d65..3acbdffc01 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -23,6 +23,7 @@ Please see LICENSE files in the repository root for full details. --buttons-dialog-gap-column: $spacing-8; --MBody-border-radius: 8px; --EventTileBubble_margin-block: 10px; + --MessageTimestamp-width: 46px; /* 8 + 30 (avatar) + 8 */ /* Expected z-indexes for dialogs: 4000 - Default wrapper index @@ -908,3 +909,17 @@ legend { -webkit-line-clamp: var(--mx-line-clamp, 1); overflow: hidden; } + +/* This class is used extensively in element-web and are included here for compatibility with the existing timeline and layout. +/* TODO: Review mx_MessageTimestamp usage after finishing migration of timeline tiles to shared components. */ +/* https://github.com/element-hq/element-web/issues/31651 */ +.mx_MessageTimestamp { + color: var(--cpd-color-text-secondary) !important; /* override anchor color */ + font-size: $font-10px; + font-variant-numeric: tabular-nums; + display: block; /* enable the width setting below */ + width: var(--MessageTimestamp-width); + white-space: nowrap; + user-select: none; + text-decoration: none; +} diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 53f99f8a35..32757abf20 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -238,7 +238,6 @@ @import "./views/messages/_MVideoBody.pcss"; @import "./views/messages/_MediaBody.pcss"; @import "./views/messages/_MessageActionBar.pcss"; -@import "./views/messages/_MessageTimestamp.pcss"; @import "./views/messages/_MjolnirBody.pcss"; @import "./views/messages/_PinnedMessageBadge.pcss"; @import "./views/messages/_ReactionsRow.pcss"; diff --git a/res/css/views/messages/_MessageTimestamp.pcss b/res/css/views/messages/_MessageTimestamp.pcss deleted file mode 100644 index d5dda3272d..0000000000 --- a/res/css/views/messages/_MessageTimestamp.pcss +++ /dev/null @@ -1,32 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -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. -*/ - -:root { - --MessageTimestamp-width: 46px; /* 8 + 30 (avatar) + 8 */ - --MessageTimestamp-max-width: 80px; - --MessageTimestamp-color: $event-timestamp-color; -} - -.mx_MessageTimestamp { - color: var(--MessageTimestamp-color) !important; /* override anchor color */ - font-size: $font-10px; - font-variant-numeric: tabular-nums; - display: block; /* enable the width setting below */ - width: var(--MessageTimestamp-width); - white-space: nowrap; - user-select: none; - text-decoration: none; -} - -.mx_MessageTimestamp_lateIcon { - position: absolute; - right: 100%; - top: 50%; - transform: translateY(-50%); - color: inherit; -} diff --git a/res/css/views/rooms/_EventBubbleTile.pcss b/res/css/views/rooms/_EventBubbleTile.pcss index 24cd126728..d7c88982db 100644 --- a/res/css/views/rooms/_EventBubbleTile.pcss +++ b/res/css/views/rooms/_EventBubbleTile.pcss @@ -38,7 +38,7 @@ Please see LICENSE files in the repository root for full details. .mx_MessageTimestamp { width: unset; /* Cancel the default width */ - max-width: var(--MessageTimestamp-max-width); + max-width: 80px; } .mx_ThreadSummary { diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index cc532079b6..59bb2d23d0 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -66,6 +66,13 @@ $left-gutter: 64px; } } + .mx_MessageTimestamp_lateIcon { + position: absolute; + right: 100%; + top: var(--cpd-space-1x); + color: var(--cpd-color-text-secondary); + } + .mx_EventTileBubble { margin-block: var(--EventTileBubble_margin-block); min-width: 100px; diff --git a/src/Modal.tsx b/src/Modal.tsx index e2873783ea..26d04eb197 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -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 - -
- -
{this.staticModal.elem}
-
-
-
- + {/* Provide I18nContext for shared-components used inside dialogs rendered in a separate root. */} + + +
+ +
{this.staticModal.elem}
+
+
+
+ + ); @@ -465,18 +469,21 @@ export class ModalManager extends TypedEventEmitter - -
- -
{modal.elem}
-
-
-
- + {/* Provide I18nContext for shared-components used inside dialogs rendered in a separate root. */} + + +
+ +
{modal.elem}
+
+
+
+ + ); diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx index ac13f04bc3..983cd42575 100644 --- a/src/components/views/elements/ImageView.tsx +++ b/src/components/views/elements/ImageView.tsx @@ -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 { const senderName = mxEvent.sender?.name ?? mxEvent.getSender(); const sender =
{senderName}
; const messageTimestamp = ( - = ({ url, fileName, m ); }; + +/** + * 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 ; +} diff --git a/src/components/views/messages/MessageTimestamp.tsx b/src/components/views/messages/MessageTimestamp.tsx deleted file mode 100644 index c2f26f1ffc..0000000000 --- a/src/components/views/messages/MessageTimestamp.tsx +++ /dev/null @@ -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; - /** - * Optional onContextMenu handler to attach to the DOM element - */ - onContextMenu?: React.MouseEventHandler; -} - -export default class MessageTimestamp extends React.Component { - 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 = ; - } - - let content; - if (this.props.href) { - content = ( - - {icon} - {timestamp} - - ); - } else { - content = ( - - {icon} - {timestamp} - - ); - } - - if (this.props.inhibitTooltip) return content; - - return ( - - {content} - - ); - } -} diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 6d1ed70a06..47741d3abb 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -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 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 = ; + const messageTimestamp = ; const linkedMessageTimestamp = ( - ; } + +/** + * 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 ? ( + + ) : undefined} + + + ); +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index cb57da8eeb..f31931b7b0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", diff --git a/src/utils/exportUtils/HtmlExport.tsx b/src/utils/exportUtils/HtmlExport.tsx index 20b7b681ac..cb5c3629c8 100644 --- a/src/utils/exportUtils/HtmlExport.tsx +++ b/src/utils/exportUtils/HtmlExport.tsx @@ -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 (
- - - - 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} - /> - - - + {/* Export rendering uses an isolated root, so provide I18nContext explicitly. */} + + + + + 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} + /> + + + +
); } diff --git a/src/viewmodels/message-body/MessageTimestampViewModel.ts b/src/viewmodels/message-body/MessageTimestampViewModel.ts new file mode 100644 index 0000000000..8d9299ba88 --- /dev/null +++ b/src/viewmodels/message-body/MessageTimestampViewModel.ts @@ -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; + /** + * Optional onContextMenu handler to attach to the DOM element + */ + onContextMenu?: MouseEventHandler; +} + +/** + * ViewModel for the message timestamp, providing the current state of the component. + */ +export class MessageTimestampViewModel + extends BaseViewModel + implements MessageTimestampViewModelInterface +{ + public onClick?: MouseEventHandler; + public onContextMenu?: MouseEventHandler; + + 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): 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; + onContextMenu?: MouseEventHandler; + }): void { + this.updateProps(handlers); + } +} diff --git a/test/unit-tests/components/views/messages/MessageTimestamp-test.tsx b/test/unit-tests/components/views/messages/MessageTimestamp-test.tsx deleted file mode 100644 index ceaad8ea01..0000000000 --- a/test/unit-tests/components/views/messages/MessageTimestamp-test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2023 The Matrix.org Foundation C.I.C. - -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 from "react"; -import { render, screen } from "jest-matrix-react"; -import userEvent from "@testing-library/user-event"; - -import MessageTimestamp from "../../../../../src/components/views/messages/MessageTimestamp"; - -jest.mock("../../../../../src/settings/SettingsStore"); - -describe("MessageTimestamp", () => { - // Friday Dec 17 2021, 9:09am - const nowDate = new Date("2021-12-17T08:09:00.000Z"); - - const HOUR_MS = 3600000; - const DAY_MS = HOUR_MS * 24; - - it("should render HH:MM", () => { - const { asFragment } = render(); - expect(asFragment()).toMatchInlineSnapshot(` - - - -`); - }); - - it("should show full date & time on hover", async () => { - const { container } = render(); - await userEvent.hover(container.querySelector(".mx_MessageTimestamp")!); - 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 { container } = render( - , - ); - await userEvent.hover(container.querySelector(".mx_MessageTimestamp")!); - expect((await screen.findByRole("tooltip")).textContent).toMatchInlineSnapshot( - `"Sent at: Fri, Dec 17, 2021, 08:09:00Received at: Sat, Dec 18, 2021, 08:09:00"`, - ); - }); -}); diff --git a/test/unit-tests/components/views/messages/__snapshots__/MImageBody-test.tsx.snap b/test/unit-tests/components/views/messages/__snapshots__/MImageBody-test.tsx.snap index 5b2befbd61..5a36c73c41 100644 --- a/test/unit-tests/components/views/messages/__snapshots__/MImageBody-test.tsx.snap +++ b/test/unit-tests/components/views/messages/__snapshots__/MImageBody-test.tsx.snap @@ -93,7 +93,7 @@ exports[` should open ImageView using thumbnail for encrypted svg 1
Thu, Jan 15, 1970, 06:56 diff --git a/test/unit-tests/utils/exportUtils/HTMLExport-test.ts b/test/unit-tests/utils/exportUtils/HTMLExport-test.ts index 102b4b4901..2bb92f37c8 100644 --- a/test/unit-tests/utils/exportUtils/HTMLExport-test.ts +++ b/test/unit-tests/utils/exportUtils/HTMLExport-test.ts @@ -246,7 +246,7 @@ describe("HTMLExport", () => { const file = getMessageFile(exporter); expect(await file.text()).toMatchSnapshot(); - }); + }, 10000); it("should include the room's avatar", async () => { mockMessages(EVENT_MESSAGE); diff --git a/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap b/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap index 115385f2e4..9f2a76734a 100644 --- a/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap +++ b/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap @@ -57,7 +57,7 @@ exports[`HTMLExport should export 1`] = `

-
  • @user48:example.com
    00:00
    Message #48
  • @user47:example.com
    00:00
    Message #47
  • @user46:example.com
    00:00
    Message #46
  • @user45:example.com
    00:00
    Message #45
  • @user44:example.com
    00:00
    Message #44
  • @user43:example.com
    00:00
    Message #43
  • @user42:example.com
    00:00
    Message #42
  • @user41:example.com
    00:00
    Message #41
  • @user40:example.com
    00:00
    Message #40
  • @user39:example.com
    00:00
    Message #39
  • @user38:example.com
    00:00
    Message #38
  • @user37:example.com
    00:00
    Message #37
  • @user36:example.com
    00:00
    Message #36
  • @user35:example.com
    00:00
    Message #35
  • @user34:example.com
    00:00
    Message #34
  • @user33:example.com
    00:00
    Message #33
  • @user32:example.com
    00:00
    Message #32
  • @user31:example.com
    00:00
    Message #31
  • @user30:example.com
    00:00
    Message #30
  • @user29:example.com
    00:00
    Message #29
  • @user28:example.com
    00:00
    Message #28
  • @user27:example.com
    00:00
    Message #27
  • @user26:example.com
    00:00
    Message #26
  • @user25:example.com
    00:00
    Message #25
  • @user24:example.com
    00:00
    Message #24
  • @user23:example.com
    00:00
    Message #23
  • @user22:example.com
    00:00
    Message #22
  • @user21:example.com
    00:00
    Message #21
  • @user20:example.com
    00:00
    Message #20
  • @user19:example.com
    00:00
    Message #19
  • @user18:example.com
    00:00
    Message #18
  • @user17:example.com
    00:00
    Message #17
  • @user16:example.com
    00:00
    Message #16
  • @user15:example.com
    00:00
    Message #15
  • @user14:example.com
    00:00
    Message #14
  • @user13:example.com
    00:00
    Message #13
  • @user12:example.com
    00:00
    Message #12
  • @user11:example.com
    00:00
    Message #11
  • @user10:example.com
    00:00
    Message #10
  • @user9:example.com
    00:00
    Message #9
  • @user8:example.com
    00:00
    Message #8
  • @user7:example.com
    00:00
    Message #7
  • @user6:example.com
    00:00
    Message #6
  • @user5:example.com
    00:00
    Message #5
  • @user4:example.com
    00:00
    Message #4
  • @user3:example.com
    00:00
    Message #3
  • @user2:example.com
    00:00
    Message #2
  • @user1:example.com
    00:00
    Message #1
  • @user0:example.com
    00:00
    Message #0
  • +
  • @user49:example.com
    00:00
    Message #49
  • @user48:example.com
    00:00
    Message #48
  • @user47:example.com
    00:00
    Message #47
  • @user46:example.com
    00:00
    Message #46
  • @user45:example.com
    00:00
    Message #45
  • @user44:example.com
    00:00
    Message #44
  • @user43:example.com
    00:00
    Message #43
  • @user42:example.com
    00:00
    Message #42
  • @user41:example.com
    00:00
    Message #41
  • @user40:example.com
    00:00
    Message #40
  • @user39:example.com
    00:00
    Message #39
  • @user38:example.com
    00:00
    Message #38
  • @user37:example.com
    00:00
    Message #37
  • @user36:example.com
    00:00
    Message #36
  • @user35:example.com
    00:00
    Message #35
  • @user34:example.com
    00:00
    Message #34
  • @user33:example.com
    00:00
    Message #33
  • @user32:example.com
    00:00
    Message #32
  • @user31:example.com
    00:00
    Message #31
  • @user30:example.com
    00:00
    Message #30
  • @user29:example.com
    00:00
    Message #29
  • @user28:example.com
    00:00
    Message #28
  • @user27:example.com
    00:00
    Message #27
  • @user26:example.com
    00:00
    Message #26
  • @user25:example.com
    00:00
    Message #25
  • @user24:example.com
    00:00
    Message #24
  • @user23:example.com
    00:00
    Message #23
  • @user22:example.com
    00:00
    Message #22
  • @user21:example.com
    00:00
    Message #21
  • @user20:example.com
    00:00
    Message #20
  • @user19:example.com
    00:00
    Message #19
  • @user18:example.com
    00:00
    Message #18
  • @user17:example.com
    00:00
    Message #17
  • @user16:example.com
    00:00
    Message #16
  • @user15:example.com
    00:00
    Message #15
  • @user14:example.com
    00:00
    Message #14
  • @user13:example.com
    00:00
    Message #13
  • @user12:example.com
    00:00
    Message #12
  • @user11:example.com
    00:00
    Message #11
  • @user10:example.com
    00:00
    Message #10
  • @user9:example.com
    00:00
    Message #9
  • @user8:example.com
    00:00
    Message #8
  • @user7:example.com
    00:00
    Message #7
  • @user6:example.com
    00:00
    Message #6
  • @user5:example.com
    00:00
    Message #5
  • @user4:example.com
    00:00
    Message #4
  • @user3:example.com
    00:00
    Message #3
  • @user2:example.com
    00:00
    Message #2
  • @user1:example.com
    00:00
    Message #1
  • @user0:example.com
    00:00
    Message #0
  • diff --git a/test/viewmodels/message-body/MessageTimestampViewModel-test.tsx b/test/viewmodels/message-body/MessageTimestampViewModel-test.tsx new file mode 100644 index 0000000000..864d4ce622 --- /dev/null +++ b/test/viewmodels/message-body/MessageTimestampViewModel-test.tsx @@ -0,0 +1,123 @@ +/* + * 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 * as DateUtils from "../../../src/DateUtils"; +import { MessageTimestampViewModel } from "../../../src/viewmodels/message-body/MessageTimestampViewModel"; + +jest.mock("../../../src/settings/SettingsStore"); + +describe("MessageTimestampViewModel", () => { + // Friday Dec 17 2021, 9:09am + const nowDate = new Date("2021-12-17T08:09:00.000Z"); + const HOUR_MS = 3600000; + const DAY_MS = HOUR_MS * 24; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("should return the snapshot", () => { + const vm = new MessageTimestampViewModel({ + ts: nowDate.getTime(), + }); + expect(vm.getSnapshot()).toMatchObject({ + ts: "08:09", + tsSentAt: "Fri, Dec 17, 2021, 08:09:00", + }); + }); + + it("should return the snapshot with tsReceivedAt", () => { + const vm = new MessageTimestampViewModel({ + ts: nowDate.getTime(), + receivedTs: nowDate.getTime() + DAY_MS, + }); + expect(vm.getSnapshot()).toMatchObject({ + ts: "08:09", + tsSentAt: "Fri, Dec 17, 2021, 08:09:00", + tsReceivedAt: "Sat, Dec 18, 2021, 08:09:00", + }); + }); + + it("should return the snapshot with extra class names", () => { + const vm = new MessageTimestampViewModel({ + ts: nowDate.getTime(), + }); + expect(vm.getSnapshot()).toMatchObject({ + ts: "08:09", + tsSentAt: "Fri, Dec 17, 2021, 08:09:00", + className: "mx_MessageTimestamp", + }); + }); + + it("should use formatRelativeTime when showRelative is true", () => { + jest.spyOn(DateUtils, "formatFullDate").mockReturnValue("SENT_AT"); + const formatRelativeTimeSpy = jest.spyOn(DateUtils, "formatRelativeTime").mockReturnValue("RELATIVE"); + + const vm = new MessageTimestampViewModel({ + ts: nowDate.getTime(), + showRelative: true, + showTwelveHour: true, + }); + + expect(vm.getSnapshot()).toMatchObject({ + ts: "RELATIVE", + tsSentAt: "SENT_AT", + }); + expect(formatRelativeTimeSpy).toHaveBeenCalledWith(expect.any(Date), true); + }); + + it("should use full date when showFullDate is true and respect showSeconds", () => { + const formatFullDateSpy = jest + .spyOn(DateUtils, "formatFullDate") + .mockImplementation((_date, _showTwelveHour, showSeconds) => + showSeconds === false ? "FULL_NO_SECONDS" : "SENT_AT", + ); + + const vm = new MessageTimestampViewModel({ + ts: nowDate.getTime(), + showFullDate: true, + showSeconds: false, + }); + + expect(vm.getSnapshot()).toMatchObject({ + ts: "FULL_NO_SECONDS", + tsSentAt: "SENT_AT", + }); + expect(formatFullDateSpy).toHaveBeenCalled(); + }); + + it("should use full time when showSeconds is true without full date", () => { + jest.spyOn(DateUtils, "formatFullDate").mockReturnValue("SENT_AT"); + const formatFullTimeSpy = jest.spyOn(DateUtils, "formatFullTime").mockReturnValue("FULL_TIME"); + const formatTimeSpy = jest.spyOn(DateUtils, "formatTime").mockReturnValue("TIME"); + + const vm = new MessageTimestampViewModel({ + ts: nowDate.getTime(), + showSeconds: true, + }); + + expect(vm.getSnapshot()).toMatchObject({ + ts: "FULL_TIME", + tsSentAt: "SENT_AT", + }); + expect(formatFullTimeSpy).toHaveBeenCalled(); + expect(formatTimeSpy).not.toHaveBeenCalled(); + }); + + it("should include tooltip inhibition and href in the snapshot", () => { + const vm = new MessageTimestampViewModel({ + ts: nowDate.getTime(), + inhibitTooltip: true, + href: "https://example.test", + }); + + expect(vm.getSnapshot()).toMatchObject({ + inhibitTooltip: true, + href: "https://example.test", + }); + }); +});