Migrate DateSeperator to shared components

This commit is contained in:
David Langley 2025-11-03 22:22:24 +00:00
parent dcf3e536ab
commit 58bd72f4c9
11 changed files with 466 additions and 21 deletions

View File

@ -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;
}

View File

@ -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<typeof DateSeparator> = {
title: "Event Tiles/DateSeparator",
component: DateSeparator,
tags: ["autodocs"],
args: {
locale: "en",
},
};
export default meta;
type Story = StoryObj<typeof DateSeparator>;
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,
},
};

View File

@ -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(<DateSeparator ts={new Date("2024-11-03T10:00:00Z").getTime()} locale="en" />);
expect(container).toMatchSnapshot();
expect(container.textContent).toContain("today");
});
it("renders yesterday's date", () => {
const { container } = render(<DateSeparator ts={new Date("2024-11-02T10:00:00Z").getTime()} locale="en" />);
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(<DateSeparator ts={new Date("2024-10-30T10:00:00Z").getTime()} locale="en" />);
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(<DateSeparator ts={new Date("2024-10-01T10:00:00Z").getTime()} locale="en" />);
expect(container).toMatchSnapshot();
expect(container.textContent).toContain("Oct");
});
it("renders full date when relative timestamps are disabled", () => {
const { container } = render(
<DateSeparator ts={new Date("2024-11-03T10:00:00Z").getTime()} locale="en" disableRelativeTimestamps />,
);
expect(container).toMatchSnapshot();
// Should show full date even though it's today
expect(container.textContent).toContain("Nov");
});
it("applies custom className", () => {
const { container } = render(
<DateSeparator ts={Date.now()} locale="en" className="custom-class" />,
);
expect(container.querySelector(".mx_DateSeparator.custom-class")).toBeTruthy();
});
it("has correct ARIA attributes", () => {
const { container } = render(<DateSeparator ts={Date.now()} locale="en" />);
const separator = container.querySelector('[role="separator"]');
expect(separator).toBeTruthy();
expect(separator?.getAttribute("aria-label")).toBeTruthy();
});
});

View File

@ -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<Props> {
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 (
<div
className={classNames(styles.dateSeparator, "mx_DateSeparator", this.props.className)}
role="separator"
aria-label={label}
>
<hr role="none" />
<div className={classNames(styles.dateContent, "mx_DateSeparator_dateContent")}>
<h2 className={classNames(styles.dateHeading, "mx_DateSeparator_dateHeading")} aria-hidden="true">
{label}
</h2>
</div>
<hr role="none" />
</div>
);
}
}

View File

@ -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`] = `
<div>
<div
aria-label="Wednesday"
class="dateSeparator mx_DateSeparator"
role="separator"
>
<hr
role="none"
/>
<div
class="dateContent mx_DateSeparator_dateContent"
>
<h2
aria-hidden="true"
class="dateHeading mx_DateSeparator_dateHeading"
>
Wednesday
</h2>
</div>
<hr
role="none"
/>
</div>
</div>
`;
exports[`DateSeparator renders full date for dates older than 6 days 1`] = `
<div>
<div
aria-label="Tue, Oct 1, 2024"
class="dateSeparator mx_DateSeparator"
role="separator"
>
<hr
role="none"
/>
<div
class="dateContent mx_DateSeparator_dateContent"
>
<h2
aria-hidden="true"
class="dateHeading mx_DateSeparator_dateHeading"
>
Tue, Oct 1, 2024
</h2>
</div>
<hr
role="none"
/>
</div>
</div>
`;
exports[`DateSeparator renders full date when relative timestamps are disabled 1`] = `
<div>
<div
aria-label="Sun, Nov 3, 2024"
class="dateSeparator mx_DateSeparator"
role="separator"
>
<hr
role="none"
/>
<div
class="dateContent mx_DateSeparator_dateContent"
>
<h2
aria-hidden="true"
class="dateHeading mx_DateSeparator_dateHeading"
>
Sun, Nov 3, 2024
</h2>
</div>
<hr
role="none"
/>
</div>
</div>
`;
exports[`DateSeparator renders today's date 1`] = `
<div>
<div
aria-label="today"
class="dateSeparator mx_DateSeparator"
role="separator"
>
<hr
role="none"
/>
<div
class="dateContent mx_DateSeparator_dateContent"
>
<h2
aria-hidden="true"
class="dateHeading mx_DateSeparator_dateHeading"
>
today
</h2>
</div>
<hr
role="none"
/>
</div>
</div>
`;
exports[`DateSeparator renders yesterday's date 1`] = `
<div>
<div
aria-label="yesterday"
class="dateSeparator mx_DateSeparator"
role="separator"
>
<hr
role="none"
/>
<div
class="dateContent mx_DateSeparator_dateContent"
>
<h2
aria-hidden="true"
class="dateHeading mx_DateSeparator_dateHeading"
>
yesterday
</h2>
</div>
<hr
role="none"
/>
</div>
</div>
`;

View File

@ -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";

View File

@ -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";

View File

@ -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

View File

@ -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;
}

View File

@ -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<IProps, IState> {
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<IProps, IState> {
title={_t("room|jump_to_date")}
>
<h2 className="mx_DateSeparator_dateHeading" aria-hidden="true">
{this.getLabel()}
{label}
</h2>
<div className="mx_DateSeparator_chevron" />
{contextMenu}
@ -319,21 +319,29 @@ export default class DateSeparator extends React.Component<IProps, IState> {
}
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 = (
<div className="mx_DateSeparator_dateContent">
<h2 className="mx_DateSeparator_dateHeading" aria-hidden="true">
{label}
</h2>
const label = this.getLabel();
return (
<div className="mx_DateSeparator" role="separator" aria-label={label}>
<hr role="none" />
{this.renderJumpToDateMenu(label)}
<hr role="none" />
</div>
);
}
return <TimelineSeparator label={label}>{dateHeaderContent}</TimelineSeparator>;
// Otherwise, just use the shared component directly
return (
<SharedDateSeparator
ts={this.props.ts}
locale={getUserLanguage()}
disableRelativeTimestamps={this.props.forExport || disableRelativeTimestamps}
/>
);
}
}

View File

@ -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`] = `
<DocumentFragment>
<div
aria-label="Invalid timestamp"
class="mx_TimelineSeparator"
class="_dateSeparator_15xjf_9 mx_DateSeparator"
role="separator"
>
<hr
role="none"
/>
<div
class="mx_DateSeparator_dateContent"
class="_dateContent_15xjf_25 mx_DateSeparator_dateContent"
>
<h2
aria-hidden="true"
class="mx_DateSeparator_dateHeading"
class="_dateHeading_15xjf_29 mx_DateSeparator_dateHeading"
>
Invalid timestamp
</h2>
@ -31,18 +31,18 @@ exports[`DateSeparator renders the date separator correctly 1`] = `
<DocumentFragment>
<div
aria-label="today"
class="mx_TimelineSeparator"
class="_dateSeparator_15xjf_9 mx_DateSeparator"
role="separator"
>
<hr
role="none"
/>
<div
class="mx_DateSeparator_dateContent"
class="_dateContent_15xjf_25 mx_DateSeparator_dateContent"
>
<h2
aria-hidden="true"
class="mx_DateSeparator_dateHeading"
class="_dateHeading_15xjf_29 mx_DateSeparator_dateHeading"
>
today
</h2>
@ -58,7 +58,7 @@ exports[`DateSeparator when feature_jump_to_date is enabled renders the date sep
<DocumentFragment>
<div
aria-label="Fri, Dec 17, 2021"
class="mx_TimelineSeparator"
class="mx_DateSeparator"
role="separator"
>
<hr