Refactor DateSeparator using MVVM and move to shared-components (#32482)

* Refactor DateSeparator using MVVM and move to shared-components

* Add a few more stories, tests and screenshots

* Use the shared component and viewmodel in element-web

* Renaming custom content property an updating snapshots

* Fix lint errors and update snapshot after merge

* Change lifecycle handling for DateSeparatoreViewModel in components where manual handling is preferrable over wrapper component.

* Move context menu from viewmodel to shared components - step 1

* Create a jump to date picker component in shared components

* Add tests for coverage and fix layout issues and roving indexes

* Make element-web use the new component

* Simplify context menu and adjusting tests

* The HTMLExport now render shared components and need a I18nContext.Provider

* Updating unit tests for context menu

* Changed to {translate: _t} to let scripts pick up translations

* Fix lint issue and updating screenshots after merge

* Update snaps for element web components

* Renaming MVVM view components with suffix View.

* Fixing problem with input date calendar icon and system dark theme

* Changed the rendering of the menu and added a separate button component

* Handle input control with useRef in onKeyDown

* Updating DateSeparator snapshots on unit tests

* Updating layout after compound Menu got a className property

* Move files to new subfolder after merge

* Updated snapshot after merge

* Updating lock file

* Updates to styling from PR review

* Updates to focus/blur functionality

* Fixed tabbing and export documentation to stories

* Updated snapshots

---------

Co-authored-by: Zack <zazi21@student.bth.se>
This commit is contained in:
rbondesson 2026-03-02 13:18:51 +01:00 committed by GitHub
parent 76cbfb1ae4
commit 11030ae68d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
48 changed files with 1819 additions and 1048 deletions

View File

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

View File

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

View File

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

View File

@ -127,20 +127,6 @@ export function formatFullDate(date: Date, showTwelveHour = false, showSeconds =
}).format(date);
}
/**
* Formats dates to be compatible with attributes of a `<input type="date">`. 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 `<input>`
*/
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.

View File

@ -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 <DateSeparatorView vm={vm} />;
}
/**
* Indicates which separator (if any) should be rendered between timeline events.
*/
@ -757,9 +769,10 @@ export default class MessagePanel extends React.Component<IProps, IState> {
const wantsSeparator = this.wantsSeparator(prevEvent, mxEv);
if (!isGrouped && this.props.room) {
if (wantsSeparator === SeparatorKind.Date) {
const separatorRoomId = this.props.room.roomId;
ret.push(
<li key={ts1}>
<DateSeparator key={ts1} roomId={this.props.room.roomId} ts={ts1} />
<li key={`${separatorRoomId}-${ts1}`}>
<DateSeparatorWrapper key={`${separatorRoomId}-${ts1}`} roomId={separatorRoomId} ts={ts1} />
</li>,
);
} else if (wantsSeparator === SeparatorKind.LateEvent) {

View File

@ -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 <DateSeparatorView vm={vm} />;
}
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(
<li key={ts + "~"}>
<DateSeparator roomId={createEvent.event.getRoomId()!} ts={ts} />
<li key={`${separatorRoomId}-${ts}~`}>
<DateSeparatorWrapper roomId={separatorRoomId} ts={ts} />
</li>,
);
}

View File

@ -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 <DateSeparatorView vm={vm} />;
}
// 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(
<li key={ts + "~"}>
<DateSeparator roomId={this.events[0].event.getRoomId()!} ts={ts} />
<li key={`${separatorRoomId}-${ts}~`}>
<DateSeparatorWrapper roomId={separatorRoomId} ts={ts} />
</li>,
);
}

View File

@ -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<IProps, IState> {
private dateSeparatorVms = new Map<string, DateSeparatorViewModel>();
public constructor(props: IProps) {
super(props);
this.state = {
@ -47,6 +50,16 @@ export default class MessageEditHistoryDialog extends React.PureComponent<IProps
};
}
private getDateSeparatorVm(roomId: string, ts: number): DateSeparatorViewModel {
const key = `${roomId}-${ts}`;
let vm = this.dateSeparatorVms.get(key);
if (!vm) {
vm = new DateSeparatorViewModel({ roomId, ts });
this.dateSeparatorVms.set(key, vm);
}
return vm;
}
private loadMoreEdits = async (backwards?: boolean): Promise<boolean> => {
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<IProps
this.loadMoreEdits();
}
public componentWillUnmount(): void {
for (const vm of this.dateSeparatorVms.values()) {
vm.dispose();
}
this.dateSeparatorVms.clear();
}
private renderEdits(): JSX.Element[] {
const nodes: JSX.Element[] = [];
let lastEvent: MatrixEvent;
@ -119,9 +139,11 @@ export default class MessageEditHistoryDialog extends React.PureComponent<IProps
const baseEventId = this.props.mxEvent.getId();
allEvents.forEach((e, i) => {
if (!lastEvent || wantsDateSeparator(lastEvent.getDate() || undefined, e.getDate() || undefined)) {
const separatorRoomId = e.getRoomId()!;
const separatorTs = e.getTs();
nodes.push(
<li key={e.getTs() + "~"}>
<DateSeparator roomId={e.getRoomId()!} ts={e.getTs()} />
<li key={`${separatorRoomId}-${separatorTs}~`}>
<DateSeparatorView vm={this.getDateSeparatorVm(separatorRoomId, separatorTs)} />
</li>,
);
}

View File

@ -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<IProps, IState> {
public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>;
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<void> => {
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<ViewRoomPayload>({
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 = (
<p>
{_t(
"room|error_jump_to_date_send_logs_prompt",
{},
{
debugLogsLink: (sub) => (
<AccessibleButton
// This is by default a `<div>` which we
// can't nest within a `<p>` 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}
</AccessibleButton>
),
},
)}
</p>
);
}
Modal.createDialog(ErrorDialog, {
title: _t("room|error_jump_to_date_title"),
description: (
<div data-testid="jump-to-date-error-content">
<p>{friendlyErrorMessage}</p>
{submitDebugLogsContent}
<details>
<summary>{_t("room|error_jump_to_date_details")}</summary>
<p>{String(err)}</p>
</details>
</div>
),
});
}
}
};
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 = (
<IconizedContextMenu
{...contextMenuBelow(this.state.contextMenuPosition)}
onFinished={this.onContextMenuCloseClick}
>
<IconizedContextMenuOptionList first>
<IconizedContextMenuOption
label={capitalize(relativeTimeFormat.format(-1, "week"))}
onClick={this.onLastWeekClicked}
data-testid="jump-to-date-last-week"
/>
<IconizedContextMenuOption
label={capitalize(relativeTimeFormat.format(-1, "month"))}
onClick={this.onLastMonthClicked}
data-testid="jump-to-date-last-month"
/>
<IconizedContextMenuOption
label={_t("room|jump_to_date_beginning")}
onClick={this.onTheBeginningClicked}
data-testid="jump-to-date-beginning"
/>
</IconizedContextMenuOptionList>
<IconizedContextMenuOptionList>
<JumpToDatePicker ts={this.props.ts} onDatePicked={this.onDatePicked} />
</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
}
return (
<ContextMenuTooltipButton
className="mx_DateSeparator_jumpToDateMenu mx_DateSeparator_dateContent"
data-testid="jump-to-date-separator-button"
onClick={this.onContextMenuOpenClick}
isExpanded={!!this.state.contextMenuPosition}
title={_t("room|jump_to_date")}
>
<h2 className="mx_DateSeparator_dateHeading" aria-hidden="true">
{this.getLabel()}
</h2>
<ChevronDownIcon className="mx_DateSeparator_chevron" />
{contextMenu}
</ContextMenuTooltipButton>
);
}
public render(): React.ReactNode {
const label = this.getLabel();
let dateHeaderContent: JSX.Element;
if (this.state.jumpToDateEnabled && !this.props.forExport) {
dateHeaderContent = this.renderJumpToDateMenu();
} else {
dateHeaderContent = (
<div className="mx_DateSeparator_dateContent">
<h2 className="mx_DateSeparator_dateHeading" aria-hidden="true">
{label}
</h2>
</div>
);
}
return (
<TimelineSeparator label={label} className="mx_TimelineSeparator">
{dateHeaderContent}
</TimelineSeparator>
);
}
}

View File

@ -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<IProps> = ({ ts, onDatePicked }: IProps) => {
const date = new Date(ts);
const dateInputDefaultValue = formatDateForInput(date);
const [dateValue, setDateValue] = useState(dateInputDefaultValue);
const [onFocus, isActive, refCallback] = useRovingTabIndex<HTMLInputElement>();
const onDateValueInput = (ev: React.InputEvent<HTMLInputElement>): void => setDateValue(ev.currentTarget.value);
const onJumpToDateSubmit = (ev: FormEvent): void => {
ev.preventDefault();
onDatePicked(dateValue);
};
return (
<form className="mx_JumpToDatePicker_form" onSubmit={onJumpToDateSubmit}>
<span className="mx_JumpToDatePicker_label">{_t("room|jump_to_date")}</span>
<Field
element="input"
type="date"
onInput={onDateValueInput}
value={dateValue}
// Prevent people from selecting a day in the future (there won't be any
// events there anyway).
max={formatDateForInput(new Date())}
className="mx_JumpToDatePicker_datePicker"
label={_t("room|jump_to_date_prompt")}
onFocus={onFocus}
inputRef={refCallback}
tabIndex={isActive ? 0 : -1}
/>
<RovingAccessibleButton
element="button"
type="submit"
kind="primary"
className="mx_JumpToDatePicker_submitButton"
onClick={onJumpToDateSubmit}
>
{_t("action|go")}
</RovingAccessibleButton>
</form>
);
};
export default JumpToDatePicker;

View File

@ -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 <DateSeparatorView vm={vm} />;
}
export default class SearchResultTile extends React.Component<IProps> {
public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>;
@ -57,7 +66,10 @@ export default class SearchResultTile extends React.Component<IProps> {
const eventId = resultEvent.getId();
const ts1 = resultEvent.getTs();
const ret = [<DateSeparator key={ts1 + "-search"} roomId={resultEvent.getRoomId()!} ts={ts1} />];
const separatorRoomId = resultEvent.getRoomId()!;
const ret = [
<DateSeparatorWrapper key={`${separatorRoomId}-${ts1}-search`} roomId={separatorRoomId} ts={ts1} />,
];
const layout = SettingsStore.getValue("layout");
const isTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
const alwaysShowTimestamps = SettingsStore.getValue("alwaysShowTimestamps");

View File

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

View File

@ -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(
<I18nContext.Provider value={window.mxModuleApi.i18n}>{element}</I18nContext.Provider>,
);
}
protected async getRoomAvatar(): Promise<string> {
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 = (
<BaseAvatar size="32px" name={this.room.name} title={this.room.name} url={blob ? avatarPath : ""} />
);
return renderToStaticMarkup(avatar);
return this.renderToStaticMarkupWithProviders(avatar);
}
protected async wrapHTML(content: string, currentPage: number, nbPages: number): Promise<string> {
@ -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(
<p>
{_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 ? (
<div style={{ textAlign: "center" }}>
<a href={`./messages${currentPage === 1 ? "" : currentPage}.html`} style={{ fontWeight: "bold" }}>
@ -135,7 +141,7 @@ export default class HTMLExporter extends Exporter {
),
);
const nextMessagesLink = renderToStaticMarkup(
const nextMessagesLink = this.renderToStaticMarkupWithProviders(
currentPage < nbPages - 1 ? (
<div style={{ textAlign: "center", margin: "10px" }}>
<a href={"./messages" + (currentPage + 2) + ".html"} style={{ fontWeight: "bold" }}>
@ -252,12 +258,21 @@ export default class HTMLExporter extends Exporter {
protected getDateSeparator(event: MatrixEvent): string {
const ts = event.getTs();
const dateSeparator = (
<li key={ts}>
<DateSeparator forExport={true} key={ts} roomId={event.getRoomId()!} ts={ts} />
</li>
);
return renderToStaticMarkup(dateSeparator);
const dateSeparatorViewModel = new DateSeparatorViewModel({
roomId: event.getRoomId()!,
ts,
forExport: true,
});
try {
const dateSeparator = (
<li key={ts}>
<DateSeparatorView vm={dateSeparatorViewModel} />
</li>
);
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) {

View File

@ -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<DateSeparatorViewSnapshotInterface, DateSeparatorViewModelProps>
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<void> => {
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<ViewRoomPayload>({
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 = (
<p>
{_t(
"room|error_jump_to_date_send_logs_prompt",
{},
{
debugLogsLink: (sub) => (
// This is by default a `<div>` which we
// can't nest within a `<p>` here so update
// this to a be a inline anchor element.
<AccessibleButton
element="a"
kind="link"
onClick={() => this.onBugReport(err instanceof Error ? err : undefined)}
data-testid="jump-to-date-error-submit-debug-logs-button"
>
{sub}
</AccessibleButton>
),
},
)}
</p>
);
}
Modal.createDialog(ErrorDialog, {
title: _t("room|error_jump_to_date_title"),
description: (
<div data-testid="jump-to-date-error-content">
<p>{friendlyErrorMessage}</p>
{submitDebugLogsContent}
<details>
<summary>{_t("room|error_jump_to_date_details")}</summary>
<p>{String(err)}</p>
</details>
</div>
),
});
}
}
};
public onBugReport = (err?: Error): void => {
Modal.createDialog(BugReportDialog, {
error: err,
initialText: "Error occured while using jump to date #jump-to-date",
});
};
public onLastWeekPicked = (): Promise<void> => {
const date = new Date();
date.setDate(date.getDate() - 7);
void this.pickDate(date);
return Promise.resolve();
};
public onLastMonthPicked = (): Promise<void> => {
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> => {
void this.pickDate(new Date(0));
return Promise.resolve();
};
public onDatePicked = (dateString: string): Promise<void> => {
void this.pickDate(dateString);
return Promise.resolve();
};
}

View File

@ -47,11 +47,11 @@ exports[`MessagePanel should handle lots of membership events quickly 1`] = `
role="none"
/>
<div
class="mx_DateSeparator_dateContent"
class="_flex_4dswl_9 _content_5dcky_8"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h2
aria-hidden="true"
class="mx_DateSeparator_dateHeading"
>
Thu, Jan 1, 1970
</h2>

View File

@ -49,11 +49,11 @@ exports[`<MessageEditHistory /> should match the snapshot 1`] = `
role="none"
/>
<div
class="mx_DateSeparator_dateContent"
class="_flex_4dswl_9 _content_5dcky_8"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h2
aria-hidden="true"
class="mx_DateSeparator_dateHeading"
>
Thu, Jan 1, 1970
</h2>
@ -180,11 +180,11 @@ exports[`<MessageEditHistory /> should support events with 1`] = `
role="none"
/>
<div
class="mx_DateSeparator_dateContent"
class="_flex_4dswl_9 _content_5dcky_8"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h2
aria-hidden="true"
class="mx_DateSeparator_dateHeading"
>
Thu, Jan 1, 1970
</h2>

View File

@ -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(
<MatrixClientContext.Provider value={mockClient}>
<ScopedRoomContextProvider {...defaultRoomContext}>
<DateSeparator {...defaultProps} {...props} />
</ScopedRoomContextProvider>
</MatrixClientContext.Provider>,
);
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(),
);
});
});
});
});

View File

@ -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(
<JumpToDatePicker ts={new Date("2020-07-04T05:55:00.000Z").getTime()} onDatePicked={() => {}} />,
);
expect(asFragment()).toMatchSnapshot();
});
});

View File

@ -1,103 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`DateSeparator renders invalid date separator correctly 1`] = `
<DocumentFragment>
<div
aria-label="Invalid timestamp"
class="_flex_4dswl_9 mx_TimelineSeparator _timelineSeparator_yq5ye_8"
role="separator"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<hr
role="none"
/>
<div
class="mx_DateSeparator_dateContent"
>
<h2
aria-hidden="true"
class="mx_DateSeparator_dateHeading"
>
Invalid timestamp
</h2>
</div>
<hr
role="none"
/>
</div>
</DocumentFragment>
`;
exports[`DateSeparator renders the date separator correctly 1`] = `
<DocumentFragment>
<div
aria-label="today"
class="_flex_4dswl_9 mx_TimelineSeparator _timelineSeparator_yq5ye_8"
role="separator"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<hr
role="none"
/>
<div
class="mx_DateSeparator_dateContent"
>
<h2
aria-hidden="true"
class="mx_DateSeparator_dateHeading"
>
today
</h2>
</div>
<hr
role="none"
/>
</div>
</DocumentFragment>
`;
exports[`DateSeparator when feature_jump_to_date is enabled renders the date separator correctly 1`] = `
<DocumentFragment>
<div
aria-label="Fri, Dec 17, 2021"
class="_flex_4dswl_9 mx_TimelineSeparator _timelineSeparator_yq5ye_8"
role="separator"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<hr
role="none"
/>
<div
aria-expanded="false"
aria-haspopup="true"
aria-label="Jump to date"
class="mx_AccessibleButton mx_DateSeparator_jumpToDateMenu mx_DateSeparator_dateContent"
data-testid="jump-to-date-separator-button"
role="button"
tabindex="0"
>
<h2
aria-hidden="true"
class="mx_DateSeparator_dateHeading"
>
Fri, Dec 17, 2021
</h2>
<svg
class="mx_DateSeparator_chevron"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
<hr
role="none"
/>
</div>
</DocumentFragment>
`;

View File

@ -1,41 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`JumpToDatePicker renders the date picker correctly 1`] = `
<DocumentFragment>
<form
class="mx_JumpToDatePicker_form"
>
<span
class="mx_JumpToDatePicker_label"
>
Jump to date
</span>
<div
class="mx_Field mx_Field_input mx_JumpToDatePicker_datePicker"
>
<input
id="mx_Field_1"
label="Pick a date to jump to"
max="2021-12-17"
placeholder="Pick a date to jump to"
tabindex="-1"
type="date"
value="2020-07-04"
/>
<label
for="mx_Field_1"
>
Pick a date to jump to
</label>
</div>
<button
class="mx_AccessibleButton mx_JumpToDatePicker_submitButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary"
role="button"
tabindex="-1"
type="submit"
>
Go
</button>
</form>
</DocumentFragment>
`;

View File

@ -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"],

File diff suppressed because one or more lines are too long

View File

@ -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<string, (...args: any[]) => 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<typeof defaultProps> & { 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<void> => vm.onLastWeekPicked(),
},
{
timeDescriptor: "last month",
run: (vm: DateSeparatorViewModel): Promise<void> => vm.onLastMonthPicked(),
},
{
timeDescriptor: "the beginning",
run: (vm: DateSeparatorViewModel): Promise<void> => 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);
});
});
});

View File

@ -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.",

View File

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

View File

@ -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<TimelineSeparatorProps> = ({ label, className, children }) => {
const TimelineSeparator: React.FC<TimelineSeparatorProps> = ({ label, className, children, role = "separator" }) => {
// ARIA treats <hr/>s as separators, here we abuse them slightly so manually treat this entire thing as one
return (
<Flex
className={classNames(className, styles.timelineSeparator)}
role="separator"
aria-label={label}
role={role}
aria-label={role === "separator" ? label : undefined}
align="center"
>
<hr role="none" />

View File

@ -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<HTMLDivElement>;
/** Called when the pointer enters the button trigger. */
onMouseEnter?: React.MouseEventHandler<HTMLDivElement>;
/** Called when the pointer leaves the button trigger. */
onMouseLeave?: React.MouseEventHandler<HTMLDivElement>;
/** Called when the button trigger receives focus. */
onFocus?: React.FocusEventHandler<HTMLDivElement>;
/** Called when the button trigger loses focus. */
onBlur?: React.FocusEventHandler<HTMLDivElement>;
}
/** 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 (
<Tooltip description={_t("room|jump_to_date")} placement="right" open={tooltipOpen}>
<Flex
ref={buttonRef}
data-testid="jump-to-date-separator-button"
className={className}
aria-live="off"
aria-label={_t("room|jump_to_date")}
role="button"
tabIndex={0}
{...props}
>
<h2 aria-hidden="true">{label}</h2>
<ChevronDownIcon />
</Flex>
</Tooltip>
);
}

View File

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

View File

@ -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(
<DateSeparatorContextMenuView
vm={vm}
open={open}
onOpenChange={onOpenChange}
trigger={
<button type="button" data-testid="jump-to-trigger">
Trigger
</button>
}
/>,
);
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);
});
});

View File

@ -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<PropsWithChildren<DateSeparatorContextMenuViewProps>> = ({
vm,
open,
trigger,
onOpenChange,
}): JSX.Element => {
const i18n = useI18n();
const { translate: _t } = useI18n();
const dateInputRef = useRef<HTMLInputElement>(null);
const onKeyDown = (event: React.KeyboardEvent<HTMLElement>): void => {
if (event.key !== "ArrowDown") return;
event.preventDefault();
dateInputRef.current?.focus();
};
return (
<Menu
open={open}
onOpenChange={(newOpen) => {
onOpenChange?.(newOpen);
}}
title={_t("room|jump_to_date")}
showTitle={false}
trigger={trigger}
align="start"
className={styles.picker_menu}
>
<MenuItem
label={capitalize(humanizeRelativeTime(i18n).format(-1, "week"))}
onSelect={() => vm.onLastWeekPicked?.()}
data-testid="jump-to-date-last-week"
hideChevron={true}
className={styles.picker_menu_item}
/>
<MenuItem
label={capitalize(humanizeRelativeTime(i18n).format(-1, "month"))}
onSelect={() => vm.onLastMonthPicked?.()}
data-testid="jump-to-date-last-month"
hideChevron={true}
className={styles.picker_menu_item}
/>
<MenuItem
label={_t("room|jump_to_date_beginning")}
onSelect={() => vm.onBeginningPicked?.()}
data-testid="jump-to-date-beginning"
hideChevron={true}
className={styles.picker_menu_item}
onKeyDown={onKeyDown}
/>
<Separator decorative className={styles.picker_separator} />
<DateSeparatorDatePickerView
vm={vm}
inputRef={dateInputRef}
onSubmitted={() => onOpenChange?.(false)}
onDismissed={() => onOpenChange?.(false)}
/>
</Menu>
);
};

View File

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

View File

@ -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<HTMLInputElement | null>;
/** 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<DateSeparatorDatePickerViewProps> = ({
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<HTMLInputElement>(null);
const dateInputRef = inputRef ?? localDateInputRef;
const submitButtonRef = useRef<HTMLButtonElement>(null);
const onDateInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
if (event.key === "Tab") {
if (event.shiftKey) {
onDismissed?.();
} else {
event.preventDefault();
submitButtonRef.current?.focus();
}
}
};
const onDateValueInput = (event: React.InputEvent<HTMLInputElement>): void => {
setDateValue(event.currentTarget.value);
};
const submitDate = (): void => {
vm.onDatePicked?.(dateValue);
onSubmitted?.();
};
const onJumpToDateSubmit = (event: React.SubmitEvent<HTMLFormElement>): void => {
event.preventDefault();
submitDate();
};
const onSubmitButtonKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>): 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 (
<MenuItem
as="div"
data-testid="jump-to-date-picker"
label={_t("room|jump_to_date")}
onSelect={keepMenuOpenOnSelect}
hideChevron={true}
className={styles.picker_menu_item}
>
<Root className={styles.picker_form} onSubmit={onJumpToDateSubmit}>
<Field name="jump-to-date-field" className={styles.picker_input}>
<TextControl
ref={dateInputRef}
id={dateInputId}
type="date"
aria-label={_t("room|jump_to_date_prompt")}
onInput={onDateValueInput}
onKeyDown={onDateInputKeyDown}
value={dateValue}
max={formatDateForInput(new Date())}
className={styles.picker_input_date}
/>
</Field>
<Submit
ref={submitButtonRef}
className={styles.picker_button}
type="submit"
kind="primary"
size="sm"
onKeyDown={onSubmitButtonKeyDown}
>
{_t("action|go")}
</Submit>
</Root>
</MenuItem>
);
};

View File

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

View File

@ -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 <DateSeparatorView vm={vm} />;
};
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<typeof DateSeparatorViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
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",
},
};

View File

@ -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<DateSeparatorViewSnapshot, undefined>
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(<Default />);
expect(screen.getByText("Today")).toBeInTheDocument();
expect(container).toMatchSnapshot();
});
it("renders with extra class names", () => {
const { container } = render(<HasExtraClassNames />);
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(<WithJumpToDatePicker />);
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(<LongLocalizedLabel />);
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(<DateSeparatorView vm={vm} />);
expect(screen.getByText("Today", { selector: "h2" })).toBeInTheDocument();
vm.setSnapshot({ label: "Yesterday" });
await waitFor(() => expect(screen.getByText("Yesterday", { selector: "h2" })).toBeInTheDocument());
});
});

View File

@ -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<DateSeparatorViewSnapshot> & 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
* <DateSeparatorView vm={vm} />
* ```
*/
export function DateSeparatorView({ vm }: Readonly<DateSeparatorViewProps>): 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 (
<TimelineSeparator label={label} className={classNames(className)} role="none">
<DateSeparatorContextMenuView
vm={vm}
open={isMenuOpen}
onOpenChange={onMenuOpenChange}
trigger={
<DateSeparatorButton
label={label}
tooltipOpen={!isMenuOpen && (isTriggerHovered || isTriggerFocused)}
className={styles.content}
onMouseEnter={() => setIsTriggerHovered(true)}
onMouseLeave={() => setIsTriggerHovered(false)}
onFocus={(event) => setIsTriggerFocused(event.currentTarget.matches(":focus-visible"))}
onBlur={() => setIsTriggerFocused(false)}
/>
}
/>
</TimelineSeparator>
);
}
return (
<TimelineSeparator label={label} className={classNames(className)}>
<Flex className={styles.content}>
<h2 aria-hidden="true">{label}</h2>
</Flex>
</TimelineSeparator>
);
}

View File

@ -0,0 +1,138 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DateSeparatorView > renders default story 1`] = `
<div>
<div
aria-label="Today"
class="flex timelineSeparator"
role="separator"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<hr
role="none"
/>
<div
class="flex content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h2
aria-hidden="true"
>
Today
</h2>
</div>
<hr
role="none"
/>
</div>
</div>
`;
exports[`DateSeparatorView > renders long localized label story 1`] = `
<div>
<div
aria-label="Wednesday, December 17, 2025 at 11:59 PM Coordinated Universal Time"
class="flex timelineSeparator"
role="separator"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<hr
role="none"
/>
<div
class="flex content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h2
aria-hidden="true"
>
Wednesday, December 17, 2025 at 11:59 PM Coordinated Universal Time
</h2>
</div>
<hr
role="none"
/>
</div>
</div>
`;
exports[`DateSeparatorView > renders with extra class names 1`] = `
<div>
<div
aria-label="Today"
class="flex extra_class_1 extra_class_2 timelineSeparator"
role="separator"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<hr
role="none"
/>
<div
class="flex content"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<h2
aria-hidden="true"
>
Today
</h2>
</div>
<hr
role="none"
/>
</div>
</div>
`;
exports[`DateSeparatorView > renders with jump to date picker story 1`] = `
<div>
<div
class="flex timelineSeparator"
role="none"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
>
<hr
aria-hidden="true"
data-aria-hidden="true"
role="none"
/>
<div
aria-controls="radix-_r_1_"
aria-expanded="true"
aria-haspopup="menu"
aria-label="Jump to date"
aria-live="off"
class="flex content"
data-state="open"
data-testid="jump-to-date-separator-button"
id="radix-_r_0_"
role="button"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;"
tabindex="0"
type="button"
>
<h2
aria-hidden="true"
>
Today
</h2>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 14.95q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-4.6-4.6a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l3.9 3.9 3.9-3.9a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-4.6 4.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063"
/>
</svg>
</div>
<hr
aria-hidden="true"
data-aria-hidden="true"
role="none"
/>
</div>
</div>
`;

View File

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

View File

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

View File

@ -33,3 +33,17 @@ export function formatSeconds(inSeconds: number): string {
return output;
}
/**
* Formats dates to be compatible with attributes of a `<input type="date">`. 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 `<input>`
*/
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}`;
}

View File

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

14
pnpm-lock.yaml generated
View File

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

View File

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