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:
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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