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 0000000000..6bc4cc3de2 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/default-auto.png differ 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 0000000000..6bc4cc3de2 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/has-extra-class-names-auto.png differ 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 0000000000..f3e3b3ae15 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/long-localized-label-auto.png differ 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 0000000000..a2df011843 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/with-jump-to-date-picker-auto.png differ 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 0000000000..811dbfd067 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/timeline/DateSeparatorView/DateSeparatorView.stories.tsx/with-jump-to-tooltip-auto.png differ diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index ff13817fd1..e4187edd63 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -7,6 +7,7 @@ "dismiss": "Dismiss", "edit": "Edit", "explore_rooms": "Explore rooms", + "go": "Go", "invite": "Invite", "new_conversation": "New conversation", "new_room": "New room", @@ -42,6 +43,9 @@ "shared": "New members see history", "world_readable": "Anyone can see history" }, + "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", "status_bar": { "delete_all": "Delete all", "exceeded_resource_limit_description": "Please contact your service administrator to continue using the service.", diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 865823fb54..fffbce1c75 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -35,6 +35,7 @@ export * from "./room-list/RoomListView"; export * from "./room-list/RoomListItemView"; export * from "./room-list/RoomListPrimaryFilters"; export * from "./room-list/VirtualizedRoomListView"; +export * from "./timeline/DateSeparatorView/"; export * from "./utils/Box"; export * from "./utils/Flex"; export * from "./right-panel/WidgetContextMenu"; diff --git a/packages/shared-components/src/message-body/TimelineSeparator/TimelineSeparator.tsx b/packages/shared-components/src/message-body/TimelineSeparator/TimelineSeparator.tsx index cd3af1c1a7..3097f09afe 100644 --- a/packages/shared-components/src/message-body/TimelineSeparator/TimelineSeparator.tsx +++ b/packages/shared-components/src/message-body/TimelineSeparator/TimelineSeparator.tsx @@ -27,6 +27,12 @@ export interface TimelineSeparatorProps { * Optional children to render inside the timeline separator */ children?: PropsWithChildren["children"]; + /** + * ARIA role for the separator container. + * Use "none" when the separator contains interactive controls. + * @default "separator" + */ + role?: "separator" | "none"; } /** @@ -35,13 +41,13 @@ export interface TimelineSeparatorProps { * @param label the accessible label string describing the separator * @param children the children to draw within the timeline separator */ -const TimelineSeparator: React.FC = ({ label, className, children }) => { +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