From b061c94526c44b341c58e2c51d54615e7ae68428 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Fri, 10 Dec 2021 21:26:57 -0600 Subject: [PATCH] Copy Jump to Date PoC changes on top of RovingTabIndex fixes Copy changes from https://github.com/matrix-org/matrix-react-sdk/pull/7317 on top of RovingTabIndex fixes in https://github.com/matrix-org/matrix-react-sdk/pull/7336 --- .../context_menus/_IconizedContextMenu.scss | 12 +- res/css/views/elements/_Field.scss | 1 + res/css/views/messages/_DateSeparator.scss | 34 +++ src/components/structures/MessagePanel.tsx | 10 +- src/components/views/elements/Field.tsx | 14 +- .../views/messages/DateSeparator.tsx | 275 +++++++++++++++++- .../views/rooms/SearchResultTile.tsx | 2 +- src/i18n/strings/en_EN.json | 8 +- src/utils/exportUtils/HtmlExport.tsx | 2 +- 9 files changed, 338 insertions(+), 20 deletions(-) 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/elements/_Field.scss b/res/css/views/elements/_Field.scss index a97e7ee949e..1083a324fe2 100644 --- a/res/css/views/elements/_Field.scss +++ b/res/css/views/elements/_Field.scss @@ -127,6 +127,7 @@ limitations under the License. transform 0.25s ease-out 0s, background-color 0.25s ease-out 0s; font-size: $font-10px; + line-height: normal; transform: translateY(-13px); padding: 0 2px; background-color: $background; diff --git a/res/css/views/messages/_DateSeparator.scss b/res/css/views/messages/_DateSeparator.scss index 66501b40cb3..bd9b77227db 100644 --- a/res/css/views/messages/_DateSeparator.scss +++ b/res/css/views/messages/_DateSeparator.scss @@ -33,3 +33,37 @@ 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; +} + +.mx_DateSeparator_jumpToDateMenuOption > .mx_IconizedContextMenu_label { + flex: initial; + width: auto; +} + +.mx_DateSeparator_datePickerForm { + display: flex; +} + +.mx_DateSeparator_datePicker { + flex: initial; + margin: 0; + margin-left: 8px; +} + +.mx_DateSeparator_datePickerSubmitButton { + margin-left: 8px; +} diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 7db09c6720b..996c53149ee 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -715,8 +715,8 @@ 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); } @@ -1109,7 +1109,7 @@ class CreationGrouper extends BaseGrouper { if (panel.wantsDateSeparator(this.prevEvent, createEvent.getDate())) { const ts = createEvent.getTs(); ret.push( -
  • , +
  • , ); } @@ -1222,7 +1222,7 @@ class RedactionGrouper extends BaseGrouper { if (panel.wantsDateSeparator(this.prevEvent, this.events[0].getDate())) { const ts = this.events[0].getTs(); ret.push( -
  • , +
  • , ); } @@ -1318,7 +1318,7 @@ class MemberGrouper 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/elements/Field.tsx b/src/components/views/elements/Field.tsx index 85f397834f2..b827002c1e8 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -20,6 +20,7 @@ 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; @@ -97,7 +98,16 @@ interface ITextareaProps extends IProps, TextareaHTMLAttributes { + // The element to create. + element: ComponentClass; + // The input's value. This is a controlled component, so the value is required. + value: string; + // Optionally can be used for the CustomInput + onInput?: React.ChangeEventHandler; +} + +type PropShapes = IInputProps | ISelectProps | ITextareaProps | ICustomInputProps; interface IState { valid: boolean; @@ -257,7 +267,7 @@ export default class Field extends React.PureComponent { } const hasValidationFlag = forceValidity !== null && forceValidity !== undefined; - const fieldClasses = classNames("mx_Field", `mx_Field_${this.props.element}`, className, { + const fieldClasses = classNames("mx_Field", `mx_Field_${typeof this.props.element === "string" ? this.props.element : "input"}`, 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. diff --git a/src/components/views/messages/DateSeparator.tsx b/src/components/views/messages/DateSeparator.tsx index b20319e800e..e9438da5e1e 100644 --- a/src/components/views/messages/DateSeparator.tsx +++ b/src/components/views/messages/DateSeparator.tsx @@ -20,6 +20,54 @@ import React from 'react'; import { _t } from '../../../languageHandler'; import { formatFullDateNoTime } from '../../../DateUtils'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import { Direction } from 'matrix-js-sdk/src/models/event-timeline'; +import dis from '../../../dispatcher/dispatcher'; +import { Action } from '../../../dispatcher/actions'; + +import Field from "../elements/Field"; +import Modal from '../../../Modal'; +import ErrorDialog from '../dialogs/ErrorDialog'; +import AccessibleButton from "../elements/AccessibleButton"; +import { contextMenuBelow } from '../rooms/RoomTile'; +import { ContextMenuTooltipButton } from "../../structures/ContextMenu"; +import IconizedContextMenu, { + IconizedContextMenuOption, + IconizedContextMenuOptionList, + IconizedContextMenuRadio, +} from "../context_menus/IconizedContextMenu"; + +interface CustomInputProps { + onChange?: (event: Event) => void; + onInput?: (event: Event) => void; +} +/** + * This component restores the native 'onChange' and 'onInput' behavior of + * JavaScript. 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 + * + * We use this for the date picker so we can distinguish + * from a final date picker selection vs navigating the months in the date + * picker which trigger an `input`(and `onChange` in React). + */ +class CustomInput extends React.Component, 'onChange' | 'onInput' | 'ref'> & CustomInputProps> { + private readonly registerCallbacks = (element: HTMLInputElement | null) => { + if (element) { + element.onchange = this.props.onChange ? this.props.onChange : null; + element.oninput = this.props.onInput ? this.props.onInput : null; + } + }; + + public render() { + return {}} onInput={() => {}} />; + } +} function getDaysArray(): string[] { return [ @@ -34,13 +82,48 @@ function getDaysArray(): string[] { } interface IProps { + roomId: string, ts: number; forExport?: boolean; } +interface IState { + dateValue: string, + // 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. + navigateOnDatePickerSelection: boolean, + contextMenuPosition?: DOMRect +} + @replaceableComponent("views.messages.DateSeparator") -export default class DateSeparator extends React.Component { - private getLabel() { +export default class DateSeparator extends React.Component { + constructor(props, context) { + super(props, context); + this.state = { + dateValue: this.getDefaultDateValue(), + navigateOnDatePickerSelection: true + }; + } + + 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); // During the time the archive is being viewed, a specific day might not make sense, so we return the full date @@ -62,12 +145,196 @@ export default class DateSeparator extends React.Component { } } + private getDefaultDateValue(): string { + const date = new Date(this.props.ts); + const year = date.getFullYear(); + const month = `${date.getMonth() + 1}`.padStart(2, "0") + const day = `${date.getDate()}`.padStart(2, "0") + + return `${year}-${month}-${day}` + } + + private pickDate = async (inputTimestamp): Promise => { + console.log('pickDate', inputTimestamp) + + const unixTimestamp = new Date(inputTimestamp).getTime(); + + const cli = MatrixClientPeg.get(); + try { + const roomId = this.props.roomId + const { event_id, origin_server_ts } = await cli.timestampToEvent( + roomId, + unixTimestamp, + Direction.Forward + ); + console.log(`/timestamp_to_event: found ${event_id} (${origin_server_ts}) for timestamp=${unixTimestamp}`) + + dis.dispatch({ + action: Action.ViewRoom, + event_id, + 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 }), + }); + } + } + }; + + // Since we're using CustomInput with native JavaScript behavior, this + // tracks the date value changes as they come in. + private onDateValueInput = (e: React.ChangeEvent): void => { + console.log('onDateValueInput') + this.setState({ dateValue: e.target.value }); + }; + + // Since we're using CustomInput 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. + private onDateValueChange = (e: React.ChangeEvent): void => { + console.log('onDateValueChange') + this.setState({ dateValue: e.target.value }); + + // Don't auto navigate if they were manually typing out a date + if(this.state.navigateOnDatePickerSelection) { + this.pickDate(this.state.dateValue); + this.closeMenu(); + } + }; + + private onDateInputKeyDown = (e: React.KeyboardEvent): void => { + // Ignore the tab key which is probably just navigating focus around + // with the keyboard + if(e.key === "Tab") { + return; + } + + // Go and navigate if they submitted + if(e.key === "Enter") { + this.pickDate(this.state.dateValue); + this.closeMenu(); + return; + } + + // When we see someone manually typing out a date, disable the auto + // submit on change. + this.setState({ navigateOnDatePickerSelection: false }); + }; + + private onLastWeekClicked = (): void => { + const date = new Date(); + // This just goes back 7 days. + // FIXME: Do we want this to go back to the last Sunday? https://upokary.com/how-to-get-last-monday-or-last-friday-or-any-last-day-in-javascript/ + 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 onJumpToDateSubmit = (): void => { + console.log('onJumpToDateSubmit') + this.pickDate(this.state.dateValue); + this.closeMenu(); + } + + private renderNotificationsMenu(): React.ReactElement { + let contextMenu: JSX.Element; + if (this.state.contextMenuPosition) { + contextMenu = + + + + + + + + {}} + tabIndex={-1} + > +
    + + + { _t("Go") } + + +
    +
    +
    ; + } + + return ( + + +
    + { contextMenu } + + ); + } + render() { // 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

    + return


    - + { this.renderNotificationsMenu() }

    ; } diff --git a/src/components/views/rooms/SearchResultTile.tsx b/src/components/views/rooms/SearchResultTile.tsx index 376c3166a98..df8761bb78f 100644 --- a/src/components/views/rooms/SearchResultTile.tsx +++ b/src/components/views/rooms/SearchResultTile.tsx @@ -47,7 +47,7 @@ export default class SearchResultTile extends React.Component { const eventId = mxEv.getId(); const ts1 = mxEv.getTs(); - const ret = []; + const ret = []; const layout = SettingsStore.getValue("layout"); const isTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps"); const alwaysShowTimestamps = SettingsStore.getValue("alwaysShowTimestamps"); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 72dd21e9b26..2487b485fd5 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2027,6 +2027,13 @@ "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", + "Pick a date to jump to": "Pick a date to jump to", + "Go": "Go", "Downloading": "Downloading", "Decrypting": "Decrypting", "Download": "Download", @@ -2551,7 +2558,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/utils/exportUtils/HtmlExport.tsx b/src/utils/exportUtils/HtmlExport.tsx index a39bced5d63..3ccf967d1f9 100644 --- a/src/utils/exportUtils/HtmlExport.tsx +++ b/src/utils/exportUtils/HtmlExport.tsx @@ -248,7 +248,7 @@ export default class HTMLExporter extends Exporter { protected getDateSeparator(event: MatrixEvent) { const ts = event.getTs(); - const dateSeparator =
  • ; + const dateSeparator =
  • ; return renderToStaticMarkup(dateSeparator); }