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/accessibility/context_menu/MenuItem.tsx b/src/accessibility/context_menu/MenuItem.tsx index 9c0b2482740..f3b5aa27eca 100644 --- a/src/accessibility/context_menu/MenuItem.tsx +++ b/src/accessibility/context_menu/MenuItem.tsx @@ -31,13 +31,13 @@ export const MenuItem: React.FC = ({ children, label, tooltip, ...props const ariaLabel = props["aria-label"] || label; if (tooltip) { - return + return { children } ; } return ( - + { children } ); diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 6c8a56e9428..40146c2b666 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -86,8 +86,9 @@ export interface IProps extends IPosition { // it will be mounted to a container at the root of the DOM. mountAsChild?: boolean; - // If specified, contents will be wrapped in a FocusLock, this is only needed if the context menu is being rendered - // within an existing FocusLock e.g inside a modal. + // If specified, contents will be wrapped in a FocusLock, this is only + // needed if the context menu is being rendered within an existing FocusLock + // e.g inside a modal. focusLock?: boolean; // Function to be called on menu close @@ -180,12 +181,14 @@ export default class ContextMenu extends React.PureComponent { if (this.props.onFinished) this.props.onFinished(); }; - private onMoveFocus = (element: Element, up: boolean) => { + private onMoveFocus = (element: Element, up: boolean, closeIfOutOfBounds: boolean = false) => { + console.log('onMoveFocus =================', element) let descending = false; // are we currently descending or ascending through the DOM tree? do { const child = up ? element.lastElementChild : element.firstElementChild; const sibling = up ? element.previousElementSibling : element.nextElementSibling; + console.log('onMoveFocus', child, sibling) if (descending) { if (child) { @@ -257,7 +260,6 @@ export default class ContextMenu extends React.PureComponent { switch (ev.key) { // XXX: this is imitating roving behaviour, it should really use the RovingTabIndex utils // to inherit proper handling of unmount edge cases - case Key.TAB: case Key.ESCAPE: case Key.ARROW_LEFT: // close on left and right arrows too for when it is a context menu on a case Key.ARROW_RIGHT: @@ -269,6 +271,19 @@ export default class ContextMenu extends React.PureComponent { case Key.ARROW_DOWN: this.onMoveFocus(ev.target as Element, false); break; + case Key.TAB: + // Pass through listener + handled = false; + + // TODO: This is more on PoC level and not very clean. + setTimeout(() => { + console.log('tab', this.state.contextMenuElem, document.activeElement) + // If we've tabbed outside of the menu, then close the menu + if(!this.state.contextMenuElem.contains(document.activeElement)) { + this.props.onFinished(); + } + }, 0); + break; case Key.HOME: this.onMoveFocusHomeEnd(this.state.contextMenuElem, true); break; diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 60a0829e2de..afada494cfd 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/dialogs/MessageEditHistoryDialog.tsx b/src/components/views/dialogs/MessageEditHistoryDialog.tsx index f5a990e4091..df86c80fc6e 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 512cc915039..a09791948b8 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -19,6 +19,7 @@ import classNames from 'classnames'; import * as sdk from '../../../index'; import { debounce } from "lodash"; 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; @@ -96,7 +97,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; @@ -256,7 +266,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 c033855eb54..b99cafbc490 100644 --- a/src/components/views/rooms/SearchResultTile.tsx +++ b/src/components/views/rooms/SearchResultTile.tsx @@ -46,7 +46,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 5349fd5224e..a055d088054 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2026,6 +2026,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", @@ -2550,7 +2557,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 7c8265fd32c..03769d387cd 100644 --- a/src/utils/exportUtils/HtmlExport.tsx +++ b/src/utils/exportUtils/HtmlExport.tsx @@ -247,7 +247,7 @@ export default class HTMLExporter extends Exporter { protected getDateSeparator(event: MatrixEvent) { const ts = event.getTs(); - const dateSeparator =
  • ; + const dateSeparator =
  • ; return renderToStaticMarkup(dateSeparator); }