diff --git a/packages/shared-components/playwright/snapshots/event-tiles-dateseparator--disable-relative-timestamps-linux.png b/packages/shared-components/playwright/snapshots/event-tiles-dateseparator--disable-relative-timestamps-linux.png new file mode 100644 index 00000000000..3f4c0be6cb6 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/event-tiles-dateseparator--disable-relative-timestamps-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/event-tiles-dateseparator--last-week-linux.png b/packages/shared-components/playwright/snapshots/event-tiles-dateseparator--last-week-linux.png new file mode 100644 index 00000000000..eeef2b94515 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/event-tiles-dateseparator--last-week-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/event-tiles-dateseparator--long-ago-linux.png b/packages/shared-components/playwright/snapshots/event-tiles-dateseparator--long-ago-linux.png new file mode 100644 index 00000000000..95ee9d8ccb9 Binary files /dev/null and b/packages/shared-components/playwright/snapshots/event-tiles-dateseparator--long-ago-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/event-tiles-dateseparator--today-linux.png b/packages/shared-components/playwright/snapshots/event-tiles-dateseparator--today-linux.png new file mode 100644 index 00000000000..71637d25dbf Binary files /dev/null and b/packages/shared-components/playwright/snapshots/event-tiles-dateseparator--today-linux.png differ diff --git a/packages/shared-components/playwright/snapshots/event-tiles-dateseparator--yesterday-linux.png b/packages/shared-components/playwright/snapshots/event-tiles-dateseparator--yesterday-linux.png new file mode 100644 index 00000000000..f10ba19c9cd Binary files /dev/null and b/packages/shared-components/playwright/snapshots/event-tiles-dateseparator--yesterday-linux.png differ 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..108950a057d --- /dev/null +++ b/packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.module.css @@ -0,0 +1,36 @@ +/* + * Copyright 2025 Element Creations 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..99e543cbba4 --- /dev/null +++ b/packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.stories.tsx @@ -0,0 +1,55 @@ +/* + * Copyright 2025 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 { Meta, StoryObj } from "@storybook/react-vite"; +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..cd9e4cdd6e3 --- /dev/null +++ b/packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2025 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 } 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..6cf7ebf583d --- /dev/null +++ b/packages/shared-components/src/event-tiles/DateSeparator/DateSeparator.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2025 Element Creations 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, { useMemo } 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; +} + +/** + * Get the label for a date separator + * @param ts - The timestamp (in milliseconds) to display + * @param locale - The locale to use for formatting + * @param disableRelativeTimestamps - Whether to disable relative timestamps + * @returns The formatted label string + */ +function getLabel(ts: number, locale: string, disableRelativeTimestamps: boolean): string { + try { + const date = new Date(ts); + + // 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); + const relativeTimeFormat = new Intl.RelativeTimeFormat(locale, { style: "long", numeric: "auto" }); + yesterday.setDate(today.getDate() - 1); + + if (date.toDateString() === today.toDateString()) { + return relativeTimeFormat.format(0, "day"); // Today + } else if (date.toDateString() === yesterday.toDateString()) { + return 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"); + } +} + +/** + * Timeline separator component to render within a MessagePanel bearing the date of the ts given + */ +export const DateSeparator: React.FC = ({ ts, locale = "en", disableRelativeTimestamps = false, className }) => { + const label = useMemo( + () => getLabel(ts, locale, disableRelativeTimestamps), + [ts, locale, disableRelativeTimestamps], + ); + + 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..94bba09c48c --- /dev/null +++ b/packages/shared-components/src/event-tiles/DateSeparator/index.tsx @@ -0,0 +1,9 @@ +/* + * Copyright 2025 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 { 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.test.ts b/packages/shared-components/src/utils/DateUtils.test.ts new file mode 100644 index 00000000000..50d7f80b2b3 --- /dev/null +++ b/packages/shared-components/src/utils/DateUtils.test.ts @@ -0,0 +1,126 @@ +/* + * Copyright 2025 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 { DAY_MS, getDaysArray, formatFullDateNoTime, formatSeconds } from "./DateUtils"; + +describe("DateUtils", () => { + describe("DAY_MS", () => { + it("should equal the number of milliseconds in a day", () => { + expect(DAY_MS).toBe(24 * 60 * 60 * 1000); + expect(DAY_MS).toBe(86400000); + }); + }); + + describe("getDaysArray", () => { + it("should return 7 days starting with Sunday", () => { + const days = getDaysArray("short", "en"); + expect(days).toHaveLength(7); + expect(days[0]).toBe("Sun"); + expect(days[1]).toBe("Mon"); + expect(days[6]).toBe("Sat"); + }); + + it("should use short format by default", () => { + const days = getDaysArray(undefined, "en"); + expect(days).toHaveLength(7); + expect(days[0]).toBe("Sun"); + }); + + it("should use en locale by default", () => { + const days = getDaysArray("short"); + expect(days).toHaveLength(7); + expect(days[0]).toBe("Sun"); + }); + + it("should support long weekday format", () => { + const days = getDaysArray("long", "en"); + expect(days).toHaveLength(7); + expect(days[0]).toBe("Sunday"); + expect(days[1]).toBe("Monday"); + expect(days[6]).toBe("Saturday"); + }); + + it("should support narrow weekday format", () => { + const days = getDaysArray("narrow", "en"); + expect(days).toHaveLength(7); + expect(days[0]).toBe("S"); + expect(days[1]).toBe("M"); + }); + + it("should support different locales", () => { + const daysFr = getDaysArray("long", "fr"); + expect(daysFr).toHaveLength(7); + expect(daysFr[0]).toBe("dimanche"); + expect(daysFr[1]).toBe("lundi"); + }); + }); + + describe("formatFullDateNoTime", () => { + it("should format date with short weekday, month, day and year", () => { + const date = new Date("2022-11-17T12:00:00Z"); + const formatted = formatFullDateNoTime(date, "en-GB"); + expect(formatted).toContain("Nov"); + expect(formatted).toContain("17"); + expect(formatted).toContain("2022"); + }); + + it("should use en locale by default", () => { + const date = new Date("2022-11-17T12:00:00Z"); + const formatted = formatFullDateNoTime(date); + expect(formatted).toContain("Nov"); + expect(formatted).toContain("17"); + expect(formatted).toContain("2022"); + }); + + it("should format dates in different locales", () => { + const date = new Date("2022-11-17T12:00:00Z"); + const formattedFr = formatFullDateNoTime(date, "fr"); + expect(formattedFr).toContain("nov."); + expect(formattedFr).toContain("17"); + expect(formattedFr).toContain("2022"); + }); + }); + + describe("formatSeconds", () => { + it("should format seconds only", () => { + expect(formatSeconds(45)).toBe("00:45"); + }); + + it("should format minutes and seconds", () => { + expect(formatSeconds(125)).toBe("02:05"); + }); + + it("should format hours, minutes and seconds", () => { + expect(formatSeconds(3665)).toBe("01:01:05"); + }); + + it("should pad with zeros", () => { + expect(formatSeconds(5)).toBe("00:05"); + expect(formatSeconds(65)).toBe("01:05"); + }); + + it("should handle zero", () => { + expect(formatSeconds(0)).toBe("00:00"); + }); + + it("should handle negative numbers", () => { + expect(formatSeconds(-45)).toBe("-00:45"); + expect(formatSeconds(-125)).toBe("-02:05"); + expect(formatSeconds(-3665)).toBe("-01:01:05"); + }); + + it("should handle large durations", () => { + expect(formatSeconds(36000)).toBe("10:00:00"); + expect(formatSeconds(359999)).toBe("99:59:59"); + }); + + it("should handle fractional seconds by flooring", () => { + expect(formatSeconds(45.9)).toBe("00:45"); + expect(formatSeconds(125.5)).toBe("02:05"); + }); + }); +}); diff --git a/packages/shared-components/src/utils/DateUtils.ts b/packages/shared-components/src/utils/DateUtils.ts index 146aeecbd20..2e02a89b3d8 100644 --- a/packages/shared-components/src/utils/DateUtils.ts +++ b/packages/shared-components/src/utils/DateUtils.ts @@ -1,10 +1,38 @@ /* - * Copyright 2025 New Vector Ltd. + * Copyright 2025 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 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/playwright/e2e/chat-export/html-export.spec.ts b/playwright/e2e/chat-export/html-export.spec.ts index 371111151e1..cf13a7cedb3 100644 --- a/playwright/e2e/chat-export/html-export.spec.ts +++ b/playwright/e2e/chat-export/html-export.spec.ts @@ -124,7 +124,7 @@ test.describe("HTML Export", () => { const zip = await extractZipFileToPath(zipPath, dirPath); await page.goto(`file://${dirPath}/${Object.keys(zip.files)[0]}/messages.html`); await expect(page).toMatchScreenshot("html-export.png", { - mask: [page.locator(".mx_TimelineSeparator")], + mask: [page.locator(".mx_DateSeparator")], css: ` .mx_MessageTimestamp { visibility: hidden; diff --git a/playwright/e2e/timeline/timeline.spec.ts b/playwright/e2e/timeline/timeline.spec.ts index 806e7e90388..55525a3ad06 100644 --- a/playwright/e2e/timeline/timeline.spec.ts +++ b/playwright/e2e/timeline/timeline.spec.ts @@ -153,7 +153,7 @@ test.describe("Timeline", () => { ).toBeVisible(); // wait for the date separator to appear to have a stable screenshot - await expect(page.locator(".mx_TimelineSeparator")).toHaveText("today"); + await expect(page.locator(".mx_DateSeparator")).toHaveText("today"); await expect(page.locator(".mx_MainSplit")).toMatchScreenshot("configured-room-irc-layout.png"); }, 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/structures/__snapshots__/MessagePanel-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/MessagePanel-test.tsx.snap index c2728d737df..266268189c3 100644 --- a/test/unit-tests/components/structures/__snapshots__/MessagePanel-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/MessagePanel-test.tsx.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`MessagePanel should handle large numbers of hidden events quickly 1`] = ` @@ -39,18 +39,18 @@ exports[`MessagePanel should handle lots of membership events quickly 1`] = ` >