mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-05 04:06:44 +02:00
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:
parent
f90b329490
commit
ca3bc30f90
@ -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" },
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@ -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<M extends object, P extends DialogProps = DialogProps, C extends DialogContent<P> = DialogContent<P>>(initialTitleOrOptions: string | ModuleUiDialogOptions, body: (props: P, ref: React.RefObject<C>) => React.ReactNode, props?: Omit<P, keyof DialogProps>): Promise<{
|
||||
+ openDialog<M extends object, P extends DialogProps = DialogProps, C extends DialogContent<P> = DialogContent<P>>(initialTitleOrOptions: string | ModuleUiDialogOptions, body: (props: P, ref: React.RefObject<C | null>) => React.ReactNode, props?: Omit<P, keyof DialogProps>): 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<ArrayBuffer> | null;
|
||||
+ createSecretStorageKey(): Uint8Array<ArrayBuffer> | 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<Uint8Array>) | null;
|
||||
+ getDehydrationKeyCallback(): ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array<ArrayBuffer>) => void) => Promise<Uint8Array<ArrayBuffer>>) | 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<ArrayBuffer> | null;
|
||||
+ abstract createSecretStorageKey(): Uint8Array<ArrayBuffer> | 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<Uint8Array>) | null;
|
||||
+ getDehydrationKeyCallback(): ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array<ArrayBuffer>) => void) => Promise<Uint8Array<ArrayBuffer>>) | 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<ArrayBuffer> | null;
|
||||
+ createSecretStorageKey(): Uint8Array<ArrayBuffer> | null;
|
||||
catchAccessSecretStorageError(e: Error): void;
|
||||
setupEncryptionNeeded(args: CryptoSetupArgs): boolean;
|
||||
- getDehydrationKeyCallback(): ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array) => void) => Promise<Uint8Array>) | null;
|
||||
+ getDehydrationKeyCallback(): ((keyInfo: SecretStorageKeyDescription, checkFunc: (key: Uint8Array<ArrayBuffer>) => void) => Promise<Uint8Array<ArrayBuffer>>) | 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;
|
||||
}
|
||||
}, {
|
||||
@ -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": {
|
||||
|
||||
@ -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";
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
173
src/viewmodels/message-body/MessageTimestampViewModel.ts
Normal file
173
src/viewmodels/message-body/MessageTimestampViewModel.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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(<MessageTimestamp ts={nowDate.getTime()} />);
|
||||
expect(asFragment()).toMatchInlineSnapshot(`
|
||||
<DocumentFragment>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
aria-live="off"
|
||||
class="mx_MessageTimestamp"
|
||||
tabindex="0"
|
||||
>
|
||||
08:09
|
||||
</span>
|
||||
</DocumentFragment>
|
||||
`);
|
||||
});
|
||||
|
||||
it("should show full date & time on hover", async () => {
|
||||
const { container } = render(<MessageTimestamp ts={nowDate.getTime()} />);
|
||||
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(
|
||||
<MessageTimestamp ts={nowDate.getTime()} receivedTs={nowDate.getTime() + DAY_MS} />,
|
||||
);
|
||||
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"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -93,7 +93,7 @@ exports[`<MImageBody/> should open ImageView using thumbnail for encrypted svg 1
|
||||
</div>
|
||||
<a
|
||||
aria-live="off"
|
||||
class="mx_MessageTimestamp"
|
||||
class="mx_MessageTimestamp _content_kc5mt_8"
|
||||
href="https://matrix.to/#/!room:server/undefined"
|
||||
>
|
||||
Thu, Jan 15, 1970, 06:56
|
||||
|
||||
@ -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);
|
||||
|
||||
File diff suppressed because one or more lines are too long
123
test/viewmodels/message-body/MessageTimestampViewModel-test.tsx
Normal file
123
test/viewmodels/message-body/MessageTimestampViewModel-test.tsx
Normal file
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user