From 7fa27f583490a4a9bf0b5469fef6143bc5f5bc06 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Thu, 27 Jan 2022 16:32:12 -0600 Subject: [PATCH] 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.png --- res/css/_components.scss | 1 + .../context_menus/_IconizedContextMenu.scss | 12 +- res/css/views/messages/_DateSeparator.scss | 15 ++ res/css/views/messages/_JumpToDatePicker.scss | 37 ++++ src/components/structures/MessagePanel.tsx | 16 +- .../dialogs/MessageEditHistoryDialog.tsx | 2 +- src/components/views/elements/Field.tsx | 67 +++++-- .../views/elements/NativeOnChangeInput.tsx | 69 +++++++ .../views/messages/DateSeparator.tsx | 180 +++++++++++++++++- .../views/messages/JumpToDatePicker.tsx | 107 +++++++++++ .../views/rooms/SearchResultTile.tsx | 2 +- src/hooks/useCombinedRefs.ts | 38 ++++ src/i18n/strings/en_EN.json | 10 +- src/settings/Settings.tsx | 2 +- src/utils/exportUtils/HtmlExport.tsx | 6 +- .../views/messages/DateSeparator-test.tsx | 26 ++- .../__snapshots__/DateSeparator-test.tsx.snap | 84 ++++++++ 17 files changed, 630 insertions(+), 44 deletions(-) create mode 100644 res/css/views/messages/_JumpToDatePicker.scss create mode 100644 src/components/views/elements/NativeOnChangeInput.tsx create mode 100644 src/components/views/messages/JumpToDatePicker.tsx create mode 100644 src/hooks/useCombinedRefs.ts diff --git a/res/css/_components.scss b/res/css/_components.scss index 2ba049eada5..323dc2841b4 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -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"; diff --git a/res/css/views/context_menus/_IconizedContextMenu.scss b/res/css/views/context_menus/_IconizedContextMenu.scss index 56e98fa50ec..9dfda3b013a 100644 --- a/res/css/views/context_menus/_IconizedContextMenu.scss +++ b/res/css/views/context_menus/_IconizedContextMenu.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; } diff --git a/res/css/views/messages/_DateSeparator.scss b/res/css/views/messages/_DateSeparator.scss index 66501b40cb3..93a61e2de0a 100644 --- a/res/css/views/messages/_DateSeparator.scss +++ b/res/css/views/messages/_DateSeparator.scss @@ -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; +} diff --git a/res/css/views/messages/_JumpToDatePicker.scss b/res/css/views/messages/_JumpToDatePicker.scss new file mode 100644 index 00000000000..5722ed1306e --- /dev/null +++ b/res/css/views/messages/_JumpToDatePicker.scss @@ -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; +} diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index fb559e5ff26..1a4cedec5f3 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -721,8 +721,12 @@ export default class MessagePanel extends React.Component { // do we need a date separator since the last event? const wantsDateSeparator = this.wantsDateSeparator(prevEvent, eventDate); - if (wantsDateSeparator && !isGrouped) { - const dateSeparator =
  • ; + if (wantsDateSeparator && !isGrouped && this.props.room) { + const dateSeparator = ( +
  • + +
  • + ); ret.push(dateSeparator); } @@ -1118,7 +1122,7 @@ class CreationGrouper extends BaseGrouper { if (panel.wantsDateSeparator(this.prevEvent, createEvent.getDate())) { const ts = createEvent.getTs(); ret.push( -
  • , +
  • , ); } @@ -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( -
  • , +
  • , ); } @@ -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( -
  • , +
  • , ); } @@ -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( -
  • , +
  • , ); } diff --git a/src/components/views/dialogs/MessageEditHistoryDialog.tsx b/src/components/views/dialogs/MessageEditHistoryDialog.tsx index 74615046a45..005f77b05f2 100644 --- a/src/components/views/dialogs/MessageEditHistoryDialog.tsx +++ b/src/components/views/dialogs/MessageEditHistoryDialog.tsx @@ -130,7 +130,7 @@ export default class MessageEditHistoryDialog extends React.PureComponent { if (!lastEvent || wantsDateSeparator(lastEvent.getDate(), e.getDate())) { - nodes.push(
  • ); + nodes.push(
  • ); } const isBaseEvent = e.getId() === baseEventId; nodes.push(( diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index 1dedf77a8b9..578ecb3333b 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -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 { + // The ref pass through to the input + inputRef?: RefObject; // 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 { + // The ref pass through to the select + inputRef?: RefObject; // To define options for a select, use 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 { + // The ref pass through to the textarea + inputRef?: RefObject; 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 { + // The ref pass through to the input + inputRef?: RefObject; + 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 { private id: string; - private input: HTMLInputElement; + private inputRef: RefObject; public static readonly defaultProps = { element: "input", @@ -146,7 +166,7 @@ export default class Field extends React.PureComponent { } 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 { 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 { 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 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 { 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 { } 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 diff --git a/src/components/views/elements/NativeOnChangeInput.tsx b/src/components/views/elements/NativeOnChangeInput.tsx new file mode 100644 index 00000000000..0937fd8de9f --- /dev/null +++ b/src/components/views/elements/NativeOnChangeInput.tsx @@ -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, '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 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 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 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 = React.forwardRef((props: IProps, ref) => { + const registerCallbacks = (input: HTMLInputElement | null) => { + if (input) { + input.onchange = props.onChange; + input.oninput = props.onInput; + } + }; + + return {}} + onInput={() => {}} + />; +}); + +export default NativeOnChangeInput; diff --git a/src/components/views/messages/DateSeparator.tsx b/src/components/views/messages/DateSeparator.tsx index 27d3643e70e..76040c1ad75 100644 --- a/src/components/views/messages/DateSeparator.tsx +++ b/src/components/views/messages/DateSeparator.tsx @@ -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 { - private getLabel() { +export default class DateSeparator extends React.Component { + 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 { } } + private pickDate = async (inputTimestamp): Promise => { + 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 = + + + + + + + + + + ; + } + + return ( + + +
    + { contextMenu } + + ); + } + render() { const label = this.getLabel(); + + let dateHeaderContent; + if (this.state.jumpToDateEnabled) { + dateHeaderContent = this.renderJumpToDateMenu(); + } else { + dateHeaderContent = ; + } + // ARIA treats
    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


    - + { dateHeaderContent }

    ; } diff --git a/src/components/views/messages/JumpToDatePicker.tsx b/src/components/views/messages/JumpToDatePicker.tsx new file mode 100644 index 00000000000..1af8fff8d49 --- /dev/null +++ b/src/components/views/messages/JumpToDatePicker.tsx @@ -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 = ({ 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): 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): 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(); + + 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 ( +
    + Jump to date + + + { _t("Go") } + + + ); +}; + +export default JumpToDatePicker; diff --git a/src/components/views/rooms/SearchResultTile.tsx b/src/components/views/rooms/SearchResultTile.tsx index eea27fcec0d..756a6ff601e 100644 --- a/src/components/views/rooms/SearchResultTile.tsx +++ b/src/components/views/rooms/SearchResultTile.tsx @@ -49,7 +49,7 @@ export default class SearchResultTile extends React.Component { const eventId = resultEvent.getId(); const ts1 = resultEvent.getTs(); - const ret = []; + const ret = []; const layout = SettingsStore.getValue("layout"); const isTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps"); const alwaysShowTimestamps = SettingsStore.getValue("alwaysShowTimestamps"); diff --git a/src/hooks/useCombinedRefs.ts b/src/hooks/useCombinedRefs.ts new file mode 100644 index 00000000000..0b1e2a6c0c7 --- /dev/null +++ b/src/hooks/useCombinedRefs.ts @@ -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; +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 0010bfa58f6..3af0a8e2cb0 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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 ).": "Start a conversation with someone using their name, email address or username (like ).", "Start a conversation with someone using their name or username (like ).": "Start a conversation with someone using their name or username (like ).", "This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click here": "This won't invite them to %(communityName)s. To invite someone to %(communityName)s, click here", - "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", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index cf4e7d2b43f..26ed5f872e6 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -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, }, diff --git a/src/utils/exportUtils/HtmlExport.tsx b/src/utils/exportUtils/HtmlExport.tsx index 09b546bf2f5..83dc5b03ddf 100644 --- a/src/utils/exportUtils/HtmlExport.tsx +++ b/src/utils/exportUtils/HtmlExport.tsx @@ -248,7 +248,11 @@ export default class HTMLExporter extends Exporter { protected getDateSeparator(event: MatrixEvent) { const ts = event.getTs(); - const dateSeparator =
  • ; + const dateSeparator = ( +
  • + +
  • + ); return renderToStaticMarkup(dateSeparator); } diff --git a/test/components/views/messages/DateSeparator-test.tsx b/test/components/views/messages/DateSeparator-test.tsx index 0a3db003f19..574a22289d7 100644 --- a/test/components/views/messages/DateSeparator-test.tsx +++ b/test/components/views/messages/DateSeparator-test.tsx @@ -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(); + }); + }); }); diff --git a/test/components/views/messages/__snapshots__/DateSeparator-test.tsx.snap b/test/components/views/messages/__snapshots__/DateSeparator-test.tsx.snap index 69b5a34defc..cc65b5cb1f5 100644 --- a/test/components/views/messages/__snapshots__/DateSeparator-test.tsx.snap +++ b/test/components/views/messages/__snapshots__/DateSeparator-test.tsx.snap @@ -30,3 +30,87 @@ exports[`DateSeparator renders the date separator correctly 1`] = `
    `; + +exports[`DateSeparator when feature_jump_to_date is enabled renders the date separator correctly 1`] = ` + + +

    +
    + + + +
    + +
    +
    + + + +
    +

    +
    +
    +`;