From 11030ae68ddb64cb451bb71ed8446ce20a10d2cb Mon Sep 17 00:00:00 2001 From: rbondesson Date: Mon, 2 Mar 2026 13:18:51 +0100 Subject: [PATCH] Refactor DateSeparator using MVVM and move to shared-components (#32482) * Refactor DateSeparator using MVVM and move to shared-components * Add a few more stories, tests and screenshots * Use the shared component and viewmodel in element-web * Renaming custom content property an updating snapshots * Fix lint errors and update snapshot after merge * Change lifecycle handling for DateSeparatoreViewModel in components where manual handling is preferrable over wrapper component. * Move context menu from viewmodel to shared components - step 1 * Create a jump to date picker component in shared components * Add tests for coverage and fix layout issues and roving indexes * Make element-web use the new component * Simplify context menu and adjusting tests * The HTMLExport now render shared components and need a I18nContext.Provider * Updating unit tests for context menu * Changed to {translate: _t} to let scripts pick up translations * Fix lint issue and updating screenshots after merge * Update snaps for element web components * Renaming MVVM view components with suffix View. * Fixing problem with input date calendar icon and system dark theme * Changed the rendering of the menu and added a separate button component * Handle input control with useRef in onKeyDown * Updating DateSeparator snapshots on unit tests * Updating layout after compound Menu got a className property * Move files to new subfolder after merge * Updated snapshot after merge * Updating lock file * Updates to styling from PR review * Updates to focus/blur functionality * Fixed tabbing and export documentation to stories * Updated snapshots --------- Co-authored-by: Zack --- apps/web/res/css/_components.pcss | 2 - .../css/views/messages/_DateSeparator.pcss | 31 -- .../css/views/messages/_JumpToDatePicker.pcss | 34 -- apps/web/src/DateUtils.ts | 14 - .../components/structures/MessagePanel.tsx | 21 +- .../structures/grouper/CreationGrouper.tsx | 16 +- .../structures/grouper/MainGrouper.tsx | 16 +- .../dialogs/MessageEditHistoryDialog.tsx | 28 +- .../views/messages/DateSeparator.tsx | 344 ----------------- .../views/messages/JumpToDatePicker.tsx | 64 ---- .../views/rooms/SearchResultTile.tsx | 18 +- apps/web/src/i18n/strings/en_EN.json | 3 - apps/web/src/utils/exportUtils/HtmlExport.tsx | 41 +- .../timeline/DateSeparatorViewModel.tsx | 284 ++++++++++++++ .../__snapshots__/MessagePanel-test.tsx.snap | 4 +- .../MessageEditHistoryDialog-test.tsx.snap | 8 +- .../views/messages/DateSeparator-test.tsx | 322 ---------------- .../views/messages/JumpToDatePicker-test.tsx | 33 -- .../__snapshots__/DateSeparator-test.tsx.snap | 103 ----- .../JumpToDatePicker-test.tsx.snap | 41 -- .../test/unit-tests/utils/DateUtils-test.ts | 10 - .../__snapshots__/HTMLExport-test.ts.snap | 2 +- .../timeline/DateSeparatorViewModel-test.tsx | 351 ++++++++++++++++++ .../default-auto.png | Bin 0 -> 17692 bytes .../has-extra-class-names-auto.png | Bin 0 -> 17692 bytes .../long-localized-label-auto.png | Bin 0 -> 24362 bytes .../with-jump-to-date-picker-auto.png | Bin 0 -> 31913 bytes .../with-jump-to-tooltip-auto.png | Bin 0 -> 19321 bytes .../src/i18n/strings/en_EN.json | 4 + packages/shared-components/src/index.ts | 1 + .../TimelineSeparator/TimelineSeparator.tsx | 12 +- .../DateSeparatorView/DateSeparatorButton.tsx | 61 +++ .../DateSeparatorContextMenuView.module.css | 20 + .../DateSeparatorContextMenuView.test.tsx | 182 +++++++++ .../DateSeparatorContextMenuView.tsx | 95 +++++ .../DateSeparatorDatePickerView.module.css | 47 +++ .../DateSeparatorDatePickerView.tsx | 132 +++++++ .../DateSeparatorView.module.css | 27 ++ .../DateSeparatorView.stories.tsx | 91 +++++ .../DateSeparatorView.test.tsx | 76 ++++ .../DateSeparatorView/DateSeparatorView.tsx | 114 ++++++ .../DateSeparatorView.test.tsx.snap | 138 +++++++ .../src/timeline/DateSeparatorView/index.ts | 8 + .../src/utils/DateUtils.test.ts | 35 ++ .../shared-components/src/utils/DateUtils.ts | 14 + .../shared-components/src/utils/humanize.ts | 4 + pnpm-lock.yaml | 14 +- pnpm-workspace.yaml | 2 +- 48 files changed, 1819 insertions(+), 1048 deletions(-) delete mode 100644 apps/web/res/css/views/messages/_DateSeparator.pcss delete mode 100644 apps/web/res/css/views/messages/_JumpToDatePicker.pcss delete mode 100644 apps/web/src/components/views/messages/DateSeparator.tsx delete mode 100644 apps/web/src/components/views/messages/JumpToDatePicker.tsx create mode 100644 apps/web/src/viewmodels/timeline/DateSeparatorViewModel.tsx delete mode 100644 apps/web/test/unit-tests/components/views/messages/DateSeparator-test.tsx delete mode 100644 apps/web/test/unit-tests/components/views/messages/JumpToDatePicker-test.tsx delete mode 100644 apps/web/test/unit-tests/components/views/messages/__snapshots__/DateSeparator-test.tsx.snap delete mode 100644 apps/web/test/unit-tests/components/views/messages/__snapshots__/JumpToDatePicker-test.tsx.snap create mode 100644 apps/web/test/viewmodels/timeline/DateSeparatorViewModel-test.tsx create mode 100644 packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/default-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/has-extra-class-names-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/long-localized-label-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/with-jump-to-date-picker-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/with-jump-to-tooltip-auto.png create mode 100644 packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorButton.tsx create mode 100644 packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorContextMenuView.module.css create mode 100644 packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorContextMenuView.test.tsx create mode 100644 packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorContextMenuView.tsx create mode 100644 packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorDatePickerView.module.css create mode 100644 packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorDatePickerView.tsx create mode 100644 packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorView.module.css create mode 100644 packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorView.stories.tsx create mode 100644 packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorView.test.tsx create mode 100644 packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorView.tsx create mode 100644 packages/shared-components/src/timeline/DateSeparatorView/__snapshots__/DateSeparatorView.test.tsx.snap create mode 100644 packages/shared-components/src/timeline/DateSeparatorView/index.ts create mode 100644 packages/shared-components/src/utils/DateUtils.test.ts diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index c8ca64d33b..a788baa032 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -219,11 +219,9 @@ @import "./views/location/_LocationPicker.pcss"; @import "./views/messages/_CallEvent.pcss"; @import "./views/messages/_CreateEvent.pcss"; -@import "./views/messages/_DateSeparator.pcss"; @import "./views/messages/_DisambiguatedProfile.pcss"; @import "./views/messages/_HiddenBody.pcss"; @import "./views/messages/_HiddenMediaPlaceholder.pcss"; -@import "./views/messages/_JumpToDatePicker.pcss"; @import "./views/messages/_LegacyCallEvent.pcss"; @import "./views/messages/_MEmoteBody.pcss"; @import "./views/messages/_MFileBody.pcss"; diff --git a/apps/web/res/css/views/messages/_DateSeparator.pcss b/apps/web/res/css/views/messages/_DateSeparator.pcss deleted file mode 100644 index 50d56b085a..0000000000 --- a/apps/web/res/css/views/messages/_DateSeparator.pcss +++ /dev/null @@ -1,31 +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_DateSeparator_dateContent { - padding: 0 25px; -} - -.mx_DateSeparator_dateHeading { - flex: 0 0 auto; - margin: 0; - font-size: inherit; - font-weight: inherit; - color: inherit; - text-transform: capitalize; -} - -.mx_DateSeparator_jumpToDateMenu { - display: flex; -} - -.mx_DateSeparator_chevron { - align-self: center; - width: 16px; - height: 16px; - color: var(--cpd-color-icon-secondary); -} diff --git a/apps/web/res/css/views/messages/_JumpToDatePicker.pcss b/apps/web/res/css/views/messages/_JumpToDatePicker.pcss deleted file mode 100644 index b4b0244be5..0000000000 --- a/apps/web/res/css/views/messages/_JumpToDatePicker.pcss +++ /dev/null @@ -1,34 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 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. -*/ - -.mx_JumpToDatePicker_form { - display: flex; - /* This matches the default padding of IconizedContextMenuOption */ - /* (see context_menus/_IconizedContextMenu.pcss) */ - padding-top: 12px; - padding-bottom: 12px; -} - -.mx_JumpToDatePicker_label { - align-self: center; - font-size: $font-15px; -} - -.mx_JumpToDatePicker_datePicker { - margin: 0; - margin-left: 8px; - - &, - & > input { - border-radius: 8px; - } -} - -.mx_JumpToDatePicker_submitButton { - margin-left: 8px; -} diff --git a/apps/web/src/DateUtils.ts b/apps/web/src/DateUtils.ts index cd4a440372..e975fe834f 100644 --- a/apps/web/src/DateUtils.ts +++ b/apps/web/src/DateUtils.ts @@ -127,20 +127,6 @@ export function formatFullDate(date: Date, showTwelveHour = false, showSeconds = }).format(date); } -/** - * Formats dates to be compatible with attributes of a ``. Dates - * should be formatted like "2020-06-23" (formatted according to ISO8601). - * - * @param date The date to format. - * @returns The date string in ISO8601 format ready to be used with an `` - */ -export function formatDateForInput(date: Date): string { - const year = `${date.getFullYear()}`.padStart(4, "0"); - const month = `${date.getMonth() + 1}`.padStart(2, "0"); - const day = `${date.getDate()}`.padStart(2, "0"); - return `${year}-${month}-${day}`; -} - /** * Formats a given date to a time string including seconds. * Will use the browser's default time zone. diff --git a/apps/web/src/components/structures/MessagePanel.tsx b/apps/web/src/components/structures/MessagePanel.tsx index 40b69130a7..207b91b0b6 100644 --- a/apps/web/src/components/structures/MessagePanel.tsx +++ b/apps/web/src/components/structures/MessagePanel.tsx @@ -18,7 +18,11 @@ 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 { + DateSeparatorView, + TimelineSeparator, + useCreateAutoDisposedViewModel, +} from "@element-hq/web-shared-components"; import shouldHideEvent from "../../shouldHideEvent"; import { formatDate, wantsDateSeparator } from "../../DateUtils"; @@ -37,7 +41,6 @@ import defaultDispatcher from "../../dispatcher/dispatcher"; import type LegacyCallEventGrouper from "./LegacyCallEventGrouper"; import WhoIsTypingTile from "../views/rooms/WhoIsTypingTile"; import ScrollPanel, { type IScrollState } from "./ScrollPanel"; -import DateSeparator from "../views/messages/DateSeparator"; import ErrorBoundary from "../views/elements/ErrorBoundary"; import Spinner from "../views/elements/Spinner"; import { type RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; @@ -53,10 +56,19 @@ import { MainGrouper } from "./grouper/MainGrouper"; import { CreationGrouper } from "./grouper/CreationGrouper"; import { _t } from "../../languageHandler"; import { getLateEventInfo } from "./grouper/LateEventGrouper"; +import { DateSeparatorViewModel } from "../../viewmodels/timeline/DateSeparatorViewModel"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; +/** + * Creates and auto-disposes the DateSeparatorViewModel for message panel rendering. + */ +function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): JSX.Element { + const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts })); + return ; +} + /** * Indicates which separator (if any) should be rendered between timeline events. */ @@ -757,9 +769,10 @@ export default class MessagePanel extends React.Component { const wantsSeparator = this.wantsSeparator(prevEvent, mxEv); if (!isGrouped && this.props.room) { if (wantsSeparator === SeparatorKind.Date) { + const separatorRoomId = this.props.room.roomId; ret.push( -
  • - +
  • +
  • , ); } else if (wantsSeparator === SeparatorKind.LateEvent) { diff --git a/apps/web/src/components/structures/grouper/CreationGrouper.tsx b/apps/web/src/components/structures/grouper/CreationGrouper.tsx index 80c9bfedcd..eafd7ade1d 100644 --- a/apps/web/src/components/structures/grouper/CreationGrouper.tsx +++ b/apps/web/src/components/structures/grouper/CreationGrouper.tsx @@ -9,20 +9,29 @@ Please see LICENSE files in the repository root for full details. import React, { type ReactNode } from "react"; import { EventType, M_BEACON_INFO, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; +import { DateSeparatorView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; import { BaseGrouper } from "./BaseGrouper"; 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 { DateSeparatorViewModel } from "../../../viewmodels/timeline/DateSeparatorViewModel"; // 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 // the first non-state event, beacon_info event or membership event which is not regarding the sender of the `m.room.create` event +/** + * Creates and auto-disposes the DateSeparatorViewModel for creation-group rendering. + */ +function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): ReactNode { + const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts })); + return ; +} + export class CreationGrouper extends BaseGrouper { public static canStartGroup = function (_panel: MessagePanel, { event }: WrappedEvent): boolean { return event.getType() === EventType.RoomCreate; @@ -86,10 +95,11 @@ export class CreationGrouper extends BaseGrouper { const lastShownEvent = this.lastShownEvent; if (panel.wantsSeparator(this.prevEvent, createEvent.event) === SeparatorKind.Date) { + const separatorRoomId = createEvent.event.getRoomId()!; const ts = createEvent.event.getTs(); ret.push( -
  • - +
  • +
  • , ); } diff --git a/apps/web/src/components/structures/grouper/MainGrouper.tsx b/apps/web/src/components/structures/grouper/MainGrouper.tsx index 6766f0e5b7..bf9a178fc8 100644 --- a/apps/web/src/components/structures/grouper/MainGrouper.tsx +++ b/apps/web/src/components/structures/grouper/MainGrouper.tsx @@ -8,15 +8,16 @@ Please see LICENSE files in the repository root for full details. import React, { type ReactNode } from "react"; import { EventType, type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { DateSeparatorView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; import type MessagePanel 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 { DateSeparatorViewModel } from "../../../viewmodels/timeline/DateSeparatorViewModel"; const groupedStateEvents = [ EventType.RoomMember, @@ -25,6 +26,14 @@ const groupedStateEvents = [ EventType.RoomPinnedEvents, ]; +/** + * Creates and auto-disposes the DateSeparatorViewModel for grouped timeline rendering. + */ +function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): ReactNode { + const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts })); + return ; +} + // Wrap consecutive grouped events in a ListSummary export class MainGrouper extends BaseGrouper { public static canStartGroup = function (panel: MessagePanel, { event: ev, shouldShow }: WrappedEvent): boolean { @@ -113,10 +122,11 @@ export class MainGrouper extends BaseGrouper { const ret: ReactNode[] = []; if (panel.wantsSeparator(this.prevEvent, this.events[0].event) === SeparatorKind.Date) { + const separatorRoomId = this.events[0].event.getRoomId()!; const ts = this.events[0].event.getTs(); ret.push( -
  • - +
  • +
  • , ); } diff --git a/apps/web/src/components/views/dialogs/MessageEditHistoryDialog.tsx b/apps/web/src/components/views/dialogs/MessageEditHistoryDialog.tsx index 72b11bc4e1..599d2c32f0 100644 --- a/apps/web/src/components/views/dialogs/MessageEditHistoryDialog.tsx +++ b/apps/web/src/components/views/dialogs/MessageEditHistoryDialog.tsx @@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX } from "react"; import { type MatrixEvent, EventType, RelationType, type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; +import { DateSeparatorView } from "@element-hq/web-shared-components"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { _t } from "../../../languageHandler"; @@ -18,7 +19,7 @@ import BaseDialog from "./BaseDialog"; import ScrollPanel from "../../structures/ScrollPanel"; import Spinner from "../elements/Spinner"; import EditHistoryMessage from "../messages/EditHistoryMessage"; -import DateSeparator from "../messages/DateSeparator"; +import { DateSeparatorViewModel } from "../../../viewmodels/timeline/DateSeparatorViewModel"; interface IProps { mxEvent: MatrixEvent; @@ -35,6 +36,8 @@ interface IState { } export default class MessageEditHistoryDialog extends React.PureComponent { + private dateSeparatorVms = new Map(); + public constructor(props: IProps) { super(props); this.state = { @@ -47,6 +50,16 @@ export default class MessageEditHistoryDialog extends React.PureComponent => { if (backwards || (!this.state.nextBatch && !this.state.isLoading)) { // bail out on backwards as we only paginate in one direction @@ -108,6 +121,13 @@ export default class MessageEditHistoryDialog extends React.PureComponent { if (!lastEvent || wantsDateSeparator(lastEvent.getDate() || undefined, e.getDate() || undefined)) { + const separatorRoomId = e.getRoomId()!; + const separatorTs = e.getTs(); nodes.push( -
  • - +
  • +
  • , ); } diff --git a/apps/web/src/components/views/messages/DateSeparator.tsx b/apps/web/src/components/views/messages/DateSeparator.tsx deleted file mode 100644 index 061cc76204..0000000000 --- a/apps/web/src/components/views/messages/DateSeparator.tsx +++ /dev/null @@ -1,344 +0,0 @@ -/* -Copyright 2024 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, { 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 { 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"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import dispatcher from "../../../dispatcher/dispatcher"; -import { Action } from "../../../dispatcher/actions"; -import SettingsStore from "../../../settings/SettingsStore"; -import { UIFeature } from "../../../settings/UIFeature"; -import Modal from "../../../Modal"; -import ErrorDialog from "../dialogs/ErrorDialog"; -import BugReportDialog from "../dialogs/BugReportDialog"; -import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; -import { contextMenuBelow } from "../rooms/RoomTile"; -import { ContextMenuTooltipButton } from "../../structures/ContextMenu"; -import IconizedContextMenu, { - IconizedContextMenuOption, - IconizedContextMenuOptionList, -} from "../context_menus/IconizedContextMenu"; -import JumpToDatePicker from "./JumpToDatePicker"; -import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import RoomContext from "../../../contexts/RoomContext"; - -interface IProps { - roomId: string; - ts: number; - forExport?: boolean; -} - -interface IState { - contextMenuPosition?: DOMRect; - jumpToDateEnabled: boolean; -} - -/** - * Timeline separator component to render within a MessagePanel bearing the date of the ts given - * - * Has additional jump to date functionality when labs flag is enabled - */ -export default class DateSeparator extends React.Component { - public static contextType = RoomContext; - declare public context: React.ContextType; - private settingWatcherRef?: string; - - public constructor(props: IProps) { - super(props); - this.state = { - jumpToDateEnabled: SettingsStore.getValue("feature_jump_to_date"), - }; - } - - public componentDidMount(): void { - // We're using a watcher so the date headers in the timeline are updated - // when the lab setting is toggled. - this.settingWatcherRef = SettingsStore.watchSetting( - "feature_jump_to_date", - null, - (settingName, roomId, level, newValAtLevel, newVal) => { - this.setState({ jumpToDateEnabled: newVal }); - }, - ); - } - - public componentWillUnmount(): void { - SettingsStore.unwatchSetting(this.settingWatcherRef); - } - - private onContextMenuOpenClick = (e: ButtonEvent): void => { - e.preventDefault(); - e.stopPropagation(); - const target = e.target as HTMLButtonElement; - this.setState({ contextMenuPosition: target.getBoundingClientRect() }); - }; - - private onContextMenuCloseClick = (): void => { - this.closeMenu(); - }; - - private closeMenu = (): void => { - this.setState({ - contextMenuPosition: undefined, - }); - }; - - private get relativeTimeFormat(): Intl.RelativeTimeFormat { - return new Intl.RelativeTimeFormat(getUserLanguage(), { style: "long", numeric: "auto" }); - } - - private getLabel(): string { - try { - const date = new Date(this.props.ts); - const disableRelativeTimestamps = !SettingsStore.getValue(UIFeature.TimelineEnableRelativeDates); - - // During the time the archive is being viewed, a specific day might not make sense, so we return the full date - if (this.props.forExport || disableRelativeTimestamps) return formatFullDateNoTime(date); - - const today = new Date(); - const yesterday = new Date(); - const days = getDaysArray("long"); - 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 * 24 * 60 * 60 * 1000) { - return days[date.getDay()]; // Sunday-Saturday - } else { - return formatFullDateNoTime(date); - } - } catch { - return _t("common|message_timestamp_invalid"); - } - } - - private pickDate = async (inputTimestamp: number | string | Date): Promise => { - const unixTimestamp = new Date(inputTimestamp).getTime(); - const roomIdForJumpRequest = this.props.roomId; - - try { - const cli = MatrixClientPeg.safeGet(); - const { event_id: eventId, origin_server_ts: originServerTs } = await cli.timestampToEvent( - roomIdForJumpRequest, - unixTimestamp, - Direction.Forward, - ); - logger.log( - `/timestamp_to_event: ` + - `found ${eventId} (${originServerTs}) for timestamp=${unixTimestamp} (looking forward)`, - ); - - // Only try to navigate to the room if the user is still viewing the same - // room. We don't want to jump someone back to a room after a slow request - // if they've already navigated away to another room. - const currentRoomId = this.context.roomViewStore.getRoomId(); - if (currentRoomId === roomIdForJumpRequest) { - dispatcher.dispatch({ - action: Action.ViewRoom, - event_id: eventId, - highlighted: true, - room_id: roomIdForJumpRequest, - metricsTrigger: undefined, // room doesn't change - }); - } else { - logger.debug( - `No longer navigating to date in room (jump to date) because the user already switched ` + - `to another room: currentRoomId=${currentRoomId}, roomIdForJumpRequest=${roomIdForJumpRequest}`, - ); - } - } catch (err) { - logger.error( - `Error occured while trying to find event in ${roomIdForJumpRequest} ` + - `at timestamp=${unixTimestamp}:`, - err, - ); - - // Only display an error if the user is still viewing the same room. We - // don't want to worry someone about an error in a room they no longer care - // about after a slow request if they've already navigated away to another - // room. - const currentRoomId = this.context.roomViewStore.getRoomId(); - if (currentRoomId === roomIdForJumpRequest) { - let friendlyErrorMessage = "An error occured while trying to find and jump to the given date."; - let submitDebugLogsContent: JSX.Element = <>; - if (err instanceof ConnectionError) { - friendlyErrorMessage = _t("room|error_jump_to_date_connection"); - } else if (err instanceof MatrixError) { - if (err?.errcode === "M_NOT_FOUND") { - friendlyErrorMessage = _t("room|error_jump_to_date_not_found", { - dateString: formatFullDateNoDay(new Date(unixTimestamp)), - }); - } else { - friendlyErrorMessage = _t("room|error_jump_to_date", { - statusCode: err?.httpStatus || _t("room|unknown_status_code_for_timeline_jump"), - errorCode: err?.errcode || _t("common|unavailable"), - }); - } - } else if (err instanceof HTTPError) { - friendlyErrorMessage = err.message; - } else { - // We only give the option to submit logs for actual errors, not network problems. - submitDebugLogsContent = ( -

    - {_t( - "room|error_jump_to_date_send_logs_prompt", - {}, - { - debugLogsLink: (sub) => ( - ` which we - // can't nest within a `

    ` here so update - // this to a be a inline anchor element. - element="a" - kind="link" - onClick={() => this.onBugReport(err instanceof Error ? err : undefined)} - data-testid="jump-to-date-error-submit-debug-logs-button" - > - {sub} - - ), - }, - )} -

    - ); - } - - Modal.createDialog(ErrorDialog, { - title: _t("room|error_jump_to_date_title"), - description: ( -
    -

    {friendlyErrorMessage}

    - {submitDebugLogsContent} -
    - {_t("room|error_jump_to_date_details")} -

    {String(err)}

    -
    -
    - ), - }); - } - } - }; - - private onBugReport = (err?: Error): void => { - Modal.createDialog(BugReportDialog, { - error: err, - initialText: "Error occured while using jump to date #jump-to-date", - }); - }; - - private onLastWeekClicked = (): void => { - const date = new Date(); - date.setDate(date.getDate() - 7); - this.pickDate(date); - this.closeMenu(); - }; - - private onLastMonthClicked = (): void => { - const date = new Date(); - // Month numbers are 0 - 11 and `setMonth` handles the negative rollover - date.setMonth(date.getMonth() - 1, 1); - this.pickDate(date); - this.closeMenu(); - }; - - private onTheBeginningClicked = (): void => { - const date = new Date(0); - this.pickDate(date); - this.closeMenu(); - }; - - private onDatePicked = (dateString: string): void => { - this.pickDate(dateString); - this.closeMenu(); - }; - - private renderJumpToDateMenu(): React.ReactElement { - let contextMenu: JSX.Element | undefined; - if (this.state.contextMenuPosition) { - const relativeTimeFormat = this.relativeTimeFormat; - contextMenu = ( - - - - - - - - - - - - ); - } - - return ( - - - - {contextMenu} - - ); - } - - public render(): React.ReactNode { - const label = this.getLabel(); - - let dateHeaderContent: JSX.Element; - if (this.state.jumpToDateEnabled && !this.props.forExport) { - dateHeaderContent = this.renderJumpToDateMenu(); - } else { - dateHeaderContent = ( -
    - -
    - ); - } - - return ( - - {dateHeaderContent} - - ); - } -} diff --git a/apps/web/src/components/views/messages/JumpToDatePicker.tsx b/apps/web/src/components/views/messages/JumpToDatePicker.tsx deleted file mode 100644 index 23a35dd164..0000000000 --- a/apps/web/src/components/views/messages/JumpToDatePicker.tsx +++ /dev/null @@ -1,64 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2022 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, { useState, type FormEvent } from "react"; - -import { _t } from "../../../languageHandler"; -import Field from "../elements/Field"; -import { RovingAccessibleButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex"; -import { formatDateForInput } from "../../../DateUtils"; - -interface IProps { - ts: number; - onDatePicked: (dateString: string) => void; -} - -const JumpToDatePicker: React.FC = ({ ts, onDatePicked }: IProps) => { - const date = new Date(ts); - const dateInputDefaultValue = formatDateForInput(date); - - const [dateValue, setDateValue] = useState(dateInputDefaultValue); - const [onFocus, isActive, refCallback] = useRovingTabIndex(); - - const onDateValueInput = (ev: React.InputEvent): void => setDateValue(ev.currentTarget.value); - const onJumpToDateSubmit = (ev: FormEvent): void => { - ev.preventDefault(); - onDatePicked(dateValue); - }; - - return ( -
    - {_t("room|jump_to_date")} - - - {_t("action|go")} - - - ); -}; - -export default JumpToDatePicker; diff --git a/apps/web/src/components/views/rooms/SearchResultTile.tsx b/apps/web/src/components/views/rooms/SearchResultTile.tsx index e5a77df60e..1b45dbf024 100644 --- a/apps/web/src/components/views/rooms/SearchResultTile.tsx +++ b/apps/web/src/components/views/rooms/SearchResultTile.tsx @@ -7,13 +7,13 @@ 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. */ -import React from "react"; +import React, { type JSX } from "react"; import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { DateSeparatorView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import SettingsStore from "../../../settings/SettingsStore"; import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; -import DateSeparator from "../messages/DateSeparator"; import EventTile from "./EventTile"; import { shouldFormContinuation } from "../../structures/MessagePanel"; import { wantsDateSeparator } from "../../../DateUtils"; @@ -21,6 +21,7 @@ import type LegacyCallEventGrouper from "../../structures/LegacyCallEventGrouper import { buildLegacyCallEventGroupers } from "../../structures/LegacyCallEventGrouper"; import { haveRendererForEvent } from "../../../events/EventTileFactory"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { DateSeparatorViewModel } from "../../../viewmodels/timeline/DateSeparatorViewModel"; interface IProps { // a list of strings to be highlighted in the results @@ -34,6 +35,14 @@ interface IProps { permalinkCreator?: RoomPermalinkCreator; } +/** + * Creates and auto-disposes the DateSeparatorViewModel for search result rendering. + */ +function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): JSX.Element { + const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts })); + return ; +} + export default class SearchResultTile extends React.Component { public static contextType = RoomContext; declare public context: React.ContextType; @@ -57,7 +66,10 @@ export default class SearchResultTile extends React.Component { const eventId = resultEvent.getId(); const ts1 = resultEvent.getTs(); - const ret = []; + const separatorRoomId = resultEvent.getRoomId()!; + const ret = [ + , + ]; const layout = SettingsStore.getValue("layout"); const isTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps"); const alwaysShowTimestamps = SettingsStore.getValue("alwaysShowTimestamps"); diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 6350993c56..9453131cfa 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -2064,9 +2064,6 @@ "joining_space": "Joining space…", "jump_read_marker": "Jump to first unread message.", "jump_to_bottom_button": "Scroll to most recent messages", - "jump_to_date": "Jump to date", - "jump_to_date_beginning": "The beginning of the room", - "jump_to_date_prompt": "Pick a date to jump to", "kick_reason": "Reason: %(reason)s", "kicked_by": "You were removed by %(memberName)s", "kicked_from_room_by": "You were removed from %(roomName)s by %(memberName)s", diff --git a/apps/web/src/utils/exportUtils/HtmlExport.tsx b/apps/web/src/utils/exportUtils/HtmlExport.tsx index cb5c3629c8..4ae8e231d4 100644 --- a/apps/web/src/utils/exportUtils/HtmlExport.tsx +++ b/apps/web/src/utils/exportUtils/HtmlExport.tsx @@ -13,7 +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 { DateSeparatorView, I18nContext } from "@element-hq/web-shared-components"; import Exporter from "./Exporter"; import { mediaFromMxc } from "../../customisations/Media"; @@ -24,7 +24,6 @@ import { RoomPermalinkCreator } from "../permalinks/Permalinks"; import { _t } from "../../languageHandler"; import * as Avatar from "../../Avatar"; import EventTile from "../../components/views/rooms/EventTile"; -import DateSeparator from "../../components/views/messages/DateSeparator"; import BaseAvatar from "../../components/views/avatars/BaseAvatar"; import { type ExportType, type IExportOptions } from "./exportUtils"; import MatrixClientContext from "../../contexts/MatrixClientContext"; @@ -32,6 +31,7 @@ import getExportCSS from "./exportCSS"; import { textForEvent } from "../../TextForEvent"; import { haveRendererForEvent } from "../../events/EventTileFactory"; import { SDKContext, SdkContextClass } from "../../contexts/SDKContext.ts"; +import { DateSeparatorViewModel } from "../../viewmodels/timeline/DateSeparatorViewModel"; import exportJS from "!!raw-loader!./exportJS"; @@ -56,6 +56,12 @@ export default class HTMLExporter extends Exporter { : _t("export_chat|media_omitted_file_size"); } + private renderToStaticMarkupWithProviders(element: JSX.Element): string { + return renderToStaticMarkup( + {element}, + ); + } + protected async getRoomAvatar(): Promise { let blob: Blob | undefined = undefined; const avatarUrl = Avatar.avatarUrlForRoom(this.room, 32, 32, "crop"); @@ -73,7 +79,7 @@ export default class HTMLExporter extends Exporter { const avatar = ( ); - return renderToStaticMarkup(avatar); + return this.renderToStaticMarkupWithProviders(avatar); } protected async wrapHTML(content: string, currentPage: number, nbPages: number): Promise { @@ -93,7 +99,7 @@ export default class HTMLExporter extends Exporter { const safeExporter = escapeHtml(exporter); const safeRoomName = escapeHtml(this.room.name); const safeTopic = escapeHtml(topic); - const safeExportedText = renderToStaticMarkup( + const safeExportedText = this.renderToStaticMarkupWithProviders(

    {_t( "export_chat|export_info", @@ -123,7 +129,7 @@ export default class HTMLExporter extends Exporter { ); const safeTopicText = topic ? _t("export_chat|topic", { topic: safeTopic }) : ""; - const previousMessagesLink = renderToStaticMarkup( + const previousMessagesLink = this.renderToStaticMarkupWithProviders( currentPage !== 0 ? (

    @@ -135,7 +141,7 @@ export default class HTMLExporter extends Exporter { ), ); - const nextMessagesLink = renderToStaticMarkup( + const nextMessagesLink = this.renderToStaticMarkupWithProviders( currentPage < nbPages - 1 ? (
    @@ -252,12 +258,21 @@ export default class HTMLExporter extends Exporter { protected getDateSeparator(event: MatrixEvent): string { const ts = event.getTs(); - const dateSeparator = ( -
  • - -
  • - ); - return renderToStaticMarkup(dateSeparator); + const dateSeparatorViewModel = new DateSeparatorViewModel({ + roomId: event.getRoomId()!, + ts, + forExport: true, + }); + try { + const dateSeparator = ( +
  • + +
  • + ); + return this.renderToStaticMarkupWithProviders(dateSeparator); + } finally { + dateSeparatorViewModel.dispose(); + } } protected needsDateSeparator(event: MatrixEvent, prevEvent: MatrixEvent | null): boolean { @@ -326,7 +341,7 @@ export default class HTMLExporter extends Exporter { eventTileMarkup = tempElement.innerHTML; tempRoot.unmount(); } else { - eventTileMarkup = renderToStaticMarkup(EventTile); + eventTileMarkup = this.renderToStaticMarkupWithProviders(EventTile); } if (filePath) { diff --git a/apps/web/src/viewmodels/timeline/DateSeparatorViewModel.tsx b/apps/web/src/viewmodels/timeline/DateSeparatorViewModel.tsx new file mode 100644 index 0000000000..2221d795a1 --- /dev/null +++ b/apps/web/src/viewmodels/timeline/DateSeparatorViewModel.tsx @@ -0,0 +1,284 @@ +/* + * 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 { + formatDateForInput, + BaseViewModel, + type DateSeparatorViewSnapshot as DateSeparatorViewSnapshotInterface, + type DateSeparatorViewModel as DateSeparatorViewModelInterface, +} from "@element-hq/web-shared-components"; +import React from "react"; +import { Direction, ConnectionError, HTTPError, MatrixError } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { formatFullDateNoDay, formatFullDateNoTime, getDaysArray } from "../../DateUtils"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import dispatcher from "../../dispatcher/dispatcher"; +import { Action } from "../../dispatcher/actions"; +import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; +import { _t, getUserLanguage } from "../../languageHandler"; +import Modal from "../../Modal"; +import SettingsStore from "../../settings/SettingsStore"; +import { UIFeature } from "../../settings/UIFeature"; +import ErrorDialog from "../../components/views/dialogs/ErrorDialog"; +import BugReportDialog from "../../components/views/dialogs/BugReportDialog"; +import AccessibleButton from "../../components/views/elements/AccessibleButton"; +import { SdkContextClass } from "../../contexts/SDKContext"; + +export interface DateSeparatorViewModelProps { + /** + * Room ID used for jump-to-date navigation and room-switch guards. + */ + roomId: string; + /** + * Timestamp used to compute the date separator label and initial picker value. + */ + ts: number; + /** + * Export mode disables relative date labels and jump-to-date menu UI. + */ + forExport?: boolean; +} + +/** + * ViewModel for the date separator, providing the current state of the component. + */ +export class DateSeparatorViewModel + extends BaseViewModel + implements DateSeparatorViewModelInterface +{ + /** + * Cached setting for UIFeature.TimelineEnableRelativeDates. + * Updated via SettingsStore watcher to keep labels in sync at runtime. + */ + private relativeDatesEnabled: boolean; + /** + * Cached setting for feature_jump_to_date. + * Controls whether the jump-to-date menu is exposed in the snapshot. + */ + private jumpToDateEnabled: boolean; + + public constructor(props: DateSeparatorViewModelProps) { + const relativeDatesEnabled = SettingsStore.getValue(UIFeature.TimelineEnableRelativeDates); + const jumpToDateEnabled = SettingsStore.getValue("feature_jump_to_date"); + + super(props, { + label: DateSeparatorViewModel.computeLabel(props, relativeDatesEnabled), + className: "mx_TimelineSeparator", + }); + + this.relativeDatesEnabled = relativeDatesEnabled; + this.jumpToDateEnabled = jumpToDateEnabled; + this.updateSnapshot(); + + // Keep label behaviour in sync with runtime setting updates. + const jumpToDateWatcherRef = SettingsStore.watchSetting( + "feature_jump_to_date", + null, + (_settingName, _roomId, _level, _newValAtLevel, newVal) => { + this.jumpToDateEnabled = newVal; + this.updateSnapshot(); + }, + ); + this.disposables.track(() => SettingsStore.unwatchSetting(jumpToDateWatcherRef)); + + const relativeDatesWatcherRef = SettingsStore.watchSetting( + UIFeature.TimelineEnableRelativeDates, + null, + (_settingName, _roomId, _level, _newValAtLevel, newVal) => { + this.relativeDatesEnabled = newVal; + this.updateSnapshot(); + }, + ); + this.disposables.track(() => SettingsStore.unwatchSetting(relativeDatesWatcherRef)); + } + + private computeSnapshot(): DateSeparatorViewSnapshotInterface { + const label = DateSeparatorViewModel.computeLabel(this.props, this.relativeDatesEnabled); + return { + label, + className: "mx_TimelineSeparator", + jumpToEnabled: this.jumpToDateEnabled && !this.props.forExport, + jumpFromDate: formatDateForInput(new Date(this.props.ts)), + }; + } + + private updateSnapshot(): void { + this.snapshot.set(this.computeSnapshot()); + } + + private static get relativeTimeFormat(): Intl.RelativeTimeFormat { + return new Intl.RelativeTimeFormat(getUserLanguage(), { style: "long", numeric: "auto" }); + } + + private static computeLabel(props: DateSeparatorViewModelProps, relativeDatesEnabled: boolean): string { + try { + const date = new Date(props.ts); + + // During export, relative dates are ambiguous and should not be used. + if (props.forExport || !relativeDatesEnabled) return formatFullDateNoTime(date); + + const today = new Date(); + const yesterday = new Date(); + const days = getDaysArray("long"); + yesterday.setDate(today.getDate() - 1); + + if (date.toDateString() === today.toDateString()) { + return this.relativeTimeFormat.format(0, "day"); + } else if (date.toDateString() === yesterday.toDateString()) { + return this.relativeTimeFormat.format(-1, "day"); + } else if (today.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) { + return days[date.getDay()]; + } else { + return formatFullDateNoTime(date); + } + } catch { + return _t("common|message_timestamp_invalid"); + } + } + + public pickDate = async (inputTimestamp: number | string | Date): Promise => { + const unixTimestamp = new Date(inputTimestamp).getTime(); + const roomIdForJumpRequest = this.props.roomId; + + try { + const cli = MatrixClientPeg.safeGet(); + const { event_id: eventId, origin_server_ts: originServerTs } = await cli.timestampToEvent( + roomIdForJumpRequest, + unixTimestamp, + Direction.Forward, + ); + logger.log( + `/timestamp_to_event: ` + + `found ${eventId} (${originServerTs}) for timestamp=${unixTimestamp} (looking forward)`, + ); + + // Only try to navigate to the room if the user is still viewing the same + // room. We don't want to jump someone back to a room after a slow request + // if they've already navigated away to another room. + const currentRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); + if (currentRoomId === roomIdForJumpRequest) { + dispatcher.dispatch({ + action: Action.ViewRoom, + event_id: eventId, + highlighted: true, + room_id: roomIdForJumpRequest, + metricsTrigger: undefined, // room doesn't change + }); + } else { + logger.debug( + `No longer navigating to date in room (jump to date) because the user already switched ` + + `to another room: currentRoomId=${currentRoomId}, roomIdForJumpRequest=${roomIdForJumpRequest}`, + ); + } + } catch (err) { + logger.error( + `Error occured while trying to find event in ${roomIdForJumpRequest} ` + + `at timestamp=${unixTimestamp}:`, + err, + ); + + // Only display an error if the user is still viewing the same room. We + // don't want to worry someone about an error in a room they no longer care + // about after a slow request if they've already navigated away to another + // room. + const currentRoomId = SdkContextClass.instance.roomViewStore.getRoomId(); + if (currentRoomId === roomIdForJumpRequest) { + let friendlyErrorMessage = "An error occured while trying to find and jump to the given date."; + let submitDebugLogsContent: React.ReactElement = <>; + + if (err instanceof ConnectionError) { + friendlyErrorMessage = _t("room|error_jump_to_date_connection"); + } else if (err instanceof MatrixError) { + if (err?.errcode === "M_NOT_FOUND") { + friendlyErrorMessage = _t("room|error_jump_to_date_not_found", { + dateString: formatFullDateNoDay(new Date(unixTimestamp)), + }); + } else { + friendlyErrorMessage = _t("room|error_jump_to_date", { + statusCode: err?.httpStatus || _t("room|unknown_status_code_for_timeline_jump"), + errorCode: err?.errcode || _t("common|unavailable"), + }); + } + } else if (err instanceof HTTPError) { + friendlyErrorMessage = err.message; + } else { + // We only give the option to submit logs for actual errors, not network problems. + submitDebugLogsContent = ( +

    + {_t( + "room|error_jump_to_date_send_logs_prompt", + {}, + { + debugLogsLink: (sub) => ( + // This is by default a `

    ` which we + // can't nest within a `

    ` here so update + // this to a be a inline anchor element. + this.onBugReport(err instanceof Error ? err : undefined)} + data-testid="jump-to-date-error-submit-debug-logs-button" + > + {sub} + + ), + }, + )} +

    + ); + } + + Modal.createDialog(ErrorDialog, { + title: _t("room|error_jump_to_date_title"), + description: ( +
    +

    {friendlyErrorMessage}

    + {submitDebugLogsContent} +
    + {_t("room|error_jump_to_date_details")} +

    {String(err)}

    +
    +
    + ), + }); + } + } + }; + + public onBugReport = (err?: Error): void => { + Modal.createDialog(BugReportDialog, { + error: err, + initialText: "Error occured while using jump to date #jump-to-date", + }); + }; + + public onLastWeekPicked = (): Promise => { + const date = new Date(); + date.setDate(date.getDate() - 7); + void this.pickDate(date); + return Promise.resolve(); + }; + + public onLastMonthPicked = (): Promise => { + const date = new Date(); + // Month numbers are 0-11 and setMonth handles rollover. + date.setMonth(date.getMonth() - 1, 1); + void this.pickDate(date); + return Promise.resolve(); + }; + + public onBeginningPicked = (): Promise => { + void this.pickDate(new Date(0)); + return Promise.resolve(); + }; + + public onDatePicked = (dateString: string): Promise => { + void this.pickDate(dateString); + return Promise.resolve(); + }; +} diff --git a/apps/web/test/unit-tests/components/structures/__snapshots__/MessagePanel-test.tsx.snap b/apps/web/test/unit-tests/components/structures/__snapshots__/MessagePanel-test.tsx.snap index f31612ae13..af12993f99 100644 --- a/apps/web/test/unit-tests/components/structures/__snapshots__/MessagePanel-test.tsx.snap +++ b/apps/web/test/unit-tests/components/structures/__snapshots__/MessagePanel-test.tsx.snap @@ -47,11 +47,11 @@ exports[`MessagePanel should handle lots of membership events quickly 1`] = ` role="none" />
    diff --git a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap index f41937f2e0..048caf3d52 100644 --- a/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap @@ -49,11 +49,11 @@ exports[` should match the snapshot 1`] = ` role="none" />
    @@ -180,11 +180,11 @@ exports[` should support events with 1`] = ` role="none" />
    diff --git a/apps/web/test/unit-tests/components/views/messages/DateSeparator-test.tsx b/apps/web/test/unit-tests/components/views/messages/DateSeparator-test.tsx deleted file mode 100644 index f51b62933c..0000000000 --- a/apps/web/test/unit-tests/components/views/messages/DateSeparator-test.tsx +++ /dev/null @@ -1,322 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2021 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 { mocked } from "jest-mock"; -import { fireEvent, render, screen, waitFor } from "jest-matrix-react"; -import { type TimestampToEventResponse, ConnectionError, HTTPError, MatrixError } from "matrix-js-sdk/src/matrix"; - -import dispatcher from "../../../../../src/dispatcher/dispatcher"; -import { Action } from "../../../../../src/dispatcher/actions"; -import { type ViewRoomPayload } from "../../../../../src/dispatcher/payloads/ViewRoomPayload"; -import { formatFullDateNoTime } from "../../../../../src/DateUtils"; -import SettingsStore from "../../../../../src/settings/SettingsStore"; -import { UIFeature } from "../../../../../src/settings/UIFeature"; -import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; -import { - clearAllModals, - flushPromisesWithFakeTimers, - getMockClientWithEventEmitter, - waitEnoughCyclesForModal, -} from "../../../../test-utils"; -import DateSeparator from "../../../../../src/components/views/messages/DateSeparator"; -import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext"; -import RoomContext, { type RoomContextType } from "../../../../../src/contexts/RoomContext"; - -jest.mock("../../../../../src/settings/SettingsStore"); - -describe("DateSeparator", () => { - const HOUR_MS = 3600000; - const DAY_MS = HOUR_MS * 24; - // Friday Dec 17 2021, 9:09am - const nowDate = new Date("2021-12-17T08:09:00.000Z"); - const roomId = "!unused:example.org"; - const defaultProps = { - ts: nowDate.getTime(), - roomId, - }; - - const mockRoomViewStore = { - getRoomId: jest.fn().mockReturnValue(roomId), - }; - - const defaultRoomContext = { - ...RoomContext, - roomId, - roomViewStore: mockRoomViewStore, - } as unknown as RoomContextType; - - const mockClient = getMockClientWithEventEmitter({ - timestampToEvent: jest.fn(), - }); - const getComponent = (props = {}) => - render( - - - - - , - ); - - type TestCase = [string, number, string]; - const testCases: TestCase[] = [ - ["the exact same moment", nowDate.getTime(), "today"], - ["same day as current day", nowDate.getTime() - HOUR_MS, "today"], - ["day before the current day", nowDate.getTime() - HOUR_MS * 12, "yesterday"], - ["2 days ago", nowDate.getTime() - DAY_MS * 2, "Wednesday"], - ["144 hours ago", nowDate.getTime() - HOUR_MS * 144, "Sat, Dec 11, 2021"], - [ - "6 days ago, but less than 144h", - new Date("Saturday Dec 11 2021 23:59:00 GMT+0100 (Central European Standard Time)").getTime(), - "Saturday", - ], - ]; - - beforeEach(() => { - // Set a consistent fake time here so the test is always consistent - jest.useFakeTimers(); - jest.setSystemTime(nowDate.getTime()); - - (SettingsStore.getValue as jest.Mock) = jest.fn((arg) => { - if (arg === UIFeature.TimelineEnableRelativeDates) { - return true; - } - }); - mockRoomViewStore.getRoomId.mockReturnValue(roomId); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - - it("renders the date separator correctly", () => { - const { asFragment } = getComponent(); - expect(asFragment()).toMatchSnapshot(); - expect(SettingsStore.getValue).toHaveBeenCalledWith(UIFeature.TimelineEnableRelativeDates); - }); - - it.each(testCases)("formats date correctly when current time is %s", (_d, ts, result) => { - expect(getComponent({ ts, forExport: false }).container.textContent).toEqual(result); - }); - - it("renders invalid date separator correctly", () => { - const ts = new Date(-8640000000000004).getTime(); - const { asFragment } = getComponent({ ts }); - expect(asFragment()).toMatchSnapshot(); - }); - - describe("when forExport is true", () => { - it.each(testCases)("formats date in full when current time is %s", (_d, ts) => { - expect(getComponent({ ts, forExport: true }).container.textContent).toEqual( - formatFullDateNoTime(new Date(ts)), - ); - }); - }); - - describe("when Settings.TimelineEnableRelativeDates is falsy", () => { - beforeEach(() => { - (SettingsStore.getValue as jest.Mock) = jest.fn((arg) => { - if (arg === UIFeature.TimelineEnableRelativeDates) { - return false; - } - }); - }); - it.each(testCases)("formats date in full when current time is %s", (_d, ts) => { - expect(getComponent({ ts, forExport: false }).container.textContent).toEqual( - formatFullDateNoTime(new Date(ts)), - ); - }); - }); - - describe("when feature_jump_to_date is enabled", () => { - beforeEach(() => { - jest.clearAllMocks(); - mocked(SettingsStore).getValue.mockImplementation((arg): any => { - if (arg === "feature_jump_to_date") { - return true; - } - }); - jest.spyOn(dispatcher, "dispatch").mockImplementation(() => {}); - }); - - afterEach(async () => { - await clearAllModals(); - }); - - it("renders the date separator correctly", () => { - const { asFragment } = getComponent(); - expect(asFragment()).toMatchSnapshot(); - }); - - [ - { - timeDescriptor: "last week", - jumpButtonTestId: "jump-to-date-last-week", - }, - { - timeDescriptor: "last month", - jumpButtonTestId: "jump-to-date-last-month", - }, - { - timeDescriptor: "the beginning", - jumpButtonTestId: "jump-to-date-beginning", - }, - ].forEach((testCase) => { - it(`can jump to ${testCase.timeDescriptor}`, async () => { - // Render the component - getComponent(); - - // Open the jump to date context menu - fireEvent.click(screen.getByTestId("jump-to-date-separator-button")); - - // Jump to "x" - const returnedDate = new Date(); - // Just an arbitrary date before "now" - returnedDate.setDate(nowDate.getDate() - 100); - const returnedEventId = "$abc"; - mockClient.timestampToEvent.mockResolvedValue({ - event_id: returnedEventId, - origin_server_ts: returnedDate.getTime(), - } satisfies TimestampToEventResponse); - const jumpToXButton = await screen.findByTestId(testCase.jumpButtonTestId); - fireEvent.click(jumpToXButton); - - // Flush out the dispatcher which uses `window.setTimeout(...)` since we're - // using `jest.useFakeTimers()` in these tests. - await flushPromisesWithFakeTimers(); - - // Ensure that we're jumping to the event at the given date - expect(dispatcher.dispatch).toHaveBeenCalledWith({ - action: Action.ViewRoom, - event_id: returnedEventId, - highlighted: true, - room_id: roomId, - metricsTrigger: undefined, - } satisfies ViewRoomPayload); - }); - }); - - it("should not jump to date if we already switched to another room", async () => { - // Render the component - getComponent(); - - // Open the jump to date context menu - fireEvent.click(screen.getByTestId("jump-to-date-separator-button")); - - // Mimic the outcome of switching rooms while waiting for the jump to date - // request to finish. Imagine that we started jumping to "last week", the - // network request is taking a while, so we got bored, switched rooms; we - // shouldn't jump back to the previous room after the network request - // happens to finish later. - mockRoomViewStore.getRoomId.mockReturnValue("!some-other-room"); - - // Jump to "last week" - mockClient.timestampToEvent.mockResolvedValue({ - event_id: "$abc", - origin_server_ts: 0, - }); - const jumpToLastWeekButton = await screen.findByTestId("jump-to-date-last-week"); - fireEvent.click(jumpToLastWeekButton); - - // Flush out the dispatcher which uses `window.setTimeout(...)` since we're - // using `jest.useFakeTimers()` in these tests. - await flushPromisesWithFakeTimers(); - - // We should not see any room switching going on (`Action.ViewRoom`) - expect(dispatcher.dispatch).not.toHaveBeenCalled(); - }); - - it("should not show jump to date error if we already switched to another room", async () => { - // Render the component - getComponent(); - - // Open the jump to date context menu - fireEvent.click(screen.getByTestId("jump-to-date-separator-button")); - - // Mimic the outcome of switching rooms while waiting for the jump to date - // request to finish. Imagine that we started jumping to "last week", the - // network request is taking a while, so we got bored, switched rooms; we - // shouldn't jump back to the previous room after the network request - // happens to finish later. - mockRoomViewStore.getRoomId.mockReturnValue("!some-other-room"); - - // Try to jump to "last week" but we want an error to occur and ensure that - // we don't show an error dialog for it since we already switched away to - // another room and don't care about the outcome here anymore. - mockClient.timestampToEvent.mockRejectedValue(new Error("Fake error in test")); - const jumpToLastWeekButton = await screen.findByTestId("jump-to-date-last-week"); - fireEvent.click(jumpToLastWeekButton); - - // Wait the necessary time in order to see if any modal will appear - await waitEnoughCyclesForModal({ - useFakeTimers: true, - }); - - // We should not see any error modal dialog - // - // We have to use `queryBy` so that it can return `null` for something that does not exist. - expect(screen.queryByTestId("jump-to-date-error-content")).not.toBeInTheDocument(); - }); - - it("should show error dialog with submit debug logs option when non-networking error occurs", async () => { - // Render the component - getComponent(); - - // Open the jump to date context menu - fireEvent.click(screen.getByTestId("jump-to-date-separator-button")); - - // Try to jump to "last week" but we want a non-network error to occur so it - // shows the "Submit debug logs" UI - mockClient.timestampToEvent.mockRejectedValue(new Error("Fake error in test")); - const jumpToLastWeekButton = await screen.findByTestId("jump-to-date-last-week"); - fireEvent.click(jumpToLastWeekButton); - - // Expect error to be shown. We have to wait for the UI to transition. - await expect(screen.findByTestId("jump-to-date-error-content")).resolves.toBeInTheDocument(); - - // Expect an option to submit debug logs to be shown when a non-network error occurs - await expect( - screen.findByTestId("jump-to-date-error-submit-debug-logs-button"), - ).resolves.toBeInTheDocument(); - }); - - [ - new ConnectionError("Fake connection error in test"), - new HTTPError("Fake http error in test", 418), - new MatrixError( - { errcode: "M_FAKE_ERROR_CODE", error: "Some fake error occured" }, - 518, - "https://fake-url/", - ), - ].forEach((fakeError) => { - it(`should show error dialog without submit debug logs option when networking error (${fakeError.name}) occurs`, async () => { - // Try to jump to "last week" but we want a network error to occur - mockClient.timestampToEvent.mockRejectedValue(fakeError); - - // Render the component - getComponent(); - - // Open the jump to date context menu - fireEvent.click(screen.getByTestId("jump-to-date-separator-button")); - - const jumpToLastWeekButton = await screen.findByTestId("jump-to-date-last-week"); - fireEvent.click(jumpToLastWeekButton); - - // Expect error to be shown. We have to wait for the UI to transition. - await expect(screen.findByTestId("jump-to-date-error-content")).resolves.toBeInTheDocument(); - - // The submit debug logs option should *NOT* be shown for network errors. - // - // We have to use `queryBy` so that it can return `null` for something that does not exist. - await waitFor(() => - expect(screen.queryByTestId("jump-to-date-error-submit-debug-logs-button")).not.toBeInTheDocument(), - ); - }); - }); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/messages/JumpToDatePicker-test.tsx b/apps/web/test/unit-tests/components/views/messages/JumpToDatePicker-test.tsx deleted file mode 100644 index 1b5c4dbd21..0000000000 --- a/apps/web/test/unit-tests/components/views/messages/JumpToDatePicker-test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2021 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 } from "jest-matrix-react"; - -import JumpToDatePicker from "../../../../../src/components/views/messages/JumpToDatePicker"; - -describe("JumpToDatePicker", () => { - const nowDate = new Date("2021-12-17T08:09:00.000Z"); - beforeEach(() => { - // Set a stable fake time here so the test is always consistent - jest.useFakeTimers(); - jest.setSystemTime(nowDate.getTime()); - }); - - afterAll(() => { - jest.useRealTimers(); - }); - - it("renders the date picker correctly", () => { - const { asFragment } = render( - {}} />, - ); - - expect(asFragment()).toMatchSnapshot(); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/messages/__snapshots__/DateSeparator-test.tsx.snap b/apps/web/test/unit-tests/components/views/messages/__snapshots__/DateSeparator-test.tsx.snap deleted file mode 100644 index 055ce6b337..0000000000 --- a/apps/web/test/unit-tests/components/views/messages/__snapshots__/DateSeparator-test.tsx.snap +++ /dev/null @@ -1,103 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`DateSeparator renders invalid date separator correctly 1`] = ` - - - -`; - -exports[`DateSeparator renders the date separator correctly 1`] = ` - - - -`; - -exports[`DateSeparator when feature_jump_to_date is enabled renders the date separator correctly 1`] = ` - - - -`; diff --git a/apps/web/test/unit-tests/components/views/messages/__snapshots__/JumpToDatePicker-test.tsx.snap b/apps/web/test/unit-tests/components/views/messages/__snapshots__/JumpToDatePicker-test.tsx.snap deleted file mode 100644 index 3e79a98240..0000000000 --- a/apps/web/test/unit-tests/components/views/messages/__snapshots__/JumpToDatePicker-test.tsx.snap +++ /dev/null @@ -1,41 +0,0 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing - -exports[`JumpToDatePicker renders the date picker correctly 1`] = ` - -
    - - Jump to date - -
    - - -
    - -
    -
    -`; diff --git a/apps/web/test/unit-tests/utils/DateUtils-test.ts b/apps/web/test/unit-tests/utils/DateUtils-test.ts index edf7fee481..e61f79fde7 100644 --- a/apps/web/test/unit-tests/utils/DateUtils-test.ts +++ b/apps/web/test/unit-tests/utils/DateUtils-test.ts @@ -11,7 +11,6 @@ import { formatRelativeTime, formatDuration, formatFullDateNoDayISO, - formatDateForInput, formatTimeLeft, formatPreciseDuration, formatLocalDateShort, @@ -351,15 +350,6 @@ describe("formatFullDateNoDayNoTime", () => { }); }); -describe("formatDateForInput", () => { - it.each([["1993-11-01"], ["1066-10-14"], ["0571-04-22"], ["0062-02-05"]])( - "should format %s", - (dateString: string) => { - expect(formatDateForInput(new Date(dateString))).toBe(dateString); - }, - ); -}); - describe("formatTimeLeft", () => { it.each([ [0, "0s left"], diff --git a/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap b/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap index 84379afbcc..19d065b3a5 100644 --- a/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap +++ b/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap @@ -57,7 +57,7 @@ exports[`HTMLExport should export 1`] = `

    -
  • @user48:example.com
    00:00
    Message #48
  • @user47:example.com
    00:00
    Message #47
  • @user46:example.com
    00:00
    Message #46
  • @user45:example.com
    00:00
    Message #45
  • @user44:example.com
    00:00
    Message #44
  • @user43:example.com
    00:00
    Message #43
  • @user42:example.com
    00:00
    Message #42
  • @user41:example.com
    00:00
    Message #41
  • @user40:example.com
    00:00
    Message #40
  • @user39:example.com
    00:00
    Message #39
  • @user38:example.com
    00:00
    Message #38
  • @user37:example.com
    00:00
    Message #37
  • @user36:example.com
    00:00
    Message #36
  • @user35:example.com
    00:00
    Message #35
  • @user34:example.com
    00:00
    Message #34
  • @user33:example.com
    00:00
    Message #33
  • @user32:example.com
    00:00
    Message #32
  • @user31:example.com
    00:00
    Message #31
  • @user30:example.com
    00:00
    Message #30
  • @user29:example.com
    00:00
    Message #29
  • @user28:example.com
    00:00
    Message #28
  • @user27:example.com
    00:00
    Message #27
  • @user26:example.com
    00:00
    Message #26
  • @user25:example.com
    00:00
    Message #25
  • @user24:example.com
    00:00
    Message #24
  • @user23:example.com
    00:00
    Message #23
  • @user22:example.com
    00:00
    Message #22
  • @user21:example.com
    00:00
    Message #21
  • @user20:example.com
    00:00
    Message #20
  • @user19:example.com
    00:00
    Message #19
  • @user18:example.com
    00:00
    Message #18
  • @user17:example.com
    00:00
    Message #17
  • @user16:example.com
    00:00
    Message #16
  • @user15:example.com
    00:00
    Message #15
  • @user14:example.com
    00:00
    Message #14
  • @user13:example.com
    00:00
    Message #13
  • @user12:example.com
    00:00
    Message #12
  • @user11:example.com
    00:00
    Message #11
  • @user10:example.com
    00:00
    Message #10
  • @user9:example.com
    00:00
    Message #9
  • @user8:example.com
    00:00
    Message #8
  • @user7:example.com
    00:00
    Message #7
  • @user6:example.com
    00:00
    Message #6
  • @user5:example.com
    00:00
    Message #5
  • @user4:example.com
    00:00
    Message #4
  • @user3:example.com
    00:00
    Message #3
  • @user2:example.com
    00:00
    Message #2
  • @user1:example.com
    00:00
    Message #1
  • @user0:example.com
    00:00
    Message #0
  • +
  • @user49:example.com
    00:00
    Message #49
  • @user48:example.com
    00:00
    Message #48
  • @user47:example.com
    00:00
    Message #47
  • @user46:example.com
    00:00
    Message #46
  • @user45:example.com
    00:00
    Message #45
  • @user44:example.com
    00:00
    Message #44
  • @user43:example.com
    00:00
    Message #43
  • @user42:example.com
    00:00
    Message #42
  • @user41:example.com
    00:00
    Message #41
  • @user40:example.com
    00:00
    Message #40
  • @user39:example.com
    00:00
    Message #39
  • @user38:example.com
    00:00
    Message #38
  • @user37:example.com
    00:00
    Message #37
  • @user36:example.com
    00:00
    Message #36
  • @user35:example.com
    00:00
    Message #35
  • @user34:example.com
    00:00
    Message #34
  • @user33:example.com
    00:00
    Message #33
  • @user32:example.com
    00:00
    Message #32
  • @user31:example.com
    00:00
    Message #31
  • @user30:example.com
    00:00
    Message #30
  • @user29:example.com
    00:00
    Message #29
  • @user28:example.com
    00:00
    Message #28
  • @user27:example.com
    00:00
    Message #27
  • @user26:example.com
    00:00
    Message #26
  • @user25:example.com
    00:00
    Message #25
  • @user24:example.com
    00:00
    Message #24
  • @user23:example.com
    00:00
    Message #23
  • @user22:example.com
    00:00
    Message #22
  • @user21:example.com
    00:00
    Message #21
  • @user20:example.com
    00:00
    Message #20
  • @user19:example.com
    00:00
    Message #19
  • @user18:example.com
    00:00
    Message #18
  • @user17:example.com
    00:00
    Message #17
  • @user16:example.com
    00:00
    Message #16
  • @user15:example.com
    00:00
    Message #15
  • @user14:example.com
    00:00
    Message #14
  • @user13:example.com
    00:00
    Message #13
  • @user12:example.com
    00:00
    Message #12
  • @user11:example.com
    00:00
    Message #11
  • @user10:example.com
    00:00
    Message #10
  • @user9:example.com
    00:00
    Message #9
  • @user8:example.com
    00:00
    Message #8
  • @user7:example.com
    00:00
    Message #7
  • @user6:example.com
    00:00
    Message #6
  • @user5:example.com
    00:00
    Message #5
  • @user4:example.com
    00:00
    Message #4
  • @user3:example.com
    00:00
    Message #3
  • @user2:example.com
    00:00
    Message #2
  • @user1:example.com
    00:00
    Message #1
  • @user0:example.com
    00:00
    Message #0
  • diff --git a/apps/web/test/viewmodels/timeline/DateSeparatorViewModel-test.tsx b/apps/web/test/viewmodels/timeline/DateSeparatorViewModel-test.tsx new file mode 100644 index 0000000000..9abfd76e6e --- /dev/null +++ b/apps/web/test/viewmodels/timeline/DateSeparatorViewModel-test.tsx @@ -0,0 +1,351 @@ +/* + * 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 { mocked } from "jest-mock"; +import { ConnectionError, Direction } from "matrix-js-sdk/src/matrix"; + +import dispatcher from "../../../src/dispatcher/dispatcher"; +import { Action } from "../../../src/dispatcher/actions"; +import { formatFullDateNoTime } from "../../../src/DateUtils"; +import Modal from "../../../src/Modal"; +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import SettingsStore from "../../../src/settings/SettingsStore"; +import { UIFeature } from "../../../src/settings/UIFeature"; +import { SdkContextClass } from "../../../src/contexts/SDKContext"; +import { DateSeparatorViewModel } from "../../../src/viewmodels/timeline/DateSeparatorViewModel"; +import { flushPromisesWithFakeTimers } from "../../test-utils"; + +jest.mock("../../../src/settings/SettingsStore"); +jest.mock("../../../src/contexts/SDKContext", () => ({ + SdkContextClass: { + instance: { + roomViewStore: { + getRoomId: jest.fn(), + }, + }, + }, +})); + +describe("DateSeparatorViewModel", () => { + const HOUR_MS = 3600000; + const DAY_MS = HOUR_MS * 24; + // Friday Dec 17 2021, 9:09am + const nowDate = new Date("2021-12-17T08:09:00.000Z"); + const roomId = "!room:example.org"; + const defaultProps = { + roomId, + ts: nowDate.getTime(), + }; + type TestCase = [string, number, string]; + const testCases: TestCase[] = [ + ["the exact same moment", nowDate.getTime(), "today"], + ["same day as current day", nowDate.getTime() - HOUR_MS, "today"], + ["day before the current day", nowDate.getTime() - HOUR_MS * 12, "yesterday"], + ["2 days ago", nowDate.getTime() - DAY_MS * 2, "Wednesday"], + ["144 hours ago", nowDate.getTime() - HOUR_MS * 144, "Sat, Dec 11, 2021"], + [ + "6 days ago, but less than 144h", + new Date("Saturday Dec 11 2021 23:59:00 GMT+0100 (Central European Standard Time)").getTime(), + "Saturday", + ], + ]; + + const watchCallbacks = new Map void>(); + const mockTimestampToEvent = jest.fn(); + + const hasTestId = (node: React.ReactNode, testId: string): boolean => { + if (!React.isValidElement<{ children?: React.ReactNode }>(node)) return false; + const props = node.props as { "children"?: React.ReactNode; "data-testid"?: string }; + if (props["data-testid"] === testId) return true; + + const children = React.Children.toArray(props.children); + return children.some((child) => hasTestId(child, testId)); + }; + + const createViewModel = ( + props: Partial & { forExport?: boolean } = {}, + ): DateSeparatorViewModel => { + return new DateSeparatorViewModel({ ...defaultProps, ...props }); + }; + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(nowDate.getTime()); + watchCallbacks.clear(); + + mocked(SettingsStore).getValue.mockImplementation((key): any => { + if (String(key) === UIFeature.TimelineEnableRelativeDates) return true; + if (key === "feature_jump_to_date") return false; + return undefined; + }); + mocked(SettingsStore).watchSetting.mockImplementation((settingName, _roomId, cb): any => { + watchCallbacks.set(String(settingName), cb); + return `${String(settingName)}-watch-ref`; + }); + mocked(SettingsStore).unwatchSetting.mockImplementation(() => {}); + + mockTimestampToEvent.mockReset(); + jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue({ + timestampToEvent: mockTimestampToEvent, + } as any); + + jest.spyOn(dispatcher, "dispatch").mockImplementation(() => {}); + jest.spyOn(Modal, "createDialog").mockImplementation(() => ({ close: jest.fn() }) as any); + + mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(roomId); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it("computes relative label for today", () => { + const vm = createViewModel(); + + expect(vm.getSnapshot().label).toBe("today"); + expect(vm.getSnapshot().className).toBe("mx_TimelineSeparator"); + }); + + it("uses full date when exporting", () => { + const vm = createViewModel({ forExport: true }); + + expect(vm.getSnapshot().label).toBe(formatFullDateNoTime(nowDate)); + }); + + it("updates label when relative dates setting changes at runtime", () => { + const vm = createViewModel(); + expect(vm.getSnapshot().label).toBe("today"); + + const callback = watchCallbacks.get(UIFeature.TimelineEnableRelativeDates); + expect(callback).toBeDefined(); + callback?.(UIFeature.TimelineEnableRelativeDates, null, null, null, false); + + expect(vm.getSnapshot().label).toBe(formatFullDateNoTime(nowDate)); + }); + + it("exposes jumpToDateMenu when feature is enabled", () => { + mocked(SettingsStore).getValue.mockImplementation((key): any => { + if (String(key) === UIFeature.TimelineEnableRelativeDates) return true; + if (key === "feature_jump_to_date") return true; + return undefined; + }); + const vm = createViewModel(); + + expect(vm.getSnapshot().jumpToEnabled).toBeTruthy(); + }); + + it("exposes jumpFromDate in snapshot", () => { + const vm = createViewModel(); + + expect(vm.getSnapshot().jumpFromDate).toBe("2021-12-17"); + }); + + it("does not expose jumpToDateMenu when exporting", () => { + mocked(SettingsStore).getValue.mockImplementation((key): any => { + if (String(key) === UIFeature.TimelineEnableRelativeDates) return true; + if (key === "feature_jump_to_date") return true; + return undefined; + }); + const vm = createViewModel({ forExport: true }); + + expect(vm.getSnapshot().jumpToEnabled).toBeFalsy(); + }); + + it("updates jumpToEnabled when feature_jump_to_date changes at runtime", () => { + const vm = createViewModel(); + expect(vm.getSnapshot().jumpToEnabled).toBeFalsy(); + + const callback = watchCallbacks.get("feature_jump_to_date"); + expect(callback).toBeDefined(); + callback?.("feature_jump_to_date", null, null, null, true); + + expect(vm.getSnapshot().jumpToEnabled).toBeTruthy(); + }); + + it("dispatches ViewRoom when pickDate resolves in active room", async () => { + const eventId = "$event"; + const unixTimestamp = nowDate.getTime() - DAY_MS; + mockTimestampToEvent.mockResolvedValue({ + event_id: eventId, + origin_server_ts: unixTimestamp, + }); + const vm = createViewModel(); + + await vm.pickDate(unixTimestamp); + + expect(mockTimestampToEvent).toHaveBeenCalledWith(roomId, unixTimestamp, Direction.Forward); + expect(dispatcher.dispatch).toHaveBeenCalledWith({ + action: Action.ViewRoom, + event_id: eventId, + highlighted: true, + room_id: roomId, + metricsTrigger: undefined, + }); + }); + + it("does not dispatch ViewRoom when room changed before pickDate resolves", async () => { + mockTimestampToEvent.mockResolvedValue({ + event_id: "$event", + origin_server_ts: nowDate.getTime(), + }); + mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue("!other:example.org"); + const vm = createViewModel(); + + await vm.pickDate(nowDate.getTime() - HOUR_MS); + + expect(dispatcher.dispatch).not.toHaveBeenCalled(); + }); + + it("shows submit debug logs option for generic errors", async () => { + mockTimestampToEvent.mockRejectedValue(new Error("Boom")); + const vm = createViewModel(); + + await vm.pickDate(nowDate.getTime() - HOUR_MS); + + expect(Modal.createDialog).toHaveBeenCalled(); + const [, params] = mocked(Modal.createDialog).mock.calls.at(-1)!; + expect(hasTestId((params as any).description, "jump-to-date-error-submit-debug-logs-button")).toBe(true); + }); + + it("does not show submit debug logs option for connection errors", async () => { + mockTimestampToEvent.mockRejectedValue(new ConnectionError("offline")); + const vm = createViewModel(); + + await vm.pickDate(nowDate.getTime() - HOUR_MS); + + expect(Modal.createDialog).toHaveBeenCalled(); + const [, params] = mocked(Modal.createDialog).mock.calls.at(-1)!; + expect(hasTestId((params as any).description, "jump-to-date-error-submit-debug-logs-button")).toBe(false); + }); + + describe("snapshot labels", () => { + it.each(testCases)("formats date correctly when current time is %s", (_d, ts, result) => { + expect(createViewModel({ ts }).getSnapshot().label).toContain(result); + }); + + describe("when forExport is true", () => { + it.each(testCases)("formats date in full when current time is %s", (_d, ts) => { + expect(createViewModel({ ts, forExport: true }).getSnapshot().label).toContain( + formatFullDateNoTime(new Date(ts)), + ); + }); + }); + + describe("when TimelineEnableRelativeDates is false", () => { + beforeEach(() => { + mocked(SettingsStore).getValue.mockImplementation((key): any => { + if (String(key) === UIFeature.TimelineEnableRelativeDates) return false; + if (key === "feature_jump_to_date") return false; + return undefined; + }); + }); + + it.each(testCases)("formats date in full when current time is %s", (_d, ts) => { + expect(createViewModel({ ts }).getSnapshot().label).toContain(formatFullDateNoTime(new Date(ts))); + }); + }); + }); + + describe("jump actions", () => { + beforeEach(() => { + mocked(SettingsStore).getValue.mockImplementation((key): any => { + if (String(key) === UIFeature.TimelineEnableRelativeDates) return true; + if (key === "feature_jump_to_date") return true; + return undefined; + }); + }); + + [ + { + timeDescriptor: "last week", + run: (vm: DateSeparatorViewModel): Promise => vm.onLastWeekPicked(), + }, + { + timeDescriptor: "last month", + run: (vm: DateSeparatorViewModel): Promise => vm.onLastMonthPicked(), + }, + { + timeDescriptor: "the beginning", + run: (vm: DateSeparatorViewModel): Promise => vm.onBeginningPicked(), + }, + ].forEach((testCase) => { + it(`can jump to ${testCase.timeDescriptor}`, async () => { + const returnedDate = new Date(); + returnedDate.setDate(nowDate.getDate() - 100); + const returnedEventId = "$abc"; + mockTimestampToEvent.mockResolvedValue({ + event_id: returnedEventId, + origin_server_ts: returnedDate.getTime(), + }); + const vm = createViewModel(); + + await testCase.run(vm); + await flushPromisesWithFakeTimers(); + + expect(mockTimestampToEvent).toHaveBeenCalledWith(roomId, expect.any(Number), Direction.Forward); + expect(dispatcher.dispatch).toHaveBeenCalledWith({ + action: Action.ViewRoom, + event_id: returnedEventId, + highlighted: true, + room_id: roomId, + metricsTrigger: undefined, + }); + }); + }); + + it("does not jump when room changed before request resolves", async () => { + mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue("!some-other-room"); + mockTimestampToEvent.mockResolvedValue({ + event_id: "$abc", + origin_server_ts: 0, + }); + const vm = createViewModel(); + + await vm.onLastWeekPicked(); + await flushPromisesWithFakeTimers(); + + expect(dispatcher.dispatch).not.toHaveBeenCalled(); + }); + + it("does not show jump to date error if user switched room", async () => { + mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue("!some-other-room"); + mockTimestampToEvent.mockRejectedValue(new Error("Fake error in test")); + const vm = createViewModel(); + + await vm.onLastWeekPicked(); + await flushPromisesWithFakeTimers(); + + expect(Modal.createDialog).not.toHaveBeenCalled(); + }); + + it("shows error dialog with submit debug logs option when non-networking error occurs", async () => { + mockTimestampToEvent.mockRejectedValue(new Error("Fake error in test")); + const vm = createViewModel(); + + await vm.onLastWeekPicked(); + await flushPromisesWithFakeTimers(); + + expect(Modal.createDialog).toHaveBeenCalled(); + const [, params] = mocked(Modal.createDialog).mock.calls.at(-1)!; + expect(hasTestId((params as any).description, "jump-to-date-error-submit-debug-logs-button")).toBe(true); + }); + + it("shows error dialog without submit debug logs option when networking error occurs", async () => { + mockTimestampToEvent.mockRejectedValue(new ConnectionError("Fake connection error in test")); + const vm = createViewModel(); + + await vm.onLastWeekPicked(); + await flushPromisesWithFakeTimers(); + + expect(Modal.createDialog).toHaveBeenCalled(); + const [, params] = mocked(Modal.createDialog).mock.calls.at(-1)!; + expect(hasTestId((params as any).description, "jump-to-date-error-submit-debug-logs-button")).toBe(false); + }); + }); +}); diff --git a/packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/default-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..6bc4cc3de25c1f9b3082c6f69bf35d102f7f5b83 GIT binary patch literal 17692 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 W!xqs4kJQ6Ah?kq+gv{UN>;DfLOf?z+ literal 0 HcmV?d00001 diff --git a/packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/has-extra-class-names-auto.png b/packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/has-extra-class-names-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..6bc4cc3de25c1f9b3082c6f69bf35d102f7f5b83 GIT binary patch literal 17692 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 W!xqs4kJQ6Ah?kq+gv{UN>;DfLOf?z+ literal 0 HcmV?d00001 diff --git a/packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/long-localized-label-auto.png b/packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/long-localized-label-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..f3e3b3ae151d54d88c45dc3487822722ec82faed GIT binary patch literal 24362 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 literal 0 HcmV?d00001 diff --git a/packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/with-jump-to-date-picker-auto.png b/packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/with-jump-to-date-picker-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..a2df01184374e38b907ca71588027d7c1138b13a GIT binary patch literal 31913 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

    ObT>25Ih(J@W2`H{ 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 literal 0 HcmV?d00001 diff --git a/packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/with-jump-to-tooltip-auto.png b/packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/with-jump-to-tooltip-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..811dbfd0679a8480aee1e4220bd049472d0a324e GIT binary patch literal 19321 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 mGUDfVLX$?LjYTuzF|_w = ({ label, className, children }) => { +const TimelineSeparator: React.FC = ({ label, className, children, role = "separator" }) => { // ARIA treats


    s as separators, here we abuse them slightly so manually treat this entire thing as one return (
    diff --git a/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorButton.tsx b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorButton.tsx new file mode 100644 index 0000000000..44fd099b52 --- /dev/null +++ b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorButton.tsx @@ -0,0 +1,61 @@ +/* + * 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 { Tooltip } from "@vector-im/compound-web"; +import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down"; + +import { Flex } from "../../utils/Flex"; +import { useI18n } from "../../utils/i18nContext"; + +/** Props for DateSeparatorButton. */ +export interface DateSeparatorButtonProps { + /** Visible date label shown in the separator button. */ + label: string; + /** Controls tooltip visibility when parent manages open state. */ + tooltipOpen?: boolean; + /** Extra CSS classes to apply to the component. */ + className?: string; + /** Optional ref for the button container element. */ + buttonRef?: React.Ref; + /** Called when the pointer enters the button trigger. */ + onMouseEnter?: React.MouseEventHandler; + /** Called when the pointer leaves the button trigger. */ + onMouseLeave?: React.MouseEventHandler; + /** Called when the button trigger receives focus. */ + onFocus?: React.FocusEventHandler; + /** Called when the button trigger loses focus. */ + onBlur?: React.FocusEventHandler; +} + +/** Interactive date separator button that opens the jump-to-date menu. */ +export function DateSeparatorButton({ + label, + tooltipOpen, + className, + buttonRef, + ...props +}: DateSeparatorButtonProps): React.ReactNode { + const { translate: _t } = useI18n(); + return ( + + + + + + + ); +} diff --git a/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorContextMenuView.module.css b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorContextMenuView.module.css new file mode 100644 index 0000000000..fff772c4bd --- /dev/null +++ b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorContextMenuView.module.css @@ -0,0 +1,20 @@ +/* + * 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. + */ + +.picker_menu { + max-inline-size: none !important; + padding: 0 !important; + gap: 0 !important; +} + +.picker_menu_item { + padding: var(--cpd-space-3x) var(--cpd-space-5x) !important; +} + +.picker_separator { + margin-inline: 0 !important; +} diff --git a/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorContextMenuView.test.tsx b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorContextMenuView.test.tsx new file mode 100644 index 0000000000..90c2c804e9 --- /dev/null +++ b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorContextMenuView.test.tsx @@ -0,0 +1,182 @@ +/* + * 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 { fireEvent, render, screen } from "@test-utils"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { type DateSeparatorViewModel, type DateSeparatorViewSnapshot } from "./DateSeparatorView"; +import { DateSeparatorContextMenuView } from "./DateSeparatorContextMenuView"; + +class TestDateSeparatorViewModel implements DateSeparatorViewModel { + private listeners = new Set<() => void>(); + + public constructor(private snapshot: DateSeparatorViewSnapshot) {} + + public getSnapshot = (): DateSeparatorViewSnapshot => this.snapshot; + + public subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + public onLastWeekPicked = vi.fn<() => void>(); + public onLastMonthPicked = vi.fn<() => void>(); + public onBeginningPicked = vi.fn<() => void>(); + public onDatePicked = vi.fn<(dateString: string) => void>(); +} + +function renderMenu({ + open = true, + jumpToEnabled = true, + onOpenChange = vi.fn<(open: boolean) => void>(), +}: { + open?: boolean; + jumpToEnabled?: boolean; + onOpenChange?: (open: boolean) => void; +} = {}): { vm: TestDateSeparatorViewModel; onOpenChange: (open: boolean) => void } { + const vm = new TestDateSeparatorViewModel({ + label: "Today", + jumpToEnabled, + jumpFromDate: "2025-01-15", + }); + + render( + + Trigger + + } + />, + ); + + return { vm, onOpenChange }; +} + +describe("DateSeparatorContextMenuView", () => { + it("renders menu actions and date picker when open", () => { + renderMenu({ open: true, jumpToEnabled: true }); + + expect(screen.getByTestId("jump-to-date-last-week")).toBeInTheDocument(); + expect(screen.getByTestId("jump-to-date-last-month")).toBeInTheDocument(); + expect(screen.getByTestId("jump-to-date-beginning")).toBeInTheDocument(); + expect(screen.getByTestId("jump-to-date-picker")).toBeInTheDocument(); + }); + + it("calls onOpenChange for opening and closing transitions", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn<(open: boolean) => void>(); + + renderMenu({ open: false, jumpToEnabled: true, onOpenChange }); + await user.click(screen.getByTestId("jump-to-trigger")); + expect(onOpenChange).toHaveBeenCalledWith(true); + + renderMenu({ open: true, jumpToEnabled: true, onOpenChange }); + await user.click(screen.getByTestId("jump-to-date-last-week")); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("wires week/month/beginning menu actions to the correct callbacks", async () => { + const user = userEvent.setup(); + const { vm } = renderMenu({ open: true, jumpToEnabled: true }); + + await user.click(screen.getByTestId("jump-to-date-last-week")); + await user.click(screen.getByTestId("jump-to-date-last-month")); + await user.click(screen.getByTestId("jump-to-date-beginning")); + + expect(vm.onLastWeekPicked).toHaveBeenCalledTimes(1); + expect(vm.onLastMonthPicked).toHaveBeenCalledTimes(1); + expect(vm.onBeginningPicked).toHaveBeenCalledTimes(1); + }); + + it("submits date picker and closes the menu", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn<(open: boolean) => void>(); + const { vm } = renderMenu({ open: true, jumpToEnabled: true, onOpenChange }); + + const dateInput = screen.getByLabelText("Pick a date to jump to"); + fireEvent.input(dateInput, { target: { value: "2025-01-10" } }); + await user.click(screen.getByRole("button", { name: "Go" })); + + expect(vm.onDatePicked).toHaveBeenCalledWith("2025-01-10"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("moves focus from date input to submit button on Tab", async () => { + const user = userEvent.setup(); + renderMenu({ open: true, jumpToEnabled: true }); + + const dateInput = screen.getByLabelText("Pick a date to jump to"); + const submitButton = screen.getByRole("button", { name: "Go" }); + dateInput.focus(); + await user.keyboard("{Tab}"); + + expect(submitButton).toHaveFocus(); + }); + + it("moves focus from submit button back to date input on Shift+Tab", async () => { + const user = userEvent.setup(); + renderMenu({ open: true, jumpToEnabled: true }); + + const dateInput = screen.getByLabelText("Pick a date to jump to"); + const submitButton = screen.getByRole("button", { name: "Go" }); + submitButton.focus(); + await user.keyboard("{Shift>}{Tab}{/Shift}"); + + expect(dateInput).toHaveFocus(); + }); + + it("closes the menu on Tab from the submit button", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn<(open: boolean) => void>(); + const { vm } = renderMenu({ open: true, jumpToEnabled: true, onOpenChange }); + + const submitButton = screen.getByRole("button", { name: "Go" }); + submitButton.focus(); + await user.keyboard("{Tab}"); + + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(vm.onDatePicked).not.toHaveBeenCalled(); + }); + + it("submits date picker with Enter on the submit button", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn<(open: boolean) => void>(); + const { vm } = renderMenu({ open: true, jumpToEnabled: true, onOpenChange }); + + const dateInput = screen.getByLabelText("Pick a date to jump to"); + fireEvent.input(dateInput, { target: { value: "2025-01-11" } }); + + const submitButton = screen.getByRole("button", { name: "Go" }); + submitButton.focus(); + await user.keyboard("{Enter}"); + + expect(vm.onDatePicked).toHaveBeenCalledWith("2025-01-11"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("submits date picker with Space on the submit button", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn<(open: boolean) => void>(); + const { vm } = renderMenu({ open: true, jumpToEnabled: true, onOpenChange }); + + const dateInput = screen.getByLabelText("Pick a date to jump to"); + fireEvent.input(dateInput, { target: { value: "2025-01-12" } }); + + const submitButton = screen.getByRole("button", { name: "Go" }); + submitButton.focus(); + await user.keyboard(" "); + + expect(vm.onDatePicked).toHaveBeenCalledWith("2025-01-12"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorContextMenuView.tsx b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorContextMenuView.tsx new file mode 100644 index 0000000000..9cd2086ea1 --- /dev/null +++ b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorContextMenuView.tsx @@ -0,0 +1,95 @@ +/* + * 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 PropsWithChildren, useRef } from "react"; +import { Menu, MenuItem, Separator } from "@vector-im/compound-web"; +import { capitalize } from "lodash"; + +import { useI18n } from "../../utils/i18nContext"; +import { humanizeRelativeTime } from "../../utils/humanize"; +import { type DateSeparatorViewModel } from "./DateSeparatorView"; +import { DateSeparatorDatePickerView } from "./DateSeparatorDatePickerView"; +import styles from "./DateSeparatorContextMenuView.module.css"; + +/** + * Props for DateSeparatorContextMenuView component. + */ +export interface DateSeparatorContextMenuViewProps { + /** The date separator view model. */ + vm: DateSeparatorViewModel; + /** Whether the menu is open (controlled by the parent). */ + open: boolean; + /** The element used as the menu trigger. */ + trigger: React.ReactNode; + /** Called when the menu requests an open state change. */ + onOpenChange?: (open: boolean) => void; +} + +/** + * Date separator jump-to menu. + * Uses the wrapped child as the menu trigger. + */ +export const DateSeparatorContextMenuView: React.FC> = ({ + vm, + open, + trigger, + onOpenChange, +}): JSX.Element => { + const i18n = useI18n(); + const { translate: _t } = useI18n(); + const dateInputRef = useRef(null); + + const onKeyDown = (event: React.KeyboardEvent): void => { + if (event.key !== "ArrowDown") return; + event.preventDefault(); + dateInputRef.current?.focus(); + }; + + return ( + { + onOpenChange?.(newOpen); + }} + title={_t("room|jump_to_date")} + showTitle={false} + trigger={trigger} + align="start" + className={styles.picker_menu} + > + vm.onLastWeekPicked?.()} + data-testid="jump-to-date-last-week" + hideChevron={true} + className={styles.picker_menu_item} + /> + vm.onLastMonthPicked?.()} + data-testid="jump-to-date-last-month" + hideChevron={true} + className={styles.picker_menu_item} + /> + vm.onBeginningPicked?.()} + data-testid="jump-to-date-beginning" + hideChevron={true} + className={styles.picker_menu_item} + onKeyDown={onKeyDown} + /> + + onOpenChange?.(false)} + onDismissed={() => onOpenChange?.(false)} + /> + + ); +}; diff --git a/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorDatePickerView.module.css b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorDatePickerView.module.css new file mode 100644 index 0000000000..e664b1f5eb --- /dev/null +++ b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorDatePickerView.module.css @@ -0,0 +1,47 @@ +/* + * 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. + */ + +.picker_menu_item { + padding: var(--cpd-space-3x) var(--cpd-space-5x) !important; +} + +.picker_form { + display: flex; + flex-direction: row !important; + flex-wrap: wrap !important; + padding: 0 !important; + gap: var(--cpd-space-2x) !important; + color: var(--cpd-color-text-primary); + font: var(--cpd-font-body-md-medium); +} + +.picker_input { + &:focus-within { + border-color: var(--cpd-color-border-focused); + } +} + +.picker_input_date { + font: inherit; + color-scheme: light; + padding: var(--cpd-space-2x) !important; + + :global(.cpd-theme-dark) &, + :global(.cpd-theme-dark-hc) & { + color-scheme: dark; + } +} + +@media (prefers-color-scheme: dark) { + .picker_input_date { + color-scheme: dark; + :global(.cpd-theme-light) &, + :global(.cpd-theme-light-hc) & { + color-scheme: light; + } + } +} diff --git a/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorDatePickerView.tsx b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorDatePickerView.tsx new file mode 100644 index 0000000000..6c6409a8ee --- /dev/null +++ b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorDatePickerView.tsx @@ -0,0 +1,132 @@ +/* + * 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, useId, useRef, useState } from "react"; +import { Root, Submit, Field, TextControl, MenuItem } from "@vector-im/compound-web"; + +import { formatDateForInput } from "../../utils/DateUtils"; +import { useI18n } from "../../utils/i18nContext"; +import { useViewModel } from "../../viewmodel"; +import { type DateSeparatorViewModel } from "./DateSeparatorView"; +import styles from "./DateSeparatorDatePickerView.module.css"; + +/** + * Props for DateSeparatorDatePickerView component. + */ +export interface DateSeparatorDatePickerViewProps { + /** The date separator view model. */ + vm: DateSeparatorViewModel; + /** Optional input ref shared with parent for focus management. */ + inputRef?: React.RefObject; + /** Called after a date has been submitted. */ + onSubmitted?: () => void; + /** Called when the picker is dismissed without submitting. */ + onDismissed?: () => void; +} + +/** + * Date picker menu item. + */ +export const DateSeparatorDatePickerView: React.FC = ({ + vm, + inputRef, + onSubmitted, + onDismissed, +}): JSX.Element => { + const snapshot = useViewModel(vm); + const date = snapshot.jumpFromDate ? new Date(snapshot.jumpFromDate) : new Date(); + const dateInputDefaultValue = formatDateForInput(date); + + const { translate: _t } = useI18n(); + const dateInputId = useId(); + const [dateValue, setDateValue] = useState(dateInputDefaultValue); + const localDateInputRef = useRef(null); + const dateInputRef = inputRef ?? localDateInputRef; + const submitButtonRef = useRef(null); + + const onDateInputKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === "Tab") { + if (event.shiftKey) { + onDismissed?.(); + } else { + event.preventDefault(); + submitButtonRef.current?.focus(); + } + } + }; + + const onDateValueInput = (event: React.InputEvent): void => { + setDateValue(event.currentTarget.value); + }; + + const submitDate = (): void => { + vm.onDatePicked?.(dateValue); + onSubmitted?.(); + }; + + const onJumpToDateSubmit = (event: React.SubmitEvent): void => { + event.preventDefault(); + submitDate(); + }; + + const onSubmitButtonKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === "Tab") { + if (event.shiftKey) { + event.preventDefault(); + dateInputRef.current?.focus(); + } else { + onDismissed?.(); + } + } + + if (event.key == "Enter" || event.key == " " || event.key == "Spacebar") { + event.preventDefault(); + submitDate(); + } + }; + + const keepMenuOpenOnSelect = (event: Event): void => { + event.preventDefault(); + }; + + return ( + + + + + + + {_t("action|go")} + + + + ); +}; diff --git a/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorView.module.css b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorView.module.css new file mode 100644 index 0000000000..5c420f3703 --- /dev/null +++ b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorView.module.css @@ -0,0 +1,27 @@ +/* + * 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 { + padding: 0 25px; + cursor: pointer; + + h2 { + flex: 0 0 auto; + margin: 0; + font-size: inherit; + font-weight: inherit; + color: inherit; + text-transform: capitalize; + } + + svg { + align-self: center; + width: 16px; + height: 16px; + color: var(--cpd-color-icon-secondary); + } +} diff --git a/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorView.stories.tsx b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorView.stories.tsx new file mode 100644 index 0000000000..d09f04d0a6 --- /dev/null +++ b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorView.stories.tsx @@ -0,0 +1,91 @@ +/* + * 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 } from "react"; +import { expect, userEvent, within } from "storybook/test"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { DateSeparatorView, type DateSeparatorViewSnapshot, type DateSeparatorViewActions } from "./DateSeparatorView"; +import { useMockedViewModel } from "../../viewmodel/useMockedViewModel"; +import { withViewDocs } from "../../../.storybook/withViewDocs"; + +type DateSeparatorProps = DateSeparatorViewSnapshot & DateSeparatorViewActions; + +const DateSeparatorViewWrapperImpl = ({ + onLastWeekPicked, + onLastMonthPicked, + onBeginningPicked, + onDatePicked, + ...rest +}: DateSeparatorProps): JSX.Element => { + const vm = useMockedViewModel(rest, { onLastWeekPicked, onLastMonthPicked, onBeginningPicked, onDatePicked }); + return ; +}; +const DateSeparatorViewWrapper = withViewDocs(DateSeparatorViewWrapperImpl, DateSeparatorView); + +const meta = { + title: "Timeline/DateSeparatorView", + component: DateSeparatorViewWrapper, + tags: ["autodocs"], + argTypes: { + jumpToEnabled: { control: "boolean" }, + jumpFromDate: { control: "text" }, + className: { control: "text" }, + }, + args: { + label: "Today", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const HasExtraClassNames: Story = { + args: { + className: "extra_class_1 extra_class_2", + }, +}; + +export const WithJumpToTooltip: Story = { + args: { + jumpToEnabled: true, + jumpFromDate: "2025-01-15", + onLastWeekPicked: () => console.log("onLastWeekPicked"), + onLastMonthPicked: () => console.log("onLastMonthPicked"), + onBeginningPicked: () => console.log("onBeginningPicked"), + onDatePicked: () => console.log("onDatePicked"), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.hover(canvas.getByText("Today")); + await expect(within(canvasElement.ownerDocument.body).findByRole("tooltip")).resolves.toBeInTheDocument(); + }, +}; + +export const WithJumpToDatePicker: Story = { + args: { + jumpToEnabled: true, + jumpFromDate: "2025-01-15", + onLastWeekPicked: () => console.log("onLastWeekPicked"), + onLastMonthPicked: () => console.log("onLastMonthPicked"), + onBeginningPicked: () => console.log("onBeginningPicked"), + onDatePicked: () => console.log("onDatePicked"), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByText("Today")); + await expect(within(canvasElement.ownerDocument.body).findByText("Jump to date")).resolves.toBeInTheDocument(); + }, +}; + +export const LongLocalizedLabel: Story = { + args: { + label: "Wednesday, December 17, 2025 at 11:59 PM Coordinated Universal Time", + }, +}; diff --git a/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorView.test.tsx b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorView.test.tsx new file mode 100644 index 0000000000..97586f5e61 --- /dev/null +++ b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorView.test.tsx @@ -0,0 +1,76 @@ +/* + * 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 } from "@test-utils"; +import { composeStories } from "@storybook/react-vite"; +import React from "react"; +import { describe, it, expect } from "vitest"; +import { waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { BaseViewModel } from "../../viewmodel/BaseViewModel"; +import { DateSeparatorView, type DateSeparatorViewModel, type DateSeparatorViewSnapshot } from "./DateSeparatorView"; +import * as stories from "./DateSeparatorView.stories"; + +const { Default, HasExtraClassNames, WithJumpToDatePicker, LongLocalizedLabel } = composeStories(stories); + +class MutableDateSeparatorViewModel + extends BaseViewModel + implements DateSeparatorViewModel +{ + public constructor(snapshot: DateSeparatorViewSnapshot) { + super(undefined, snapshot); + } + + public setSnapshot(snapshot: DateSeparatorViewSnapshot): void { + this.snapshot.set(snapshot); + } + + public onLastWeekPicked = (): void => undefined; + public onLastMonthPicked = (): void => undefined; + public onBeginningPicked = (): void => undefined; + public onDatePicked = (_dateString: string): void => undefined; +} + +describe("DateSeparatorView", () => { + it("renders default story", () => { + const { container } = render(); + expect(screen.getByText("Today")).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + + it("renders with extra class names", () => { + const { container } = render(); + expect(container.firstElementChild).toHaveClass("extra_class_1"); + expect(container.firstElementChild).toHaveClass("extra_class_2"); + expect(container).toMatchSnapshot(); + }); + + it("renders with jump to date picker story", async () => { + const { container } = render(); + await userEvent.click(screen.getByTestId("jump-to-date-separator-button")); + await expect(screen.findByTestId("jump-to-date-last-week")).resolves.toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + + it("renders long localized label story", () => { + const { container } = render(); + expect( + screen.getByText("Wednesday, December 17, 2025 at 11:59 PM Coordinated Universal Time"), + ).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + + it("updates when view model snapshot changes", async () => { + const vm = new MutableDateSeparatorViewModel({ label: "Today" }); + render(); + + expect(screen.getByText("Today", { selector: "h2" })).toBeInTheDocument(); + vm.setSnapshot({ label: "Yesterday" }); + await waitFor(() => expect(screen.getByText("Yesterday", { selector: "h2" })).toBeInTheDocument()); + }); +}); diff --git a/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorView.tsx b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorView.tsx new file mode 100644 index 0000000000..a21bed9f82 --- /dev/null +++ b/packages/shared-components/src/timeline/DateSeparatorView/DateSeparatorView.tsx @@ -0,0 +1,114 @@ +/* + * 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 classNames from "classnames"; +import React, { type JSX, useState } from "react"; + +import { type ViewModel } from "../../viewmodel/ViewModel"; +import { useViewModel } from "../../viewmodel/useViewModel"; +import styles from "./DateSeparatorView.module.css"; +import { Flex } from "../../utils/Flex"; +import { TimelineSeparator } from "../../message-body/TimelineSeparator"; +import { DateSeparatorContextMenuView } from "./DateSeparatorContextMenuView"; +import { DateSeparatorButton } from "./DateSeparatorButton"; + +export interface DateSeparatorViewSnapshot { + /** + * Visible date label and the separator's accessible label. + */ + label: string; + /** + * Controls whether the jump-to menu is rendered. + */ + jumpToEnabled?: boolean; + /** + * Reference date as input format used to prefill the jump-to-date picker value. + */ + jumpFromDate?: string; + /** + * Extra CSS classes to apply to the component. + */ + className?: string; +} + +export interface DateSeparatorViewActions { + /** Optional: Jump to messages from the last week. */ + onLastWeekPicked?: () => void; + /** Optional: Jump to messages from the last month. */ + onLastMonthPicked?: () => void; + /** Optional: Jump to the beginning of the room history. */ + onBeginningPicked?: () => void; + /** Optional: Jump to the picked date of the room history. */ + onDatePicked?: (date: string) => void; +} + +/** + * The view model for the component. + */ +export type DateSeparatorViewModel = ViewModel & DateSeparatorViewActions; + +interface DateSeparatorViewProps { + /** + * The view model for the component. + */ + vm: DateSeparatorViewModel; +} + +/** + * Renders a timeline date separator. + * When `jumpToEnabled` is true, wraps the separator label with a jump-to menu trigger. + * The tooltip is disabled while the menu is open to avoid overlap. + * + * @example + * ```tsx + * + * ``` + */ +export function DateSeparatorView({ vm }: Readonly): JSX.Element { + const { label, className, jumpToEnabled } = useViewModel(vm); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isTriggerHovered, setIsTriggerHovered] = useState(false); + const [isTriggerFocused, setIsTriggerFocused] = useState(false); + const onMenuOpenChange = (newOpen: boolean): void => { + setIsMenuOpen(newOpen); + if (newOpen) { + setIsTriggerHovered(false); + setIsTriggerFocused(false); + } + }; + + if (jumpToEnabled) { + return ( + + setIsTriggerHovered(true)} + onMouseLeave={() => setIsTriggerHovered(false)} + onFocus={(event) => setIsTriggerFocused(event.currentTarget.matches(":focus-visible"))} + onBlur={() => setIsTriggerFocused(false)} + /> + } + /> + + ); + } + + return ( + + + + + + ); +} diff --git a/packages/shared-components/src/timeline/DateSeparatorView/__snapshots__/DateSeparatorView.test.tsx.snap b/packages/shared-components/src/timeline/DateSeparatorView/__snapshots__/DateSeparatorView.test.tsx.snap new file mode 100644 index 0000000000..b29aabf86f --- /dev/null +++ b/packages/shared-components/src/timeline/DateSeparatorView/__snapshots__/DateSeparatorView.test.tsx.snap @@ -0,0 +1,138 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`DateSeparatorView > renders default story 1`] = ` +
    + +
    +`; + +exports[`DateSeparatorView > renders long localized label story 1`] = ` +
    + +
    +`; + +exports[`DateSeparatorView > renders with extra class names 1`] = ` +
    + +
    +`; + +exports[`DateSeparatorView > renders with jump to date picker story 1`] = ` +
    +
    + +
    + + + + +
    + +
    +
    +`; diff --git a/packages/shared-components/src/timeline/DateSeparatorView/index.ts b/packages/shared-components/src/timeline/DateSeparatorView/index.ts new file mode 100644 index 0000000000..6fcfdb9ea6 --- /dev/null +++ b/packages/shared-components/src/timeline/DateSeparatorView/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 { DateSeparatorView, type DateSeparatorViewModel, type DateSeparatorViewSnapshot } from "./DateSeparatorView"; 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 0000000000..b4438ae2ba --- /dev/null +++ b/packages/shared-components/src/utils/DateUtils.test.ts @@ -0,0 +1,35 @@ +/* + * 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 { describe, it, expect } from "vitest"; + +import { formatSeconds, formatDateForInput } from "./DateUtils"; + +describe("formatSeconds", () => { + it("correctly formats time with hours", () => { + expect(formatSeconds(60 * 60 * 3 + 60 * 31 + 55)).toBe("03:31:55"); + expect(formatSeconds(60 * 60 * 3 + 60 * 0 + 55)).toBe("03:00:55"); + expect(formatSeconds(60 * 60 * 3 + 60 * 31 + 0)).toBe("03:31:00"); + expect(formatSeconds(-(60 * 60 * 3 + 60 * 31 + 0))).toBe("-03:31:00"); + }); + + it("correctly formats time without hours", () => { + expect(formatSeconds(60 * 60 * 0 + 60 * 31 + 55)).toBe("31:55"); + expect(formatSeconds(60 * 60 * 0 + 60 * 0 + 55)).toBe("00:55"); + expect(formatSeconds(60 * 60 * 0 + 60 * 31 + 0)).toBe("31:00"); + expect(formatSeconds(-(60 * 60 * 0 + 60 * 31 + 0))).toBe("-31:00"); + }); +}); + +describe("formatDateForInput", () => { + it.each([["1993-11-01"], ["1066-10-14"], ["0571-04-22"], ["0062-02-05"]])( + "should format %s", + (dateString: string) => { + expect(formatDateForInput(new Date(dateString))).toBe(dateString); + }, + ); +}); diff --git a/packages/shared-components/src/utils/DateUtils.ts b/packages/shared-components/src/utils/DateUtils.ts index 146aeecbd2..6df2836d20 100644 --- a/packages/shared-components/src/utils/DateUtils.ts +++ b/packages/shared-components/src/utils/DateUtils.ts @@ -33,3 +33,17 @@ export function formatSeconds(inSeconds: number): string { return output; } + +/** + * Formats dates to be compatible with attributes of a ``. Dates + * should be formatted like "2020-06-23" (formatted according to ISO8601). + * + * @param date The date to format. + * @returns The date string in ISO8601 format ready to be used with an `` + */ +export function formatDateForInput(date: Date): string { + const year = `${date.getFullYear()}`.padStart(4, "0"); + const month = `${date.getMonth() + 1}`.padStart(2, "0"); + const day = `${date.getDate()}`.padStart(2, "0"); + return `${year}-${month}-${day}`; +} diff --git a/packages/shared-components/src/utils/humanize.ts b/packages/shared-components/src/utils/humanize.ts index 1e00e69a9d..b5953c9154 100644 --- a/packages/shared-components/src/utils/humanize.ts +++ b/packages/shared-components/src/utils/humanize.ts @@ -53,3 +53,7 @@ export function humanizeTime(timeMillis: number, i18nApi?: I18nApi): string { return _t("time|in_n_days", { num: days }); } } + +export function humanizeRelativeTime(i18nApi?: I18nApi): Intl.RelativeTimeFormat { + return new Intl.RelativeTimeFormat(i18nApi?.language, { style: "long", numeric: "auto" }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a85653cc9..5ddd735652 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,8 +22,8 @@ catalogs: specifier: 6.10.0 version: 6.10.0 '@vector-im/compound-web': - specifier: 8.3.6 - version: 8.3.6 + specifier: 8.4.0 + version: 8.4.0 matrix-web-i18n: specifier: 3.6.0 version: 3.6.0 @@ -175,7 +175,7 @@ importers: version: 6.10.0(@types/react@19.2.10)(react@19.2.4) '@vector-im/compound-web': specifier: 'catalog:' - version: 8.3.6(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(@vector-im/compound-design-tokens@6.10.0(@types/react@19.2.10)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 8.4.0(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(@vector-im/compound-design-tokens@6.10.0(@types/react@19.2.10)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@vector-im/matrix-wysiwyg': specifier: 2.40.0 version: 2.40.0(patch_hash=7bdf6150f2905bc2f055a6bcaa7b9d78fa7ffde82e800bcc454ac7b0096bd65e)(react@19.2.4) @@ -852,7 +852,7 @@ importers: version: 8.55.0(eslint@8.57.1)(typescript@5.9.3) '@vector-im/compound-web': specifier: 'catalog:' - version: 8.3.6(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(@vector-im/compound-design-tokens@6.10.0(@types/react@19.2.10)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 8.4.0(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(@vector-im/compound-design-tokens@6.10.0(@types/react@19.2.10)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@vitejs/plugin-react': specifier: ^5.1.2 version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) @@ -4604,8 +4604,8 @@ packages: react: optional: true - '@vector-im/compound-web@8.3.6': - resolution: {integrity: sha512-w7jjUJ8dXlLE2Ja/2J8Z433mtra3QF0cHDU5BGVLBBJnT/IwU4vazJRaU61dIOm3OnzDnsDreKcrMtWAUIhQkA==} + '@vector-im/compound-web@8.4.0': + resolution: {integrity: sha512-xScGP2UP04qxXBCI6D7MfzwfT0w+8k5sepXehaHleLZX0pykdPFeFp+P1WJz2NocQgEnqWIYHrp50kMrYzs0fA==} peerDependencies: '@fontsource/inconsolata': ^5 '@fontsource/inter': ^5 @@ -14836,7 +14836,7 @@ snapshots: '@types/react': 19.2.10 react: 19.2.4 - '@vector-im/compound-web@8.3.6(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(@vector-im/compound-design-tokens@6.10.0(@types/react@19.2.10)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@vector-im/compound-web@8.4.0(@fontsource/inconsolata@5.2.8)(@fontsource/inter@5.2.8)(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(@vector-im/compound-design-tokens@6.10.0(@types/react@19.2.10)(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@fontsource/inconsolata': 5.2.8 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6bfe3e5ef0..21402a0fe4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -19,7 +19,7 @@ catalog: "@element-hq/element-web-module-api": 1.9.1 # Compound "@vector-im/compound-design-tokens": 6.10.0 - "@vector-im/compound-web": 8.3.6 + "@vector-im/compound-web": 8.4.0 # i18n matrix-web-i18n: 3.6.0 # fonts