From 58bd72f4c99cf006cea9d876049e2447d7af11bf Mon Sep 17 00:00:00 2001 From: David Langley Date: Mon, 3 Nov 2025 22:22:24 +0000 Subject: [PATCH 1/9] Migrate DateSeperator to shared components --- .../DateSeparator/DateSeparator.module.css | 36 +++++ .../DateSeparator/DateSeparator.stories.tsx | 56 ++++++++ .../DateSeparator/DateSeparator.test.tsx | 72 ++++++++++ .../DateSeparator/DateSeparator.tsx | 83 +++++++++++ .../__snapshots__/DateSeparator.test.tsx.snap | 136 ++++++++++++++++++ .../src/event-tiles/DateSeparator/index.tsx | 9 ++ packages/shared-components/src/index.ts | 1 + .../shared-components/src/utils/DateUtils.ts | 28 ++++ res/css/views/messages/_DateSeparator.pcss | 16 +++ .../views/messages/DateSeparator.tsx | 34 +++-- .../__snapshots__/DateSeparator-test.tsx.snap | 16 +-- 11 files changed, 466 insertions(+), 21 deletions(-) create mode 100644 packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.module.css create mode 100644 packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.stories.tsx create mode 100644 packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.test.tsx create mode 100644 packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.tsx create mode 100644 packages/shared-components/src/event-tiles/DateSeparator/__snapshots__/DateSeparator.test.tsx.snap create mode 100644 packages/shared-components/src/event-tiles/DateSeparator/index.tsx diff --git a/packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.module.css b/packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.module.css new file mode 100644 index 00000000000..02df06a514d --- /dev/null +++ b/packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.module.css @@ -0,0 +1,36 @@ +/* + * Copyright 2025 New Vector Ltd. + * Copyright 2017 Vector 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. + */ + +.dateSeparator { + clear: both; + margin: 4px 0; + display: flex; + align-items: center; + font: var(--cpd-font-body-md-regular); + color: var(--cpd-color-text-primary); +} + +.dateSeparator > hr { + flex: 1 1 0; + height: 0; + border: none; + border-bottom: 1px solid var(--cpd-color-gray-400); +} + +.dateContent { + padding: 0 25px; +} + +.dateHeading { + flex: 0 0 auto; + margin: 0; + font-size: inherit; + font-weight: inherit; + color: inherit; + text-transform: capitalize; +} diff --git a/packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.stories.tsx b/packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.stories.tsx new file mode 100644 index 00000000000..d0e5673361d --- /dev/null +++ b/packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.stories.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2025 New Vector 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 { Meta, StoryObj } from "@storybook/react"; + +import { DateSeparator } from "./DateSeparator"; + +const now = Date.now(); +const DAY_MS = 24 * 60 * 60 * 1000; + +const meta: Meta = { + title: "Event Tiles/DateSeparator", + component: DateSeparator, + tags: ["autodocs"], + args: { + locale: "en", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Today: Story = { + args: { + ts: now, + }, +}; + +export const Yesterday: Story = { + args: { + ts: now - DAY_MS, + }, +}; + +export const LastWeek: Story = { + args: { + ts: now - 4 * DAY_MS, + }, +}; + +export const LongAgo: Story = { + args: { + ts: now - 365 * DAY_MS, + }, +}; + +export const DisableRelativeTimestamps: Story = { + args: { + ts: now, + disableRelativeTimestamps: true, + }, +}; diff --git a/packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.test.tsx b/packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.test.tsx new file mode 100644 index 00000000000..b92178b27fc --- /dev/null +++ b/packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.test.tsx @@ -0,0 +1,72 @@ +/* + * Copyright 2025 New Vector 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 } from "jest-matrix-react"; +import React from "react"; + +import { DateSeparator } from "./DateSeparator"; + +describe("DateSeparator", () => { + beforeEach(() => { + jest.useFakeTimers(); + // Set a fixed "now" time for consistent testing + jest.setSystemTime(new Date("2024-11-03T12:00:00Z")); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("renders today's date", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + expect(container.textContent).toContain("today"); + }); + + it("renders yesterday's date", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + expect(container.textContent).toContain("yesterday"); + }); + + it("renders a weekday for dates within the last 6 days", () => { + // 4 days ago + const { container } = render(); + expect(container).toMatchSnapshot(); + // Should show a day name like "Wednesday" + expect(container.querySelector(".mx_DateSeparator_dateHeading")).toBeTruthy(); + }); + + it("renders full date for dates older than 6 days", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + expect(container.textContent).toContain("Oct"); + }); + + it("renders full date when relative timestamps are disabled", () => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + // Should show full date even though it's today + expect(container.textContent).toContain("Nov"); + }); + + it("applies custom className", () => { + const { container } = render( + , + ); + expect(container.querySelector(".mx_DateSeparator.custom-class")).toBeTruthy(); + }); + + it("has correct ARIA attributes", () => { + const { container } = render(); + const separator = container.querySelector('[role="separator"]'); + expect(separator).toBeTruthy(); + expect(separator?.getAttribute("aria-label")).toBeTruthy(); + }); +}); diff --git a/packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.tsx b/packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.tsx new file mode 100644 index 00000000000..4298dec4e88 --- /dev/null +++ b/packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.tsx @@ -0,0 +1,83 @@ +/* + * Copyright 2025 New Vector Ltd. + * Copyright 2015-2021 The Matrix.org Foundation C.I.C. + * Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> + * + * 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 classNames from "classnames"; + +import { _t } from "../../utils/i18n"; +import { formatFullDateNoTime, getDaysArray, DAY_MS } from "../../utils/DateUtils"; + +import styles from "./DateSeparator.module.css"; + +export interface Props { + /** The timestamp (in milliseconds) to display */ + ts: number; + /** The locale to use for formatting. Defaults to "en" */ + locale?: string; + /** Whether to disable relative timestamps (e.g., "Today", "Yesterday"). If true, always shows full date */ + disableRelativeTimestamps?: boolean; + /** Additional CSS class name */ + className?: string; +} + +/** + * Timeline separator component to render within a MessagePanel bearing the date of the ts given + */ +export class DateSeparator extends React.Component { + private get relativeTimeFormat(): Intl.RelativeTimeFormat { + return new Intl.RelativeTimeFormat(this.props.locale ?? "en", { style: "long", numeric: "auto" }); + } + + public getLabel(): string { + try { + const date = new Date(this.props.ts); + const { disableRelativeTimestamps = false, locale = "en" } = this.props; + + // If relative timestamps are disabled, return the full date + if (disableRelativeTimestamps) return formatFullDateNoTime(date, locale); + + const today = new Date(); + const yesterday = new Date(); + const days = getDaysArray("long", locale); + yesterday.setDate(today.getDate() - 1); + + if (date.toDateString() === today.toDateString()) { + return this.relativeTimeFormat.format(0, "day"); // Today + } else if (date.toDateString() === yesterday.toDateString()) { + return this.relativeTimeFormat.format(-1, "day"); // Yesterday + } else if (today.getTime() - date.getTime() < 6 * DAY_MS) { + return days[date.getDay()]; // Sunday-Saturday + } else { + return formatFullDateNoTime(date, locale); + } + } catch { + return _t("common|message_timestamp_invalid"); + } + } + + public render(): React.ReactNode { + const label = this.getLabel(); + + return ( +
+
+
+ +
+
+
+ ); + } +} diff --git a/packages/shared-components/src/event-tiles/DateSeparator/__snapshots__/DateSeparator.test.tsx.snap b/packages/shared-components/src/event-tiles/DateSeparator/__snapshots__/DateSeparator.test.tsx.snap new file mode 100644 index 00000000000..63379f3f09b --- /dev/null +++ b/packages/shared-components/src/event-tiles/DateSeparator/__snapshots__/DateSeparator.test.tsx.snap @@ -0,0 +1,136 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`DateSeparator renders a weekday for dates within the last 6 days 1`] = ` +
+ +
+`; + +exports[`DateSeparator renders full date for dates older than 6 days 1`] = ` +
+ +
+`; + +exports[`DateSeparator renders full date when relative timestamps are disabled 1`] = ` +
+ +
+`; + +exports[`DateSeparator renders today's date 1`] = ` +
+ +
+`; + +exports[`DateSeparator renders yesterday's date 1`] = ` +
+ +
+`; diff --git a/packages/shared-components/src/event-tiles/DateSeparator/index.tsx b/packages/shared-components/src/event-tiles/DateSeparator/index.tsx new file mode 100644 index 00000000000..05a24d317d3 --- /dev/null +++ b/packages/shared-components/src/event-tiles/DateSeparator/index.tsx @@ -0,0 +1,9 @@ +/* + * Copyright 2025 New Vector 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 { DateSeparator } from "./DateSeparator"; +export type { Props as DateSeparatorProps } from "./DateSeparator"; diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 68935afd3fc..176b80e8c65 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -11,6 +11,7 @@ export * from "./audio/Clock"; export * from "./audio/PlayPauseButton"; export * from "./audio/SeekBar"; export * from "./avatar/AvatarWithDetails"; +export * from "./event-tiles/DateSeparator"; export * from "./event-tiles/TextualEventView"; export * from "./message-body/MediaBody"; export * from "./pill-input/Pill"; diff --git a/packages/shared-components/src/utils/DateUtils.ts b/packages/shared-components/src/utils/DateUtils.ts index 146aeecbd20..5cbe3b20530 100644 --- a/packages/shared-components/src/utils/DateUtils.ts +++ b/packages/shared-components/src/utils/DateUtils.ts @@ -5,6 +5,34 @@ * Please see LICENSE files in the repository root for full details. */ +export const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Returns array of 7 weekday names, from Sunday to Saturday, internationalised to the given locale. + * @param weekday - format desired "long" | "short" | "narrow" + * @param locale - the locale string to use, defaults to "en" + */ +export function getDaysArray(weekday: Intl.DateTimeFormatOptions["weekday"] = "short", locale = "en"): string[] { + const sunday = 1672574400000; // 2023-01-01 12:00 UTC + const { format } = new Intl.DateTimeFormat(locale, { weekday, timeZone: "UTC" }); + return [...Array(7).keys()].map((day) => format(sunday + day * DAY_MS)); +} + +/** + * Formats a given date to a human-friendly string with short weekday. + * @example "Thu, 17 Nov 2022" in en-GB locale + * @param date - date object to format + * @param locale - the locale string to use, defaults to "en" + */ +export function formatFullDateNoTime(date: Date, locale = "en"): string { + return new Intl.DateTimeFormat(locale, { + weekday: "short", + month: "short", + day: "numeric", + year: "numeric", + }).format(date); +} + /** * Formats a number of seconds into a human-readable string. * @param inSeconds diff --git a/res/css/views/messages/_DateSeparator.pcss b/res/css/views/messages/_DateSeparator.pcss index fe134125610..de18bdb6919 100644 --- a/res/css/views/messages/_DateSeparator.pcss +++ b/res/css/views/messages/_DateSeparator.pcss @@ -6,6 +6,22 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +.mx_DateSeparator { + clear: both; + margin: 4px 0; + display: flex; + align-items: center; + font: var(--cpd-font-body-md-regular); + color: var(--cpd-color-text-primary); +} + +.mx_DateSeparator > hr { + flex: 1 1 0; + height: 0; + border: none; + border-bottom: 1px solid var(--cpd-color-gray-400); +} + .mx_DateSeparator_dateContent { padding: 0 25px; } diff --git a/src/components/views/messages/DateSeparator.tsx b/src/components/views/messages/DateSeparator.tsx index 3b9a1bc393b..daaf3bb5b45 100644 --- a/src/components/views/messages/DateSeparator.tsx +++ b/src/components/views/messages/DateSeparator.tsx @@ -11,6 +11,7 @@ import React, { type JSX } from "react"; import { Direction, ConnectionError, MatrixError, HTTPError } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { capitalize } from "lodash"; +import { DateSeparator as SharedDateSeparator } from "@element-hq/web-shared-components"; import { _t, getUserLanguage } from "../../../languageHandler"; import { formatFullDateNoDay, formatFullDateNoTime, getDaysArray } from "../../../DateUtils"; @@ -31,7 +32,6 @@ import IconizedContextMenu, { } from "../context_menus/IconizedContextMenu"; import JumpToDatePicker from "./JumpToDatePicker"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import TimelineSeparator from "./TimelineSeparator"; import RoomContext from "../../../contexts/RoomContext"; interface IProps { @@ -267,7 +267,7 @@ export default class DateSeparator extends React.Component { this.closeMenu(); }; - private renderJumpToDateMenu(): React.ReactElement { + private renderJumpToDateMenu(label: string): React.ReactElement { let contextMenu: JSX.Element | undefined; if (this.state.contextMenuPosition) { const relativeTimeFormat = this.relativeTimeFormat; @@ -310,7 +310,7 @@ export default class DateSeparator extends React.Component { title={_t("room|jump_to_date")} >
{contextMenu} @@ -319,21 +319,29 @@ export default class DateSeparator extends React.Component { } public render(): React.ReactNode { - const label = this.getLabel(); + const disableRelativeTimestamps = !SettingsStore.getValue(UIFeature.TimelineEnableRelativeDates); - let dateHeaderContent: JSX.Element; + // If jump to date is enabled and we're not exporting, we need to wrap the content + // in our custom jump-to-date menu button if (this.state.jumpToDateEnabled && !this.props.forExport) { - dateHeaderContent = this.renderJumpToDateMenu(); - } else { - dateHeaderContent = ( -
- + const label = this.getLabel(); + + return ( +
+
+ {this.renderJumpToDateMenu(label)} +
); } - return {dateHeaderContent}; + // Otherwise, just use the shared component directly + return ( + + ); } } diff --git a/test/unit-tests/components/views/messages/__snapshots__/DateSeparator-test.tsx.snap b/test/unit-tests/components/views/messages/__snapshots__/DateSeparator-test.tsx.snap index 90d5537b65d..b42465f189b 100644 --- a/test/unit-tests/components/views/messages/__snapshots__/DateSeparator-test.tsx.snap +++ b/test/unit-tests/components/views/messages/__snapshots__/DateSeparator-test.tsx.snap @@ -1,21 +1,21 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`DateSeparator renders invalid date separator correctly 1`] = `