diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/TimelineSeparator/TimelineSeparator.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/TimelineSeparator/TimelineSeparator.stories.tsx/default-auto.png new file mode 100644 index 0000000000..9830d14901 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/TimelineSeparator/TimelineSeparator.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/TimelineSeparator/TimelineSeparator.stories.tsx/with-date-event-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/TimelineSeparator/TimelineSeparator.stories.tsx/with-date-event-auto.png new file mode 100644 index 0000000000..0c14346698 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/TimelineSeparator/TimelineSeparator.stories.tsx/with-date-event-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/TimelineSeparator/TimelineSeparator.stories.tsx/with-html-child-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/TimelineSeparator/TimelineSeparator.stories.tsx/with-html-child-auto.png new file mode 100644 index 0000000000..a6182a84ab Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/TimelineSeparator/TimelineSeparator.stories.tsx/with-html-child-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/TimelineSeparator/TimelineSeparator.stories.tsx/with-late-event-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/TimelineSeparator/TimelineSeparator.stories.tsx/with-late-event-auto.png new file mode 100644 index 0000000000..9cb04d9757 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/TimelineSeparator/TimelineSeparator.stories.tsx/with-late-event-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/message-body/TimelineSeparator/TimelineSeparator.stories.tsx/without-children-auto.png b/packages/shared-components/__vis__/linux/__baselines__/message-body/TimelineSeparator/TimelineSeparator.stories.tsx/without-children-auto.png new file mode 100644 index 0000000000..2e132bb28b Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/message-body/TimelineSeparator/TimelineSeparator.stories.tsx/without-children-auto.png differ diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 95c3d1994d..0efa6698f6 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -18,6 +18,7 @@ export * from "./event-tiles/TextualEventView"; export * from "./message-body/MediaBody"; export * from "./message-body/DecryptionFailureBodyView"; export * from "./message-body/ReactionsRowButtonTooltip"; +export * from "./message-body/TimelineSeparator/"; export * from "./pill-input/Pill"; export * from "./pill-input/PillInput"; export * from "./room/RoomStatusBar"; diff --git a/packages/shared-components/src/message-body/TimelineSeparator/TimelineSeparator.module.css b/packages/shared-components/src/message-body/TimelineSeparator/TimelineSeparator.module.css new file mode 100644 index 0000000000..fea4615390 --- /dev/null +++ b/packages/shared-components/src/message-body/TimelineSeparator/TimelineSeparator.module.css @@ -0,0 +1,21 @@ +/* + * 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. + */ + +.timelineSeparator { + clear: both; + margin: var(--cpd-space-1x) 0; + font: var(--cpd-font-body-md-regular); + letter-spacing: var(--cpd-font-letter-spacing-body-md); + color: var(--cpd-color-text-primary); +} + +.timelineSeparator > hr { + flex: 1 1 0; + height: 0; + border: none; + border-bottom: 1px solid var(--cpd-color-gray-400); +} diff --git a/packages/shared-components/src/message-body/TimelineSeparator/TimelineSeparator.stories.tsx b/packages/shared-components/src/message-body/TimelineSeparator/TimelineSeparator.stories.tsx new file mode 100644 index 0000000000..cf067f4ecd --- /dev/null +++ b/packages/shared-components/src/message-body/TimelineSeparator/TimelineSeparator.stories.tsx @@ -0,0 +1,54 @@ +/* + * 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 from "react"; + +import type { Meta, StoryFn } from "@storybook/react-vite"; +import TimelineSeparator from "./TimelineSeparator"; +import styles from "./TimelineSeparator.module.css"; + +export default { + title: "MessageBody/TimelineSeparator", + component: TimelineSeparator, + tags: ["autodocs"], + args: { + label: "Label Separator", + children: "Timeline Separator", + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); + +export const WithHtmlChild = Template.bind({}); +WithHtmlChild.args = { + label: "Custom Label", + children: ( + + ), +}; + +export const WithDateEvent = Template.bind({}); +WithDateEvent.args = { + label: "Date Event Separator", + children: "Wednesday", +}; + +export const WithLateEvent = Template.bind({}); +WithLateEvent.args = { + label: "Late Event Separator", + children: "Fri, Jan 9, 2026", +}; + +export const WithoutChildren = Template.bind({}); +WithoutChildren.args = { + children: undefined, + label: "Separator without children", +}; diff --git a/packages/shared-components/src/message-body/TimelineSeparator/TimelineSeparator.test.tsx b/packages/shared-components/src/message-body/TimelineSeparator/TimelineSeparator.test.tsx new file mode 100644 index 0000000000..859142b49c --- /dev/null +++ b/packages/shared-components/src/message-body/TimelineSeparator/TimelineSeparator.test.tsx @@ -0,0 +1,48 @@ +/* + * 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 } from "@test-utils"; +import { composeStories } from "@storybook/react-vite"; +import React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import * as stories from "./TimelineSeparator.stories.tsx"; + +const { Default, WithHtmlChild, WithoutChildren, WithDateEvent, WithLateEvent } = composeStories(stories); + +describe("TimelineSeparator", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("Snapshot tests", () => { + it("renders the timeline separator in default state", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the timeline separator with HTML child", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the timeline separator with date event", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the timeline separator with late event", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the timeline separator without children", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/shared-components/src/message-body/TimelineSeparator/TimelineSeparator.tsx b/packages/shared-components/src/message-body/TimelineSeparator/TimelineSeparator.tsx new file mode 100644 index 0000000000..cd3af1c1a7 --- /dev/null +++ b/packages/shared-components/src/message-body/TimelineSeparator/TimelineSeparator.tsx @@ -0,0 +1,54 @@ +/* +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 PropsWithChildren } from "react"; +import classNames from "classnames"; + +import styles from "./TimelineSeparator.module.css"; +import { Flex } from "../.."; + +/** + * Timeline separator props + */ +export interface TimelineSeparatorProps { + /** + * Accessible label for the separator (for example: "Today", "Yesterday", or a date). + */ + label: string; + /** + * The CSS class name. + */ + className?: string; + /** + * Optional children to render inside the timeline separator + */ + children?: PropsWithChildren["children"]; +} + +/** + * Generic timeline separator component to render within a MessagePanel + * + * @param label the accessible label string describing the separator + * @param children the children to draw within the timeline separator + */ +const TimelineSeparator: React.FC = ({ label, className, children }) => { + // ARIA treats
s as separators, here we abuse them slightly so manually treat this entire thing as one + return ( + +
+ {children} +
+
+ ); +}; + +export default TimelineSeparator; diff --git a/packages/shared-components/src/message-body/TimelineSeparator/__snapshots__/TimelineSeparator.test.tsx.snap b/packages/shared-components/src/message-body/TimelineSeparator/__snapshots__/TimelineSeparator.test.tsx.snap new file mode 100644 index 0000000000..e45501e14d --- /dev/null +++ b/packages/shared-components/src/message-body/TimelineSeparator/__snapshots__/TimelineSeparator.test.tsx.snap @@ -0,0 +1,100 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TimelineSeparator > Snapshot tests > renders the timeline separator in default state 1`] = ` +
+ +
+`; + +exports[`TimelineSeparator > Snapshot tests > renders the timeline separator with HTML child 1`] = ` +
+ +
+`; + +exports[`TimelineSeparator > Snapshot tests > renders the timeline separator with date event 1`] = ` +
+ +
+`; + +exports[`TimelineSeparator > Snapshot tests > renders the timeline separator with late event 1`] = ` +
+ +
+`; + +exports[`TimelineSeparator > Snapshot tests > renders the timeline separator without children 1`] = ` +
+ +
+`; diff --git a/packages/shared-components/src/message-body/TimelineSeparator/index.ts b/packages/shared-components/src/message-body/TimelineSeparator/index.ts new file mode 100644 index 0000000000..c5812abb07 --- /dev/null +++ b/packages/shared-components/src/message-body/TimelineSeparator/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { default as TimelineSeparator, type TimelineSeparatorProps } from "./TimelineSeparator"; diff --git a/res/css/_components.pcss b/res/css/_components.pcss index a47fc0ac82..28458c899f 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -246,7 +246,6 @@ @import "./views/messages/_RedactedBody.pcss"; @import "./views/messages/_RoomAvatarEvent.pcss"; @import "./views/messages/_TextualEvent.pcss"; -@import "./views/messages/_TimelineSeparator.pcss"; @import "./views/messages/_UnknownBody.pcss"; @import "./views/messages/_ViewSourceEvent.pcss"; @import "./views/messages/_common_CryptoEvent.pcss"; diff --git a/res/css/views/messages/_TimelineSeparator.pcss b/res/css/views/messages/_TimelineSeparator.pcss deleted file mode 100644 index aab77d4e03..0000000000 --- a/res/css/views/messages/_TimelineSeparator.pcss +++ /dev/null @@ -1,23 +0,0 @@ -/* -Copyright 2024 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. -*/ - -.mx_TimelineSeparator { - clear: both; - margin: 4px 0; - display: flex; - align-items: center; - font: var(--cpd-font-body-md-regular); - color: var(--cpd-color-text-primary); -} - -.mx_TimelineSeparator > hr { - flex: 1 1 0; - height: 0; - border: none; - border-bottom: 1px solid var(--cpd-color-gray-400); -} diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 30f9b474a6..40b69130a7 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -18,6 +18,7 @@ import { } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { isSupportedReceiptType } from "matrix-js-sdk/src/utils"; +import { TimelineSeparator } from "@element-hq/web-shared-components"; import shouldHideEvent from "../../shouldHideEvent"; import { formatDate, wantsDateSeparator } from "../../DateUtils"; @@ -37,7 +38,6 @@ import type LegacyCallEventGrouper from "./LegacyCallEventGrouper"; import WhoIsTypingTile from "../views/rooms/WhoIsTypingTile"; import ScrollPanel, { type IScrollState } from "./ScrollPanel"; import DateSeparator from "../views/messages/DateSeparator"; -import TimelineSeparator, { SeparatorKind } from "../views/messages/TimelineSeparator"; import ErrorBoundary from "../views/elements/ErrorBoundary"; import Spinner from "../views/elements/Spinner"; import { type RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; @@ -57,6 +57,18 @@ import { getLateEventInfo } from "./grouper/LateEventGrouper"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; +/** + * Indicates which separator (if any) should be rendered between timeline events. + */ +export const enum SeparatorKind { + /** No separator should be shown between the two events. */ + None, + /** Insert a date separator (oriented by event date boundaries). */ + Date, + /** Insert a late-event separator when events belong to different late groups. */ + LateEvent, +} + // check if there is a previous event and it has the same sender as this event // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL export function shouldFormContinuation( @@ -756,7 +768,7 @@ export default class MessagePanel extends React.Component { }); ret.push(
  • - + {text}
  • , diff --git a/src/components/structures/grouper/CreationGrouper.tsx b/src/components/structures/grouper/CreationGrouper.tsx index 009f5bdc26..80c9bfedcd 100644 --- a/src/components/structures/grouper/CreationGrouper.tsx +++ b/src/components/structures/grouper/CreationGrouper.tsx @@ -11,14 +11,13 @@ import { EventType, M_BEACON_INFO, type MatrixEvent } from "matrix-js-sdk/src/ma import { KnownMembership } from "matrix-js-sdk/src/types"; import { BaseGrouper } from "./BaseGrouper"; -import { type WrappedEvent } from "../MessagePanel"; +import { SeparatorKind, type WrappedEvent } from "../MessagePanel"; import type MessagePanel from "../MessagePanel"; import DMRoomMap from "../../../utils/DMRoomMap"; import { _t } from "../../../languageHandler"; import DateSeparator from "../../views/messages/DateSeparator"; import NewRoomIntro from "../../views/rooms/NewRoomIntro"; import GenericEventListSummary from "../../views/elements/GenericEventListSummary"; -import { SeparatorKind } from "../../views/messages/TimelineSeparator"; // Wrap initial room creation events into a GenericEventListSummary // Grouping only events sent by the same user that sent the `m.room.create` and only until diff --git a/src/components/structures/grouper/MainGrouper.tsx b/src/components/structures/grouper/MainGrouper.tsx index e686f1aa81..6766f0e5b7 100644 --- a/src/components/structures/grouper/MainGrouper.tsx +++ b/src/components/structures/grouper/MainGrouper.tsx @@ -10,14 +10,13 @@ import React, { type ReactNode } from "react"; import { EventType, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import type MessagePanel from "../MessagePanel"; -import type { WrappedEvent } from "../MessagePanel"; +import { SeparatorKind, type WrappedEvent } from "../MessagePanel"; import { BaseGrouper } from "./BaseGrouper"; import { hasText } from "../../../TextForEvent"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import DateSeparator from "../../views/messages/DateSeparator"; import HistoryTile from "../../views/rooms/HistoryTile"; import EventListSummary from "../../views/elements/EventListSummary"; -import { SeparatorKind } from "../../views/messages/TimelineSeparator"; const groupedStateEvents = [ EventType.RoomMember, diff --git a/src/components/views/messages/DateSeparator.tsx b/src/components/views/messages/DateSeparator.tsx index 7d49042533..061cc76204 100644 --- a/src/components/views/messages/DateSeparator.tsx +++ b/src/components/views/messages/DateSeparator.tsx @@ -12,6 +12,7 @@ import { Direction, ConnectionError, MatrixError, HTTPError } from "matrix-js-sd import { logger } from "matrix-js-sdk/src/logger"; import { capitalize } from "lodash"; import { ChevronDownIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { TimelineSeparator } from "@element-hq/web-shared-components"; import { _t, getUserLanguage } from "../../../languageHandler"; import { formatFullDateNoDay, formatFullDateNoTime, getDaysArray } from "../../../DateUtils"; @@ -32,7 +33,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 { @@ -335,6 +335,10 @@ export default class DateSeparator extends React.Component { ); } - return {dateHeaderContent}; + return ( + + {dateHeaderContent} + + ); } } diff --git a/src/components/views/messages/TimelineSeparator.tsx b/src/components/views/messages/TimelineSeparator.tsx deleted file mode 100644 index 4735c8e00c..0000000000 --- a/src/components/views/messages/TimelineSeparator.tsx +++ /dev/null @@ -1,39 +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, { type ReactNode } from "react"; - -interface Props { - label: string; - children?: ReactNode; -} - -export const enum SeparatorKind { - None, - Date, - LateEvent, -} - -/** - * Generic timeline separator component to render within a MessagePanel - * - * @param label the accessible label string describing the separator - * @param children the children to draw within the timeline separator - */ -const TimelineSeparator: React.FC = ({ label, children }) => { - // ARIA treats
    s as separators, here we abuse them slightly so manually treat this entire thing as one - return ( -
    -
    - {children} -
    -
    - ); -}; - -export default TimelineSeparator; 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 716b0e7257..f31612ae13 100644 --- a/test/unit-tests/components/structures/__snapshots__/MessagePanel-test.tsx.snap +++ b/test/unit-tests/components/structures/__snapshots__/MessagePanel-test.tsx.snap @@ -39,8 +39,9 @@ exports[`MessagePanel should handle lots of membership events quickly 1`] = ` >