mirror of https://github.com/vector-im/riot-web
Add jump to date functionality to date headers in timeline v2 (#7339)
Fix https://github.com/vector-im/element-web/issues/7677 Utilizes MSC3030: https://github.com/matrix-org/matrix-doc/pull/3030 https://user-images.githubusercontent.com/558581/150060664-79627573-f4fd-497c-b726-dc3485854bd0.pngpull/21833/head
parent
efa1667d7e
commit
7fa27f5834
|
@ -183,6 +183,7 @@
|
|||
@import "./views/messages/_CallEvent.scss";
|
||||
@import "./views/messages/_CreateEvent.scss";
|
||||
@import "./views/messages/_DateSeparator.scss";
|
||||
@import "./views/messages/_JumpToDatePicker.scss";
|
||||
@import "./views/messages/_EventTileBubble.scss";
|
||||
@import "./views/messages/_HiddenBody.scss";
|
||||
@import "./views/messages/_MEmoteBody.scss";
|
||||
|
|
|
@ -50,21 +50,21 @@ limitations under the License.
|
|||
}
|
||||
|
||||
// round the top corners of the top button for the hover effect to be bounded
|
||||
&:first-child .mx_AccessibleButton:first-child {
|
||||
&:first-child .mx_AccessibleButton:not(.mx_AccessibleButton_hasKind):first-child {
|
||||
border-radius: 8px 8px 0 0; // radius matches .mx_ContextualMenu
|
||||
}
|
||||
|
||||
// round the bottom corners of the bottom button for the hover effect to be bounded
|
||||
&:last-child .mx_AccessibleButton:last-child {
|
||||
&:last-child .mx_AccessibleButton:not(.mx_AccessibleButton_hasKind):last-child {
|
||||
border-radius: 0 0 8px 8px; // radius matches .mx_ContextualMenu
|
||||
}
|
||||
|
||||
// round all corners of the only button for the hover effect to be bounded
|
||||
&:first-child:last-child .mx_AccessibleButton:first-child:last-child {
|
||||
&:first-child:last-child .mx_AccessibleButton:not(.mx_AccessibleButton_hasKind):first-child:last-child {
|
||||
border-radius: 8px; // radius matches .mx_ContextualMenu
|
||||
}
|
||||
|
||||
.mx_AccessibleButton {
|
||||
.mx_AccessibleButton:not(.mx_AccessibleButton_hasKind) {
|
||||
// pad the inside of the button so that the hover background is padded too
|
||||
padding-top: 12px;
|
||||
padding-bottom: 12px;
|
||||
|
@ -130,7 +130,7 @@ limitations under the License.
|
|||
}
|
||||
|
||||
.mx_IconizedContextMenu_optionList_red {
|
||||
.mx_AccessibleButton {
|
||||
.mx_AccessibleButton:not(.mx_AccessibleButton_hasKind) {
|
||||
color: $alert !important;
|
||||
}
|
||||
|
||||
|
@ -148,7 +148,7 @@ limitations under the License.
|
|||
}
|
||||
|
||||
.mx_IconizedContextMenu_active {
|
||||
&.mx_AccessibleButton, .mx_AccessibleButton {
|
||||
&.mx_AccessibleButton:not(.mx_AccessibleButton_hasKind), .mx_AccessibleButton:not(.mx_AccessibleButton_hasKind) {
|
||||
color: $accent !important;
|
||||
}
|
||||
|
||||
|
|
|
@ -33,3 +33,18 @@ limitations under the License.
|
|||
margin: 0 25px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.mx_DateSeparator_jumpToDateMenu {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mx_DateSeparator_chevron {
|
||||
align-self: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
mask-position: center;
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
|
||||
background-color: $tertiary-content;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
.mx_JumpToDatePicker_form {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
|
@ -721,8 +721,12 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
|
||||
// do we need a date separator since the last event?
|
||||
const wantsDateSeparator = this.wantsDateSeparator(prevEvent, eventDate);
|
||||
if (wantsDateSeparator && !isGrouped) {
|
||||
const dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1} /></li>;
|
||||
if (wantsDateSeparator && !isGrouped && this.props.room) {
|
||||
const dateSeparator = (
|
||||
<li key={ts1}>
|
||||
<DateSeparator key={ts1} roomId={this.props.room.roomId} ts={ts1} />
|
||||
</li>
|
||||
);
|
||||
ret.push(dateSeparator);
|
||||
}
|
||||
|
||||
|
@ -1118,7 +1122,7 @@ class CreationGrouper extends BaseGrouper {
|
|||
if (panel.wantsDateSeparator(this.prevEvent, createEvent.getDate())) {
|
||||
const ts = createEvent.getTs();
|
||||
ret.push(
|
||||
<li key={ts+'~'}><DateSeparator key={ts+'~'} ts={ts} /></li>,
|
||||
<li key={ts+'~'}><DateSeparator key={ts+'~'} roomId={createEvent.getRoomId()} ts={ts} /></li>,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1231,7 +1235,7 @@ class RedactionGrouper extends BaseGrouper {
|
|||
if (panel.wantsDateSeparator(this.prevEvent, this.events[0].getDate())) {
|
||||
const ts = this.events[0].getTs();
|
||||
ret.push(
|
||||
<li key={ts+'~'}><DateSeparator key={ts+'~'} ts={ts} /></li>,
|
||||
<li key={ts+'~'}><DateSeparator key={ts+'~'} roomId={this.events[0].getRoomId()} ts={ts} /></li>,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1327,7 +1331,7 @@ class MemberGrouper extends BaseGrouper {
|
|||
if (panel.wantsDateSeparator(this.prevEvent, this.events[0].getDate())) {
|
||||
const ts = this.events[0].getTs();
|
||||
ret.push(
|
||||
<li key={ts+'~'}><DateSeparator key={ts+'~'} ts={ts} /></li>,
|
||||
<li key={ts+'~'}><DateSeparator key={ts+'~'} roomId={this.events[0].getRoomId()} ts={ts} /></li>,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -1429,7 +1433,7 @@ class HiddenEventGrouper extends BaseGrouper {
|
|||
if (panel.wantsDateSeparator(this.prevEvent, this.events[0].getDate())) {
|
||||
const ts = this.events[0].getTs();
|
||||
ret.push(
|
||||
<li key={ts+'~'}><DateSeparator key={ts+'~'} ts={ts} /></li>,
|
||||
<li key={ts+'~'}><DateSeparator key={ts+'~'} roomId={this.events[0].getRoomId()} ts={ts} /></li>,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -130,7 +130,7 @@ export default class MessageEditHistoryDialog extends React.PureComponent<IProps
|
|||
const baseEventId = this.props.mxEvent.getId();
|
||||
allEvents.forEach((e, i) => {
|
||||
if (!lastEvent || wantsDateSeparator(lastEvent.getDate(), e.getDate())) {
|
||||
nodes.push(<li key={e.getTs() + "~"}><DateSeparator ts={e.getTs()} /></li>);
|
||||
nodes.push(<li key={e.getTs() + "~"}><DateSeparator roomId={e.getRoomId()} ts={e.getTs()} /></li>);
|
||||
}
|
||||
const isBaseEvent = e.getId() === baseEventId;
|
||||
nodes.push((
|
||||
|
|
|
@ -14,12 +14,13 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react';
|
||||
import React, { InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes, RefObject } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { debounce } from "lodash";
|
||||
|
||||
import * as sdk from '../../../index';
|
||||
import { IFieldState, IValidationResult } from "./Validation";
|
||||
import { ComponentClass } from "../../../@types/common";
|
||||
|
||||
// Invoke validation from user input (when typing, etc.) at most once every N ms.
|
||||
const VALIDATION_THROTTLE_MS = 200;
|
||||
|
@ -78,26 +79,45 @@ interface IProps {
|
|||
}
|
||||
|
||||
export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
|
||||
// The ref pass through to the input
|
||||
inputRef?: RefObject<HTMLInputElement>;
|
||||
// The element to create. Defaults to "input".
|
||||
element?: "input";
|
||||
componentClass?: undefined;
|
||||
// The input's value. This is a controlled component, so the value is required.
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ISelectProps extends IProps, SelectHTMLAttributes<HTMLSelectElement> {
|
||||
// The ref pass through to the select
|
||||
inputRef?: RefObject<HTMLSelectElement>;
|
||||
// To define options for a select, use <Field><option ... /></Field>
|
||||
element: "select";
|
||||
componentClass?: undefined;
|
||||
// The select's value. This is a controlled component, so the value is required.
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ITextareaProps extends IProps, TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
// The ref pass through to the textarea
|
||||
inputRef?: RefObject<HTMLTextAreaElement>;
|
||||
element: "textarea";
|
||||
componentClass?: undefined;
|
||||
// The textarea's value. This is a controlled component, so the value is required.
|
||||
value: string;
|
||||
}
|
||||
|
||||
type PropShapes = IInputProps | ISelectProps | ITextareaProps;
|
||||
export interface INativeOnChangeInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
|
||||
// The ref pass through to the input
|
||||
inputRef?: RefObject<HTMLInputElement>;
|
||||
element: "input";
|
||||
// The custom component to render
|
||||
componentClass: ComponentClass;
|
||||
// The input's value. This is a controlled component, so the value is required.
|
||||
value: string;
|
||||
}
|
||||
|
||||
type PropShapes = IInputProps | ISelectProps | ITextareaProps | INativeOnChangeInputProps;
|
||||
|
||||
interface IState {
|
||||
valid: boolean;
|
||||
|
@ -108,7 +128,7 @@ interface IState {
|
|||
|
||||
export default class Field extends React.PureComponent<PropShapes, IState> {
|
||||
private id: string;
|
||||
private input: HTMLInputElement;
|
||||
private inputRef: RefObject<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>;
|
||||
|
||||
public static readonly defaultProps = {
|
||||
element: "input",
|
||||
|
@ -146,7 +166,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
}
|
||||
|
||||
public focus() {
|
||||
this.input.focus();
|
||||
this.inputRef.current?.focus();
|
||||
// programmatic does not fire onFocus handler
|
||||
this.setState({
|
||||
focused: true,
|
||||
|
@ -197,7 +217,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
if (!this.props.onValidate) {
|
||||
return;
|
||||
}
|
||||
const value = this.input ? this.input.value : null;
|
||||
const value = this.inputRef.current?.value ?? null;
|
||||
const { valid, feedback } = await this.props.onValidate({
|
||||
value,
|
||||
focused,
|
||||
|
@ -228,13 +248,13 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
|
||||
public render() {
|
||||
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
|
||||
const { element, prefixComponent, postfixComponent, className, onValidate, children,
|
||||
const { element, componentClass, inputRef, prefixComponent, postfixComponent, className, onValidate, children,
|
||||
tooltipContent, forceValidity, tooltipClassName, list, validateOnBlur, validateOnChange, validateOnFocus,
|
||||
usePlaceholderAsHint, forceTooltipVisible,
|
||||
...inputProps } = this.props;
|
||||
|
||||
// Set some defaults for the <input> element
|
||||
const ref = input => this.input = input;
|
||||
this.inputRef = inputRef || React.createRef();
|
||||
|
||||
inputProps.placeholder = inputProps.placeholder || inputProps.label;
|
||||
inputProps.id = this.id; // this overwrites the id from props
|
||||
|
||||
|
@ -243,9 +263,9 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
inputProps.onBlur = this.onBlur;
|
||||
|
||||
// Appease typescript's inference
|
||||
const inputProps_ = { ...inputProps, ref, list };
|
||||
const inputProps_ = { ...inputProps, ref: this.inputRef, list };
|
||||
|
||||
const fieldInput = React.createElement(this.props.element, inputProps_, children);
|
||||
const fieldInput = React.createElement(this.props.componentClass || this.props.element, inputProps_, children);
|
||||
|
||||
let prefixContainer = null;
|
||||
if (prefixComponent) {
|
||||
|
@ -257,17 +277,22 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
|
|||
}
|
||||
|
||||
const hasValidationFlag = forceValidity !== null && forceValidity !== undefined;
|
||||
const fieldClasses = classNames("mx_Field", `mx_Field_${this.props.element}`, className, {
|
||||
// If we have a prefix element, leave the label always at the top left and
|
||||
// don't animate it, as it looks a bit clunky and would add complexity to do
|
||||
// properly.
|
||||
mx_Field_labelAlwaysTopLeft: prefixComponent || usePlaceholderAsHint,
|
||||
mx_Field_placeholderIsHint: usePlaceholderAsHint,
|
||||
mx_Field_valid: hasValidationFlag ? forceValidity : onValidate && this.state.valid === true,
|
||||
mx_Field_invalid: hasValidationFlag
|
||||
? !forceValidity
|
||||
: onValidate && this.state.valid === false,
|
||||
});
|
||||
const fieldClasses = classNames(
|
||||
"mx_Field",
|
||||
`mx_Field_${this.props.element}`,
|
||||
className,
|
||||
{
|
||||
// If we have a prefix element, leave the label always at the top left and
|
||||
// don't animate it, as it looks a bit clunky and would add complexity to do
|
||||
// properly.
|
||||
mx_Field_labelAlwaysTopLeft: prefixComponent || usePlaceholderAsHint,
|
||||
mx_Field_placeholderIsHint: usePlaceholderAsHint,
|
||||
mx_Field_valid: hasValidationFlag ? forceValidity : onValidate && this.state.valid === true,
|
||||
mx_Field_invalid: hasValidationFlag
|
||||
? !forceValidity
|
||||
: onValidate && this.state.valid === false,
|
||||
},
|
||||
);
|
||||
|
||||
// Handle displaying feedback on validity
|
||||
// FIXME: Using an import will result in test failures
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useCombinedRefs } from "../../../hooks/useCombinedRefs";
|
||||
|
||||
interface IProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'onInput'> {
|
||||
onChange?: (event: Event) => void;
|
||||
onInput?: (event: Event) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* This component restores the native 'onChange' and 'onInput' behavior of
|
||||
* JavaScript which have important differences for certain <input> types. This is
|
||||
* necessary because in React, the `onChange` handler behaves like the native
|
||||
* `oninput` handler and there is no way to tell the difference between an
|
||||
* `input` vs `change` event.
|
||||
*
|
||||
* via https://stackoverflow.com/a/62383569/796832 and
|
||||
* https://github.com/facebook/react/issues/9657#issuecomment-643970199
|
||||
*
|
||||
* See:
|
||||
* - https://reactjs.org/docs/dom-elements.html#onchange
|
||||
* - https://github.com/facebook/react/issues/3964
|
||||
* - https://github.com/facebook/react/issues/9657
|
||||
* - https://github.com/facebook/react/issues/14857
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
* We use this for the <input type="date"> date picker so we can distinguish from
|
||||
* a final date picker selection (onChange) vs navigating the months in the date
|
||||
* picker (onInput).
|
||||
*
|
||||
* This is also potentially useful for <input type="range" /> because the native
|
||||
* events behave in such a way that moving the slider around triggers an onInput
|
||||
* event and releasing it triggers onChange.
|
||||
*/
|
||||
const NativeOnChangeInput: React.FC<IProps> = React.forwardRef((props: IProps, ref) => {
|
||||
const registerCallbacks = (input: HTMLInputElement | null) => {
|
||||
if (input) {
|
||||
input.onchange = props.onChange;
|
||||
input.oninput = props.onInput;
|
||||
}
|
||||
};
|
||||
|
||||
return <input
|
||||
ref={useCombinedRefs(registerCallbacks, ref)}
|
||||
{...props}
|
||||
// These are just here so we don't get a read-only input warning from React
|
||||
onChange={() => {}}
|
||||
onInput={() => {}}
|
||||
/>;
|
||||
});
|
||||
|
||||
export default NativeOnChangeInput;
|
|
@ -16,12 +16,26 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Direction } from 'matrix-js-sdk/src/models/event-timeline';
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import { formatFullDateNoTime } from '../../../DateUtils';
|
||||
import { replaceableComponent } from "../../../utils/replaceableComponent";
|
||||
import { MatrixClientPeg } from '../../../MatrixClientPeg';
|
||||
import dis 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 { contextMenuBelow } from '../rooms/RoomTile';
|
||||
import { ContextMenuTooltipButton } from "../../structures/ContextMenu";
|
||||
import IconizedContextMenu, {
|
||||
IconizedContextMenuOption,
|
||||
IconizedContextMenuOptionList,
|
||||
} from "../context_menus/IconizedContextMenu";
|
||||
import JumpToDatePicker from './JumpToDatePicker';
|
||||
|
||||
function getDaysArray(): string[] {
|
||||
return [
|
||||
|
@ -36,13 +50,59 @@ function getDaysArray(): string[] {
|
|||
}
|
||||
|
||||
interface IProps {
|
||||
roomId: string;
|
||||
ts: number;
|
||||
forExport?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
contextMenuPosition?: DOMRect;
|
||||
jumpToDateEnabled: boolean;
|
||||
}
|
||||
|
||||
@replaceableComponent("views.messages.DateSeparator")
|
||||
export default class DateSeparator extends React.Component<IProps> {
|
||||
private getLabel() {
|
||||
export default class DateSeparator extends React.Component<IProps, IState> {
|
||||
private settingWatcherRef = null;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
jumpToDateEnabled: SettingsStore.getValue("feature_jump_to_date"),
|
||||
};
|
||||
|
||||
// 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 });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
SettingsStore.unwatchSetting(this.settingWatcherRef);
|
||||
}
|
||||
|
||||
private onContextMenuOpenClick = (e: React.MouseEvent): 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: null,
|
||||
});
|
||||
};
|
||||
|
||||
private getLabel(): string {
|
||||
const date = new Date(this.props.ts);
|
||||
const disableRelativeTimestamps = !SettingsStore.getValue(UIFeature.TimelineEnableRelativeDates);
|
||||
|
||||
|
@ -65,13 +125,127 @@ export default class DateSeparator extends React.Component<IProps> {
|
|||
}
|
||||
}
|
||||
|
||||
private pickDate = async (inputTimestamp): Promise<void> => {
|
||||
const unixTimestamp = new Date(inputTimestamp).getTime();
|
||||
|
||||
const cli = MatrixClientPeg.get();
|
||||
try {
|
||||
const roomId = this.props.roomId;
|
||||
const { event_id: eventId, origin_server_ts: originServerTs } = await cli.timestampToEvent(
|
||||
roomId,
|
||||
unixTimestamp,
|
||||
Direction.Forward,
|
||||
);
|
||||
logger.log(
|
||||
`/timestamp_to_event: ` +
|
||||
`found ${eventId} (${originServerTs}) for timestamp=${unixTimestamp} (looking forward)`,
|
||||
);
|
||||
|
||||
dis.dispatch({
|
||||
action: Action.ViewRoom,
|
||||
event_id: eventId,
|
||||
highlighted: true,
|
||||
room_id: roomId,
|
||||
});
|
||||
} catch (e) {
|
||||
const code = e.errcode || e.statusCode;
|
||||
// only show the dialog if failing for something other than a network error
|
||||
// (e.g. no errcode or statusCode) as in that case the redactions end up in the
|
||||
// detached queue and we show the room status bar to allow retry
|
||||
if (typeof code !== "undefined") {
|
||||
// display error message stating you couldn't delete this.
|
||||
Modal.createTrackedDialog('Unable to find event at that date', '', ErrorDialog, {
|
||||
title: _t('Error'),
|
||||
description: _t('Unable to find event at that date. (%(code)s)', { code }),
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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): void => {
|
||||
this.pickDate(dateString);
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
private renderJumpToDateMenu(): React.ReactElement {
|
||||
let contextMenu: JSX.Element;
|
||||
if (this.state.contextMenuPosition) {
|
||||
contextMenu = <IconizedContextMenu
|
||||
{...contextMenuBelow(this.state.contextMenuPosition)}
|
||||
compact
|
||||
onFinished={this.onContextMenuCloseClick}
|
||||
>
|
||||
<IconizedContextMenuOptionList first>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("Last week")}
|
||||
onClick={this.onLastWeekClicked}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("Last month")}
|
||||
onClick={this.onLastMonthClicked}
|
||||
/>
|
||||
<IconizedContextMenuOption
|
||||
label={_t("The beginning of the room")}
|
||||
onClick={this.onTheBeginningClicked}
|
||||
/>
|
||||
</IconizedContextMenuOptionList>
|
||||
|
||||
<IconizedContextMenuOptionList>
|
||||
<JumpToDatePicker ts={this.props.ts} onDatePicked={this.onDatePicked} />
|
||||
</IconizedContextMenuOptionList>
|
||||
</IconizedContextMenu>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_DateSeparator_jumpToDateMenu"
|
||||
onClick={this.onContextMenuOpenClick}
|
||||
isExpanded={!!this.state.contextMenuPosition}
|
||||
title={_t("Jump to date")}
|
||||
>
|
||||
<div aria-hidden="true">{ this.getLabel() }</div>
|
||||
<div className="mx_DateSeparator_chevron" />
|
||||
{ contextMenu }
|
||||
</ContextMenuTooltipButton>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const label = this.getLabel();
|
||||
|
||||
let dateHeaderContent;
|
||||
if (this.state.jumpToDateEnabled) {
|
||||
dateHeaderContent = this.renderJumpToDateMenu();
|
||||
} else {
|
||||
dateHeaderContent = <div aria-hidden="true">{ label }</div>;
|
||||
}
|
||||
|
||||
// ARIA treats <hr/>s as separators, here we abuse them slightly so manually treat this entire thing as one
|
||||
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
|
||||
return <h2 className="mx_DateSeparator" role="separator" tabIndex={-1} aria-label={label}>
|
||||
<hr role="none" />
|
||||
<div aria-hidden="true">{ label }</div>
|
||||
{ dateHeaderContent }
|
||||
<hr role="none" />
|
||||
</h2>;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useState, FormEvent } from 'react';
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import Field from "../elements/Field";
|
||||
import NativeOnChangeInput from "../elements/NativeOnChangeInput";
|
||||
import { RovingAccessibleButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
|
||||
|
||||
interface IProps {
|
||||
ts: number;
|
||||
onDatePicked?: (dateString: string) => void;
|
||||
}
|
||||
|
||||
const JumpToDatePicker: React.FC<IProps> = ({ ts, onDatePicked }: IProps) => {
|
||||
const date = new Date(ts);
|
||||
const year = date.getFullYear();
|
||||
const month = `${date.getMonth() + 1}`.padStart(2, "0");
|
||||
const day = `${date.getDate()}`.padStart(2, "0");
|
||||
const dateDefaultValue = `${year}-${month}-${day}`;
|
||||
|
||||
const [dateValue, setDateValue] = useState(dateDefaultValue);
|
||||
// Whether or not to automatically navigate to the given date after someone
|
||||
// selects a day in the date picker. We want to disable this after someone
|
||||
// starts manually typing in the input instead of picking.
|
||||
const [navigateOnDatePickerSelection, setNavigateOnDatePickerSelection] = useState(true);
|
||||
|
||||
// Since we're using NativeOnChangeInput with native JavaScript behavior, this
|
||||
// tracks the date value changes as they come in.
|
||||
const onDateValueInput = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setDateValue(e.target.value);
|
||||
};
|
||||
|
||||
// Since we're using NativeOnChangeInput with native JavaScript behavior, the change
|
||||
// event listener will trigger when a date is picked from the date picker
|
||||
// or when the text is fully filled out. In order to not trigger early
|
||||
// as someone is typing out a date, we need to disable when we see keydowns.
|
||||
const onDateValueChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
setDateValue(e.target.value);
|
||||
|
||||
// Don't auto navigate if they were manually typing out a date
|
||||
if (navigateOnDatePickerSelection) {
|
||||
onDatePicked(dateValue);
|
||||
}
|
||||
};
|
||||
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
|
||||
|
||||
const onDateInputKeyDown = (e: React.KeyboardEvent): void => {
|
||||
// When we see someone manually typing out a date, disable the auto
|
||||
// submit on change.
|
||||
setNavigateOnDatePickerSelection(false);
|
||||
};
|
||||
|
||||
const onJumpToDateSubmit = (ev: FormEvent): void => {
|
||||
ev.preventDefault();
|
||||
|
||||
onDatePicked(dateValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
className="mx_JumpToDatePicker_form"
|
||||
onSubmit={onJumpToDateSubmit}
|
||||
>
|
||||
<span className="mx_JumpToDatePicker_label">Jump to date</span>
|
||||
<Field
|
||||
componentClass={NativeOnChangeInput}
|
||||
type="date"
|
||||
onChange={onDateValueChange}
|
||||
onInput={onDateValueInput}
|
||||
onKeyDown={onDateInputKeyDown}
|
||||
value={dateValue}
|
||||
className="mx_JumpToDatePicker_datePicker"
|
||||
label={_t("Pick a date to jump to")}
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
/>
|
||||
<RovingAccessibleButton
|
||||
element="button"
|
||||
type="submit"
|
||||
kind="primary"
|
||||
className="mx_JumpToDatePicker_submitButton"
|
||||
onClick={onJumpToDateSubmit}
|
||||
>
|
||||
{ _t("Go") }
|
||||
</RovingAccessibleButton>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default JumpToDatePicker;
|
|
@ -49,7 +49,7 @@ export default class SearchResultTile extends React.Component<IProps> {
|
|||
const eventId = resultEvent.getId();
|
||||
|
||||
const ts1 = resultEvent.getTs();
|
||||
const ret = [<DateSeparator key={ts1 + "-search"} ts={ts1} />];
|
||||
const ret = [<DateSeparator key={ts1 + "-search"} roomId={resultEvent.getRoomId()} ts={ts1} />];
|
||||
const layout = SettingsStore.getValue("layout");
|
||||
const isTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
|
||||
const alwaysShowTimestamps = SettingsStore.getValue("alwaysShowTimestamps");
|
||||
|
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useRef, useEffect } from 'react';
|
||||
|
||||
// Takes in multiple React refs and combines them to reference the same target/element
|
||||
//
|
||||
// via https://itnext.io/reusing-the-ref-from-forwardref-with-react-hooks-4ce9df693dd
|
||||
export const useCombinedRefs = (...refs) => {
|
||||
const targetRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
refs.forEach(ref => {
|
||||
if (!ref) return;
|
||||
|
||||
if (typeof ref === 'function') {
|
||||
ref(targetRef.current);
|
||||
} else {
|
||||
ref.current = targetRef.current;
|
||||
}
|
||||
});
|
||||
}, [refs]);
|
||||
|
||||
return targetRef;
|
||||
};
|
|
@ -888,7 +888,7 @@
|
|||
"Use new room breadcrumbs": "Use new room breadcrumbs",
|
||||
"New spotlight search experience": "New spotlight search experience",
|
||||
"Right panel stays open (defaults to room member list)": "Right panel stays open (defaults to room member list)",
|
||||
"Jump to date (adds /jumptodate)": "Jump to date (adds /jumptodate)",
|
||||
"Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)",
|
||||
"Don't send read receipts": "Don't send read receipts",
|
||||
"Font size": "Font size",
|
||||
"Use custom size": "Use custom size",
|
||||
|
@ -2062,6 +2062,11 @@
|
|||
"Saturday": "Saturday",
|
||||
"Today": "Today",
|
||||
"Yesterday": "Yesterday",
|
||||
"Unable to find event at that date. (%(code)s)": "Unable to find event at that date. (%(code)s)",
|
||||
"Last week": "Last week",
|
||||
"Last month": "Last month",
|
||||
"The beginning of the room": "The beginning of the room",
|
||||
"Jump to date": "Jump to date",
|
||||
"Downloading": "Downloading",
|
||||
"Decrypting": "Decrypting",
|
||||
"Download": "Download",
|
||||
|
@ -2075,6 +2080,8 @@
|
|||
"The encryption used by this room isn't supported.": "The encryption used by this room isn't supported.",
|
||||
"Message pending moderation: %(reason)s": "Message pending moderation: %(reason)s",
|
||||
"Message pending moderation": "Message pending moderation",
|
||||
"Pick a date to jump to": "Pick a date to jump to",
|
||||
"Go": "Go",
|
||||
"Error processing audio message": "Error processing audio message",
|
||||
"React": "React",
|
||||
"Edit": "Edit",
|
||||
|
@ -2607,7 +2614,6 @@
|
|||
"Start a conversation with someone using their name, email address or username (like <userId/>).": "Start a conversation with someone using their name, email address or username (like <userId/>).",
|
||||
"Start a conversation with someone using their name or username (like <userId/>).": "Start a conversation with someone using their name or username (like <userId/>).",
|
||||
"This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click <a>here</a>": "This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click <a>here</a>",
|
||||
"Go": "Go",
|
||||
"Some suggestions may be hidden for privacy.": "Some suggestions may be hidden for privacy.",
|
||||
"If you can't see who you're looking for, send them your invite link below.": "If you can't see who you're looking for, send them your invite link below.",
|
||||
"Or send invite link": "Or send invite link",
|
||||
|
|
|
@ -358,7 +358,7 @@ export const SETTINGS: {[setting: string]: ISetting} = {
|
|||
// by default. We will conditionally show it depending on whether we can
|
||||
// detect MSC3030 support (see LabUserSettingsTab.tsx).
|
||||
// labsGroup: LabGroup.Messaging,
|
||||
displayName: _td("Jump to date (adds /jumptodate)"),
|
||||
displayName: _td("Jump to date (adds /jumptodate and jump to date headers)"),
|
||||
supportedLevels: LEVELS_FEATURE,
|
||||
default: false,
|
||||
},
|
||||
|
|
|
@ -248,7 +248,11 @@ export default class HTMLExporter extends Exporter {
|
|||
|
||||
protected getDateSeparator(event: MatrixEvent) {
|
||||
const ts = event.getTs();
|
||||
const dateSeparator = <li key={ts}><DateSeparator forExport={true} key={ts} ts={ts} /></li>;
|
||||
const dateSeparator = (
|
||||
<li key={ts}>
|
||||
<DateSeparator forExport={true} key={ts} roomId={event.getRoomId()} ts={ts} />
|
||||
</li>
|
||||
);
|
||||
return renderToStaticMarkup(dateSeparator);
|
||||
}
|
||||
|
||||
|
|
|
@ -64,7 +64,11 @@ describe("DateSeparator", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
global.Date = MockDate as unknown as DateConstructor;
|
||||
(SettingsStore.getValue as jest.Mock).mockReturnValue(true);
|
||||
(SettingsStore.getValue as jest.Mock) = jest.fn((arg) => {
|
||||
if (arg === UIFeature.TimelineEnableRelativeDates) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
|
@ -89,10 +93,28 @@ describe("DateSeparator", () => {
|
|||
|
||||
describe('when Settings.TimelineEnableRelativeDates is falsy', () => {
|
||||
beforeEach(() => {
|
||||
(SettingsStore.getValue as jest.Mock).mockReturnValue(false);
|
||||
(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 }).text()).toEqual(formatFullDateNoTime(new Date(ts)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('when feature_jump_to_date is enabled', () => {
|
||||
beforeEach(() => {
|
||||
(SettingsStore.getValue as jest.Mock) = jest.fn((arg) => {
|
||||
if (arg === "feature_jump_to_date") {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
});
|
||||
it('renders the date separator correctly', () => {
|
||||
const component = getComponent();
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -30,3 +30,87 @@ exports[`DateSeparator renders the date separator correctly 1`] = `
|
|||
</DateSeparator>
|
||||
</Wrapper>
|
||||
`;
|
||||
|
||||
exports[`DateSeparator when feature_jump_to_date is enabled renders the date separator correctly 1`] = `
|
||||
<Wrapper
|
||||
now="2021-12-17T08:09:00.000Z"
|
||||
ts={1639728540000}
|
||||
>
|
||||
<DateSeparator
|
||||
now="2021-12-17T08:09:00.000Z"
|
||||
ts={1639728540000}
|
||||
>
|
||||
<h2
|
||||
aria-label="Fri, Dec 17 2021"
|
||||
className="mx_DateSeparator"
|
||||
role="separator"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<hr
|
||||
role="none"
|
||||
/>
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_DateSeparator_jumpToDateMenu"
|
||||
isExpanded={false}
|
||||
onClick={[Function]}
|
||||
title="Jump to date"
|
||||
>
|
||||
<AccessibleTooltipButton
|
||||
aria-expanded={false}
|
||||
aria-haspopup={true}
|
||||
className="mx_DateSeparator_jumpToDateMenu"
|
||||
forceHide={false}
|
||||
onClick={[Function]}
|
||||
onContextMenu={[Function]}
|
||||
title="Jump to date"
|
||||
>
|
||||
<AccessibleButton
|
||||
aria-expanded={false}
|
||||
aria-haspopup={true}
|
||||
aria-label="Jump to date"
|
||||
className="mx_DateSeparator_jumpToDateMenu"
|
||||
element="div"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onContextMenu={[Function]}
|
||||
onFocus={[Function]}
|
||||
onMouseLeave={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
aria-expanded={false}
|
||||
aria-haspopup={true}
|
||||
aria-label="Jump to date"
|
||||
className="mx_AccessibleButton mx_DateSeparator_jumpToDateMenu"
|
||||
onBlur={[Function]}
|
||||
onClick={[Function]}
|
||||
onContextMenu={[Function]}
|
||||
onFocus={[Function]}
|
||||
onKeyDown={[Function]}
|
||||
onKeyUp={[Function]}
|
||||
onMouseLeave={[Function]}
|
||||
onMouseOver={[Function]}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
>
|
||||
Fri, Dec 17 2021
|
||||
</div>
|
||||
<div
|
||||
className="mx_DateSeparator_chevron"
|
||||
/>
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
</AccessibleTooltipButton>
|
||||
</ContextMenuTooltipButton>
|
||||
<hr
|
||||
role="none"
|
||||
/>
|
||||
</h2>
|
||||
</DateSeparator>
|
||||
</Wrapper>
|
||||
`;
|
||||
|
|
Loading…
Reference in New Issue