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:
@@ -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();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user