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
48 changed files with 1819 additions and 1048 deletions
@@ -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();
};
}