From 1c4d89f2d77f605e2f1b6dd991b086faa10c7fd6 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 11 Nov 2019 17:53:17 +0000 Subject: [PATCH] Migrate all standard Context Menus over to new custom framework --- res/css/views/context_menus/_TopLeftMenu.scss | 14 +- src/components/structures/ContextualMenu.js | 332 +++++++++++++++++- .../structures/TopLeftMenuButton.js | 73 ++-- .../GroupInviteTileContextMenu.js | 10 +- .../context_menus/RoomTileContextMenu.js | 180 +++++----- .../views/context_menus/TagTileContextMenu.js | 27 +- .../views/context_menus/TopLeftMenu.js | 29 +- src/components/views/elements/TagTile.js | 121 ++++--- .../views/emojipicker/ReactionPicker.js | 2 - .../views/groups/GroupInviteTile.js | 100 +++--- .../views/messages/MessageActionBar.js | 224 +++++++----- src/components/views/rooms/RoomTile.js | 141 ++++---- src/i18n/strings/en_EN.json | 1 + 13 files changed, 830 insertions(+), 424 deletions(-) diff --git a/res/css/views/context_menus/_TopLeftMenu.scss b/res/css/views/context_menus/_TopLeftMenu.scss index 9d258bcf55..d17d683e7e 100644 --- a/res/css/views/context_menus/_TopLeftMenu.scss +++ b/res/css/views/context_menus/_TopLeftMenu.scss @@ -49,23 +49,23 @@ limitations under the License. padding: 0; list-style: none; - li.mx_TopLeftMenu_icon_home::after { + .mx_TopLeftMenu_icon_home::after { mask-image: url('$(res)/img/feather-customised/home.svg'); } - li.mx_TopLeftMenu_icon_settings::after { + .mx_TopLeftMenu_icon_settings::after { mask-image: url('$(res)/img/feather-customised/settings.svg'); } - li.mx_TopLeftMenu_icon_signin::after { + .mx_TopLeftMenu_icon_signin::after { mask-image: url('$(res)/img/feather-customised/sign-in.svg'); } - li.mx_TopLeftMenu_icon_signout::after { + .mx_TopLeftMenu_icon_signout::after { mask-image: url('$(res)/img/feather-customised/sign-out.svg'); } - li::after { + .mx_AccessibleButton::after { mask-repeat: no-repeat; mask-position: 0 center; mask-size: 16px; @@ -78,14 +78,14 @@ limitations under the License. background-color: $primary-fg-color; } - li { + .mx_AccessibleButton { position: relative; cursor: pointer; white-space: nowrap; padding: 5px 20px 5px 43px; } - li:hover { + .mx_AccessibleButton:hover { background-color: $menu-selected-color; } } diff --git a/src/components/structures/ContextualMenu.js b/src/components/structures/ContextualMenu.js index 3f8c87efef..6d046c08d4 100644 --- a/src/components/structures/ContextualMenu.js +++ b/src/components/structures/ContextualMenu.js @@ -21,7 +21,9 @@ import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import {focusCapturedRef} from "../../utils/Accessibility"; -import {KeyCode} from "../../Keyboard"; +import {Key, KeyCode} from "../../Keyboard"; +import {_t} from "../../languageHandler"; +import sdk from "../../index"; // Shamelessly ripped off Modal.js. There's probably a better way // of doing reusable widgets like dialog boxes & menus where we go and @@ -229,6 +231,334 @@ export default class ContextualMenu extends React.Component { } } +const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]); + +class ContextualMenu2 extends React.Component { + propTypes: { + top: PropTypes.number, + bottom: PropTypes.number, + left: PropTypes.number, + right: PropTypes.number, + menuWidth: PropTypes.number, + menuHeight: PropTypes.number, + chevronOffset: PropTypes.number, + chevronFace: PropTypes.string, // top, bottom, left, right or none + // Function to be called on menu close + onFinished: PropTypes.func, + menuPaddingTop: PropTypes.number, + menuPaddingRight: PropTypes.number, + menuPaddingBottom: PropTypes.number, + menuPaddingLeft: PropTypes.number, + zIndex: PropTypes.number, + + // If true, insert an invisible screen-sized element behind the + // menu that when clicked will close it. + hasBackground: PropTypes.bool, + + // on resize callback + windowResize: PropTypes.func, + }; + + constructor() { + super(); + this.state = { + contextMenuRect: null, + }; + + // persist what had focus when we got initialized so we can return it after + this.initialFocus = document.activeElement; + } + + componentWillUnmount() { + // return focus to the thing which had it before us + this.initialFocus.focus(); + } + + collectContextMenuRect = (element) => { + // We don't need to clean up when unmounting, so ignore + if (!element) return; + + const first = element.querySelector('[role^="menuitem"]'); + if (first) { + first.focus(); + } + + this.setState({ + contextMenuRect: element.getBoundingClientRect(), + }); + }; + + onContextMenu = (e) => { + if (this.props.closeMenu) { + this.props.closeMenu(); + + e.preventDefault(); + const x = e.clientX; + const y = e.clientY; + + // XXX: This isn't pretty but the only way to allow opening a different context menu on right click whilst + // a context menu and its click-guard are up without completely rewriting how the context menus work. + setImmediate(() => { + const clickEvent = document.createEvent('MouseEvents'); + clickEvent.initMouseEvent( + 'contextmenu', true, true, window, 0, + 0, 0, x, y, false, false, + false, false, 0, null, + ); + document.elementFromPoint(x, y).dispatchEvent(clickEvent); + }); + } + }; + + _onMoveFocus = (element, up) => { + 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; + + if (descending) { + if (child) { + element = child; + } else if (sibling) { + element = sibling; + } else { + descending = false; + element = element.parentElement; + } + } else { + if (sibling) { + element = sibling; + descending = true; + } else { + element = element.parentElement; + } + } + + if (element) { + if (element.classList.contains("mx_ContextualMenu")) { // we hit the top + element = up ? element.lastElementChild : element.firstElementChild; + descending = true; + } + } + } while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role"))); + + if (element) { + element.focus(); + } + }; + + _onKeyDown = (ev) => { + let handled = true; + + switch (ev.key) { + case Key.TAB: + case Key.ESCAPE: + this.props.closeMenu(); + break; + case Key.ARROW_UP: + this._onMoveFocus(ev.target, true); + break; + case Key.ARROW_DOWN: + this._onMoveFocus(ev.target, false); + break; + default: + handled = false; + } + + if (handled) { + ev.stopPropagation(); + ev.preventDefault(); + } + }; + + render() { + const position = {}; + let chevronFace = null; + const props = this.props; + + if (props.top) { + position.top = props.top; + } else { + position.bottom = props.bottom; + } + + if (props.left) { + position.left = props.left; + chevronFace = 'left'; + } else { + position.right = props.right; + chevronFace = 'right'; + } + + const contextMenuRect = this.state.contextMenuRect || null; + const padding = 10; + + const chevronOffset = {}; + if (props.chevronFace) { + chevronFace = props.chevronFace; + } + const hasChevron = chevronFace && chevronFace !== "none"; + + if (chevronFace === 'top' || chevronFace === 'bottom') { + chevronOffset.left = props.chevronOffset; + } else { + const target = position.top; + + // By default, no adjustment is made + let adjusted = target; + + // If we know the dimensions of the context menu, adjust its position + // such that it does not leave the (padded) window. + if (contextMenuRect) { + adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height - padding); + } + + position.top = adjusted; + chevronOffset.top = Math.max(props.chevronOffset, props.chevronOffset + target - adjusted); + } + + let chevron; + if (hasChevron) { + chevron =
; + } + + const menuClasses = classNames({ + 'mx_ContextualMenu': true, + 'mx_ContextualMenu_left': !hasChevron && position.left, + 'mx_ContextualMenu_right': !hasChevron && position.right, + 'mx_ContextualMenu_top': !hasChevron && position.top, + 'mx_ContextualMenu_bottom': !hasChevron && position.bottom, + 'mx_ContextualMenu_withChevron_left': chevronFace === 'left', + 'mx_ContextualMenu_withChevron_right': chevronFace === 'right', + 'mx_ContextualMenu_withChevron_top': chevronFace === 'top', + 'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom', + }); + + const menuStyle = {}; + if (props.menuWidth) { + menuStyle.width = props.menuWidth; + } + + if (props.menuHeight) { + menuStyle.height = props.menuHeight; + } + + if (!isNaN(Number(props.menuPaddingTop))) { + menuStyle["paddingTop"] = props.menuPaddingTop; + } + if (!isNaN(Number(props.menuPaddingLeft))) { + menuStyle["paddingLeft"] = props.menuPaddingLeft; + } + if (!isNaN(Number(props.menuPaddingBottom))) { + menuStyle["paddingBottom"] = props.menuPaddingBottom; + } + if (!isNaN(Number(props.menuPaddingRight))) { + menuStyle["paddingRight"] = props.menuPaddingRight; + } + + const wrapperStyle = {}; + if (!isNaN(Number(props.zIndex))) { + menuStyle["zIndex"] = props.zIndex + 1; + wrapperStyle["zIndex"] = props.zIndex; + } + + let background; + if (props.hasBackground) { + background = ( +
+ ); + } + + return ( +
+
+ { chevron } + { props.children } +
+ { background } +
+ ); + } +} + +// Generic ContextMenu Portal wrapper +// all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1} +// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines. + +export const ContextMenu = ({children, onFinished, props, hasBackground=true}) => { + const menu = + { children } + ; + + return ReactDOM.createPortal(menu, getOrCreateContainer()); +}; + +// Semantic component for representing a role=menuitem +export const MenuItem = ({children, label, ...props}) => { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return ( + + { children } + + ); +}; +MenuItem.propTypes = { + label: PropTypes.string, // optional + className: PropTypes.string, // optional + onClick: PropTypes.func.isRequired, +}; + +// Semantic component for representing a role=group for grouping menu radios/checkboxes +export const MenuGroup = ({children, label, ...props}) => { + return
+ { children } +
; +}; +MenuGroup.propTypes = { + label: PropTypes.string.isRequired, + className: PropTypes.string, // optional +}; + +// Semantic component for representing a role=menuitemcheckbox +export const MenuItemCheckbox = ({children, label, active=false, disabled=false, ...props}) => { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return ( + + { children } + + ); +}; +MenuItemCheckbox.propTypes = { + label: PropTypes.string, // optional + active: PropTypes.bool.isRequired, + disabled: PropTypes.bool, // optional + className: PropTypes.string, // optional + onClick: PropTypes.func.isRequired, +}; + +// Semantic component for representing a role=menuitemradio +export const MenuItemRadio = ({children, label, active=false, disabled=false, ...props}) => { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return ( + + { children } + + ); +}; +MenuItemRadio.propTypes = { + label: PropTypes.string, // optional + active: PropTypes.bool.isRequired, + disabled: PropTypes.bool, // optional + className: PropTypes.string, // optional + onClick: PropTypes.func.isRequired, +}; + export function createMenu(ElementClass, props, hasBackground=true) { const closeMenu = function(...args) { ReactDOM.unmountComponentAtNode(getOrCreateContainer()); diff --git a/src/components/structures/TopLeftMenuButton.js b/src/components/structures/TopLeftMenuButton.js index 42b8623e56..e0875efb42 100644 --- a/src/components/structures/TopLeftMenuButton.js +++ b/src/components/structures/TopLeftMenuButton.js @@ -17,15 +17,14 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import * as ContextualMenu from './ContextualMenu'; import {TopLeftMenu} from '../views/context_menus/TopLeftMenu'; -import AccessibleButton from '../views/elements/AccessibleButton'; import BaseAvatar from '../views/avatars/BaseAvatar'; import MatrixClientPeg from '../../MatrixClientPeg'; import Avatar from '../../Avatar'; import { _t } from '../../languageHandler'; import dis from "../../dispatcher"; -import {focusCapturedRef} from "../../utils/Accessibility"; +import {ContextMenu} from "./ContextualMenu"; +import sdk from "../../index"; const AVATAR_SIZE = 28; @@ -40,11 +39,8 @@ export default class TopLeftMenuButton extends React.Component { super(); this.state = { menuDisplayed: false, - menuFunctions: null, // should be { close: fn } profileInfo: null, }; - - this.onToggleMenu = this.onToggleMenu.bind(this); } async _getProfileInfo() { @@ -95,7 +91,21 @@ export default class TopLeftMenuButton extends React.Component { } } + openMenu = (e) => { + e.preventDefault(); + e.stopPropagation(); + this.setState({ menuDisplayed: true }); + }; + + closeMenu = () => { + this.setState({ + menuDisplayed: false, + }); + }; + render() { + const cli = MatrixClientPeg.get().getUserId(); + const name = this._getDisplayName(); let nameElement; let chevronElement; @@ -106,10 +116,28 @@ export default class TopLeftMenuButton extends React.Component { chevronElement = ; } - return ( + let contextMenu; + if (this.state.menuDisplayed) { + const elementRect = this._buttonRef.getBoundingClientRect(); + const x = elementRect.left; + const y = elementRect.top + elementRect.height; + + const props = { + chevronFace: "none", + left: x, + top: y, + }; + + contextMenu = + + ; + } + + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return this._buttonRef = r} aria-label={_t("Your profile")} aria-haspopup={true} @@ -126,33 +154,8 @@ export default class TopLeftMenuButton extends React.Component { { nameElement } { chevronElement } - ); - } - onToggleMenu(e) { - e.preventDefault(); - e.stopPropagation(); - - if (this.state.menuDisplayed && this.state.menuFunctions) { - this.state.menuFunctions.close(); - return; - } - - const elementRect = e.currentTarget.getBoundingClientRect(); - const x = elementRect.left; - const y = elementRect.top + elementRect.height; - - const menuFunctions = ContextualMenu.createMenu(TopLeftMenu, { - chevronFace: "none", - left: x, - top: y, - userId: MatrixClientPeg.get().getUserId(), - displayName: this._getDisplayName(), - containerRef: focusCapturedRef, // Focus the TopLeftMenu on first render - onFinished: () => { - this.setState({ menuDisplayed: false, menuFunctions: null }); - }, - }); - this.setState({ menuDisplayed: true, menuFunctions }); + { contextMenu } + ; } } diff --git a/src/components/views/context_menus/GroupInviteTileContextMenu.js b/src/components/views/context_menus/GroupInviteTileContextMenu.js index 8c0d9c215b..093e6045c7 100644 --- a/src/components/views/context_menus/GroupInviteTileContextMenu.js +++ b/src/components/views/context_menus/GroupInviteTileContextMenu.js @@ -22,6 +22,7 @@ import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; import {Group} from 'matrix-js-sdk'; import GroupStore from "../../../stores/GroupStore"; +import {MenuItem} from "../../structures/ContextualMenu"; export default class GroupInviteTileContextMenu extends React.Component { static propTypes = { @@ -36,7 +37,7 @@ export default class GroupInviteTileContextMenu extends React.Component { this._onClickReject = this._onClickReject.bind(this); } - componentWillMount() { + componentDidMount() { this._unmounted = false; } @@ -78,12 +79,11 @@ export default class GroupInviteTileContextMenu extends React.Component { } render() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return
- - + + { _t('Reject') } - +
; } } diff --git a/src/components/views/context_menus/RoomTileContextMenu.js b/src/components/views/context_menus/RoomTileContextMenu.js index 9bb573026f..6b1149cca1 100644 --- a/src/components/views/context_menus/RoomTileContextMenu.js +++ b/src/components/views/context_menus/RoomTileContextMenu.js @@ -32,6 +32,36 @@ import * as RoomNotifs from '../../../RoomNotifs'; import Modal from '../../../Modal'; import RoomListActions from '../../../actions/RoomListActions'; import RoomViewStore from '../../../stores/RoomViewStore'; +import {MenuItem, MenuItemCheckbox, MenuItemRadio} from "../../structures/ContextualMenu"; + +const RoomTagOption = ({active, onClick, src, srcSet, label}) => { + const classes = classNames('mx_RoomTileContextMenu_tag_field', { + 'mx_RoomTileContextMenu_tag_fieldSet': active, + 'mx_RoomTileContextMenu_tag_fieldDisabled': false, + }); + + return ( + + + + { label } + + ); +}; + +const NotifOption = ({active, onClick, src, label}) => { + const classes = classNames('mx_RoomTileContextMenu_notif_field', { + 'mx_RoomTileContextMenu_notif_fieldSet': active, + }); + + return ( + + + + { label } + + ); +}; module.exports = createReactClass({ displayName: 'RoomTileContextMenu', @@ -228,53 +258,36 @@ module.exports = createReactClass({ }, _renderNotifMenu: function() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - - const alertMeClasses = classNames({ - 'mx_RoomTileContextMenu_notif_field': true, - 'mx_RoomTileContextMenu_notif_fieldSet': this.state.roomNotifState == RoomNotifs.ALL_MESSAGES_LOUD, - }); - - const allNotifsClasses = classNames({ - 'mx_RoomTileContextMenu_notif_field': true, - 'mx_RoomTileContextMenu_notif_fieldSet': this.state.roomNotifState == RoomNotifs.ALL_MESSAGES, - }); - - const mentionsClasses = classNames({ - 'mx_RoomTileContextMenu_notif_field': true, - 'mx_RoomTileContextMenu_notif_fieldSet': this.state.roomNotifState == RoomNotifs.MENTIONS_ONLY, - }); - - const muteNotifsClasses = classNames({ - 'mx_RoomTileContextMenu_notif_field': true, - 'mx_RoomTileContextMenu_notif_fieldSet': this.state.roomNotifState == RoomNotifs.MUTE, - }); - return ( -
-
- +
+
+
- - - - { _t('All messages (noisy)') } - - - - - { _t('All messages') } - - - - - { _t('Mentions only') } - - - - - { _t('Mute') } - + + + + +
); }, @@ -290,13 +303,12 @@ module.exports = createReactClass({ }, _renderSettingsMenu: function() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return (
- - + + { _t('Settings') } - +
); }, @@ -329,52 +341,38 @@ module.exports = createReactClass({ return (
- - + + { leaveText } - +
); }, _renderRoomTagMenu: function() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - - const favouriteClasses = classNames({ - 'mx_RoomTileContextMenu_tag_field': true, - 'mx_RoomTileContextMenu_tag_fieldSet': this.state.isFavourite, - 'mx_RoomTileContextMenu_tag_fieldDisabled': false, - }); - - const lowPriorityClasses = classNames({ - 'mx_RoomTileContextMenu_tag_field': true, - 'mx_RoomTileContextMenu_tag_fieldSet': this.state.isLowPriority, - 'mx_RoomTileContextMenu_tag_fieldDisabled': false, - }); - - const dmClasses = classNames({ - 'mx_RoomTileContextMenu_tag_field': true, - 'mx_RoomTileContextMenu_tag_fieldSet': this.state.isDirectMessage, - 'mx_RoomTileContextMenu_tag_fieldDisabled': false, - }); - return (
- - - - { _t('Favourite') } - - - - - { _t('Low Priority') } - - - - - { _t('Direct Chat') } - + + +
); }, @@ -386,11 +384,11 @@ module.exports = createReactClass({ case 'join': return
{ this._renderNotifMenu() } -
+
{ this._renderLeaveMenu(myMembership) } -
+
{ this._renderRoomTagMenu() } -
+
{ this._renderSettingsMenu() }
; case 'invite': @@ -400,7 +398,7 @@ module.exports = createReactClass({ default: return
{ this._renderLeaveMenu(myMembership) } -
+
{ this._renderSettingsMenu() }
; } diff --git a/src/components/views/context_menus/TagTileContextMenu.js b/src/components/views/context_menus/TagTileContextMenu.js index c0203a3ac8..3cfad4efe4 100644 --- a/src/components/views/context_menus/TagTileContextMenu.js +++ b/src/components/views/context_menus/TagTileContextMenu.js @@ -1,5 +1,6 @@ /* Copyright 2018 New Vector Ltd +Copyright 2019 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. @@ -16,11 +17,12 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import { MatrixClient } from 'matrix-js-sdk'; import { _t } from '../../../languageHandler'; import dis from '../../../dispatcher'; import TagOrderActions from '../../../actions/TagOrderActions'; -import MatrixClientPeg from '../../../MatrixClientPeg'; import sdk from '../../../index'; +import {MenuItem} from "../../structures/ContextualMenu"; export default class TagTileContextMenu extends React.Component { static propTypes = { @@ -29,6 +31,10 @@ export default class TagTileContextMenu extends React.Component { onFinished: PropTypes.func.isRequired, }; + static contextTypes = { + matrixClient: PropTypes.instanceOf(MatrixClient), + }; + constructor() { super(); @@ -45,18 +51,15 @@ export default class TagTileContextMenu extends React.Component { } _onRemoveClick() { - dis.dispatch(TagOrderActions.removeTag( - // XXX: Context menus don't have a MatrixClient context - MatrixClientPeg.get(), - this.props.tag, - )); + dis.dispatch(TagOrderActions.removeTag(this.context.matrixClient, this.props.tag)); this.props.onFinished(); } render() { const TintableSvg = sdk.getComponent("elements.TintableSvg"); + return
-
+ { _t('View Community') } -
-
-
- + +
+ + { _t('Hide') } -
+
; } } diff --git a/src/components/views/context_menus/TopLeftMenu.js b/src/components/views/context_menus/TopLeftMenu.js index 815d0a5f55..d5b6817b00 100644 --- a/src/components/views/context_menus/TopLeftMenu.js +++ b/src/components/views/context_menus/TopLeftMenu.js @@ -25,6 +25,7 @@ import SdkConfig from '../../../SdkConfig'; import { getHostingLink } from '../../../utils/HostingLink'; import MatrixClientPeg from '../../../MatrixClientPeg'; import sdk from "../../../index"; +import {MenuItem} from "../../structures/ContextualMenu"; export class TopLeftMenu extends React.Component { static propTypes = { @@ -58,8 +59,6 @@ export class TopLeftMenu extends React.Component { } render() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - const isGuest = MatrixClientPeg.get().isGuest(); const hostingSignupLink = getHostingLink('user-context-menu'); @@ -69,10 +68,10 @@ export class TopLeftMenu extends React.Component { {_t( "Upgrade to your own domain", {}, { - a: sub => {sub}, + a: sub => {sub}, }, )} - +
; @@ -81,40 +80,40 @@ export class TopLeftMenu extends React.Component { let homePageItem = null; if (this.hasHomePage()) { homePageItem = ( - + {_t("Home")} - + ); } let signInOutItem; if (isGuest) { signInOutItem = ( - + {_t("Sign in")} - + ); } else { signInOutItem = ( - + {_t("Sign out")} - + ); } const settingsItem = ( - + {_t("Settings")} - + ); - return
-
+ return
+
{this.props.displayName}
{this.props.userId}
{hostingSignup}
-
    +
      {homePageItem} {settingsItem} {signInOutItem} diff --git a/src/components/views/elements/TagTile.js b/src/components/views/elements/TagTile.js index 2355cae820..03d7f61204 100644 --- a/src/components/views/elements/TagTile.js +++ b/src/components/views/elements/TagTile.js @@ -1,6 +1,7 @@ /* Copyright 2017 New Vector Ltd. Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2019 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. @@ -15,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import classNames from 'classnames'; @@ -23,12 +24,12 @@ import { MatrixClient } from 'matrix-js-sdk'; import sdk from '../../../index'; import dis from '../../../dispatcher'; import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent } from '../../../Keyboard'; -import * as ContextualMenu from '../../structures/ContextualMenu'; import * as FormattingUtils from '../../../utils/FormattingUtils'; import FlairStore from '../../../stores/FlairStore'; import GroupStore from '../../../stores/GroupStore'; import TagOrderStore from '../../../stores/TagOrderStore'; +import {ContextMenu} from "../../structures/ContextualMenu"; // A class for a child of TagPanel (possibly wrapped in a DNDTagTile) that represents // a thing to click on for the user to filter the visible rooms in the RoomList to: @@ -58,6 +59,8 @@ export default createReactClass({ }, componentDidMount() { + this._contextMenuButton = createRef(); + this.unmounted = false; if (this.props.tag[0] === '+') { FlairStore.addListener('updateGroupProfile', this._onFlairStoreUpdated); @@ -107,53 +110,33 @@ export default createReactClass({ } }, - _openContextMenu: function(x, y, chevronOffset) { - // Hide the (...) immediately - this.setState({ hover: false }); - - const TagTileContextMenu = sdk.getComponent('context_menus.TagTileContextMenu'); - ContextualMenu.createMenu(TagTileContextMenu, { - chevronOffset: chevronOffset, - left: x, - top: y, - tag: this.props.tag, - onFinished: () => { - this.setState({ menuDisplayed: false }); - }, - }); - this.setState({ menuDisplayed: true }); - }, - - onContextButtonClick: function(e) { - e.preventDefault(); - e.stopPropagation(); - - const elementRect = e.target.getBoundingClientRect(); - - // The window X and Y offsets are to adjust position when zoomed in to page - const x = elementRect.right + window.pageXOffset + 3; - const chevronOffset = 12; - let y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset); - y = y - (chevronOffset + 8); // where 8 is half the height of the chevron - - this._openContextMenu(x, y, chevronOffset); - }, - - onContextMenu: function(e) { - e.preventDefault(); - - const chevronOffset = 12; - this._openContextMenu(e.clientX, e.clientY - (chevronOffset + 8), chevronOffset); - }, - onMouseOver: function() { + console.log("DEBUG onMouseOver"); this.setState({hover: true}); }, onMouseOut: function() { + console.log("DEBUG onMouseOut"); this.setState({hover: false}); }, + openMenu: function(e) { + // Prevent the TagTile onClick event firing as well + e.stopPropagation(); + e.preventDefault(); + + this.setState({ + menuDisplayed: true, + hover: false, + }); + }, + + closeMenu: function() { + this.setState({ + menuDisplayed: false, + }); + }, + render: function() { const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); @@ -184,23 +167,47 @@ export default createReactClass({ const tip = this.state.hover ? :
      ; + // FIXME: this ought to use AccessibleButton for a11y but that causes onMouseOut/onMouseOver to fire too much const contextButton = this.state.hover || this.state.menuDisplayed ? -
      +
      { "\u00B7\u00B7\u00B7" } -
      :
      ; - return -
      - - { tip } - { contextButton } - { badgeElement } -
      -
      ; +
      :
      ; + + let contextMenu; + if (this.state.menuDisplayed) { + const elementRect = this._contextMenuButton.current.getBoundingClientRect(); + + // The window X and Y offsets are to adjust position when zoomed in to page + const left = elementRect.right + window.pageXOffset + 3; + const chevronOffset = 12; + let top = (elementRect.top + (elementRect.height / 2) + window.pageYOffset); + top = top - (chevronOffset + 8); // where 8 is half the height of the chevron + + const TagTileContextMenu = sdk.getComponent('context_menus.TagTileContextMenu'); + contextMenu = ( + + + + ); + } + + return + +
      + + { tip } + { contextButton } + { badgeElement } +
      +
      + + { contextMenu } +
      ; }, }); diff --git a/src/components/views/emojipicker/ReactionPicker.js b/src/components/views/emojipicker/ReactionPicker.js index 5e506f39d1..c051ab40bb 100644 --- a/src/components/views/emojipicker/ReactionPicker.js +++ b/src/components/views/emojipicker/ReactionPicker.js @@ -23,7 +23,6 @@ class ReactionPicker extends React.Component { static propTypes = { mxEvent: PropTypes.object.isRequired, onFinished: PropTypes.func.isRequired, - closeMenu: PropTypes.func.isRequired, reactions: PropTypes.object, }; @@ -89,7 +88,6 @@ class ReactionPicker extends React.Component { onChoose(reaction) { this.componentWillUnmount(); - this.props.closeMenu(); this.props.onFinished(); const myReactions = this.getReactions(); if (myReactions.hasOwnProperty(reaction)) { diff --git a/src/components/views/groups/GroupInviteTile.js b/src/components/views/groups/GroupInviteTile.js index 7d7275c55b..99427acb03 100644 --- a/src/components/views/groups/GroupInviteTile.js +++ b/src/components/views/groups/GroupInviteTile.js @@ -1,6 +1,7 @@ /* Copyright 2017, 2018 New Vector Ltd Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2019 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. @@ -15,16 +16,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import { MatrixClient } from 'matrix-js-sdk'; import sdk from '../../../index'; import dis from '../../../dispatcher'; -import AccessibleButton from '../elements/AccessibleButton'; import classNames from 'classnames'; import MatrixClientPeg from "../../../MatrixClientPeg"; -import {createMenu} from "../../structures/ContextualMenu"; +import {ContextMenu} from "../../structures/ContextualMenu"; export default createReactClass({ displayName: 'GroupInviteTile', @@ -46,6 +46,10 @@ export default createReactClass({ }); }, + componentDidMount: function() { + this._contextMenuButton = createRef(); + }, + onClick: function(e) { dis.dispatch({ action: 'view_group', @@ -69,54 +73,34 @@ export default createReactClass({ }); }, - _showContextMenu: function(x, y, chevronOffset) { - const GroupInviteTileContextMenu = sdk.getComponent('context_menus.GroupInviteTileContextMenu'); - - createMenu(GroupInviteTileContextMenu, { - chevronOffset, - left: x, - top: y, - group: this.props.group, - onFinished: () => { - this.setState({ menuDisplayed: false }); - }, - }); - this.setState({ menuDisplayed: true }); - }, - - onContextMenu: function(e) { - // Prevent the RoomTile onClick event firing as well - e.preventDefault(); + openMenu: function(e) { // Only allow non-guests to access the context menu if (MatrixClientPeg.get().isGuest()) return; - const chevronOffset = 12; - this._showContextMenu(e.clientX, e.clientY - (chevronOffset + 8), chevronOffset); - }, - - onBadgeClicked: function(e) { - // Prevent the RoomTile onClick event firing as well + // Prevent the GroupInviteTile onClick event firing as well e.stopPropagation(); - // Only allow non-guests to access the context menu - if (MatrixClientPeg.get().isGuest()) return; + e.preventDefault(); + + const state = { + menuDisplayed: true, + }; // If the badge is clicked, then no longer show tooltip if (this.props.collapsed) { - this.setState({ hover: false }); + state.hover = false; } - const elementRect = e.target.getBoundingClientRect(); + this.setState(state); + }, - // The window X and Y offsets are to adjust position when zoomed in to page - const x = elementRect.right + window.pageXOffset + 3; - const chevronOffset = 12; - let y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset); - y = y - (chevronOffset + 8); // where 8 is half the height of the chevron - - this._showContextMenu(x, y, chevronOffset); + closeMenu: function() { + this.setState({ + menuDisplayed: false, + }); }, render: function() { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); const groupName = this.props.group.name || this.props.group.groupId; @@ -139,7 +123,11 @@ export default createReactClass({ }); const badgeContent = badgeEllipsis ? '\u00B7\u00B7\u00B7' : '!'; - const badge =
      { badgeContent }
      ; + const badge = ( + + { badgeContent } + + ); let tooltip; if (this.props.collapsed && this.state.hover) { @@ -153,12 +141,30 @@ export default createReactClass({ 'mx_GroupInviteTile': true, }); - return ( - + + + ); + } + + return +
      { av } @@ -169,6 +175,8 @@ export default createReactClass({
      { tooltip }
      - ); + + { contextMenu } +
      ; }, }); diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index acd8263410..b67107eebf 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -1,6 +1,7 @@ /* Copyright 2019 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2019 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. @@ -15,17 +16,146 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {useState, useEffect, useRef} from 'react'; import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import sdk from '../../../index'; import dis from '../../../dispatcher'; import Modal from '../../../Modal'; -import { createMenu } from '../../structures/ContextualMenu'; +import {ContextMenu} from '../../structures/ContextualMenu'; import { isContentActionable, canEditContent } from '../../../utils/EventUtils'; import {RoomContext} from "../../structures/RoomView"; +const useContextMenu = () => { + const _button = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const open = () => { + setIsOpen(true); + }; + const close = () => { + setIsOpen(false); + }; + + return [isOpen, _button, open, close, setIsOpen]; +}; + +const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => { + const [menuDisplayed, _button, openMenu, closeMenu] = useContextMenu(); + useEffect(() => { + onFocusChange(menuDisplayed); + }, [onFocusChange, menuDisplayed]); + + let contextMenu; + if (menuDisplayed) { + const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu'); + + const tile = getTile && getTile(); + const replyThread = getReplyThread && getReplyThread(); + + const onCryptoClick = () => { + Modal.createTrackedDialogAsync('Encrypted Event Dialog', '', + import('../../../async-components/views/dialogs/EncryptedEventDialog'), + {event: mxEvent}, + ); + }; + + let e2eInfoCallback = null; + if (mxEvent.isEncrypted()) { + e2eInfoCallback = onCryptoClick; + } + + const menuOptions = { + chevronFace: "none", + }; + + const buttonRect = _button.current.getBoundingClientRect(); + // The window X and Y offsets are to adjust position when zoomed in to page + const buttonRight = buttonRect.right + window.pageXOffset; + const buttonBottom = buttonRect.bottom + window.pageYOffset; + const buttonTop = buttonRect.top + window.pageYOffset; + // Align the right edge of the menu to the right edge of the button + menuOptions.right = window.innerWidth - buttonRight; + // Align the menu vertically on whichever side of the button has more + // space available. + if (buttonBottom < window.innerHeight / 2) { + menuOptions.top = buttonBottom; + } else { + menuOptions.bottom = window.innerHeight - buttonTop; + } + + contextMenu = + + ; + } + + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return + + + { contextMenu } + ; +}; + +const ReactButton = ({mxEvent, reactions}) => { + const [menuDisplayed, _button, openMenu, closeMenu] = useContextMenu(); + + let contextMenu; + if (menuDisplayed) { + const menuOptions = { + chevronFace: "none", + }; + + const buttonRect = _button.current.getBoundingClientRect(); + // The window X and Y offsets are to adjust position when zoomed in to page + const buttonRight = buttonRect.right + window.pageXOffset; + const buttonBottom = buttonRect.bottom + window.pageYOffset; + const buttonTop = buttonRect.top + window.pageYOffset; + // Align the right edge of the menu to the right edge of the button + menuOptions.right = window.innerWidth - buttonRight; + // Align the menu vertically on whichever side of the button has more + // space available. + if (buttonBottom < window.innerHeight / 2) { + menuOptions.top = buttonBottom; + } else { + menuOptions.bottom = window.innerHeight - buttonTop; + } + + const ReactionPicker = sdk.getComponent('emojipicker.ReactionPicker'); + contextMenu = + + ; + } + + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return + + + { contextMenu } + ; +}; + export default class MessageActionBar extends React.PureComponent { static propTypes = { mxEvent: PropTypes.object.isRequired, @@ -62,14 +192,6 @@ export default class MessageActionBar extends React.PureComponent { this.props.onFocusChange(focused); }; - onCryptoClick = () => { - const event = this.props.mxEvent; - Modal.createTrackedDialogAsync('Encrypted Event Dialog', '', - import('../../../async-components/views/dialogs/EncryptedEventDialog'), - {event}, - ); - }; - onReplyClick = (ev) => { dis.dispatch({ action: 'reply_to_event', @@ -84,71 +206,6 @@ export default class MessageActionBar extends React.PureComponent { }); }; - getMenuOptions = (ev) => { - const menuOptions = {}; - const buttonRect = ev.target.getBoundingClientRect(); - // The window X and Y offsets are to adjust position when zoomed in to page - const buttonRight = buttonRect.right + window.pageXOffset; - const buttonBottom = buttonRect.bottom + window.pageYOffset; - const buttonTop = buttonRect.top + window.pageYOffset; - // Align the right edge of the menu to the right edge of the button - menuOptions.right = window.innerWidth - buttonRight; - // Align the menu vertically on whichever side of the button has more - // space available. - if (buttonBottom < window.innerHeight / 2) { - menuOptions.top = buttonBottom; - } else { - menuOptions.bottom = window.innerHeight - buttonTop; - } - return menuOptions; - }; - - onReactClick = (ev) => { - const ReactionPicker = sdk.getComponent('emojipicker.ReactionPicker'); - - const menuOptions = { - ...this.getMenuOptions(ev), - mxEvent: this.props.mxEvent, - reactions: this.props.reactions, - chevronFace: "none", - onFinished: () => this.onFocusChange(false), - }; - - createMenu(ReactionPicker, menuOptions); - - this.onFocusChange(true); - }; - - onOptionsClick = (ev) => { - const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu'); - - const { getTile, getReplyThread } = this.props; - const tile = getTile && getTile(); - const replyThread = getReplyThread && getReplyThread(); - - let e2eInfoCallback = null; - if (this.props.mxEvent.isEncrypted()) { - e2eInfoCallback = () => this.onCryptoClick(); - } - - const menuOptions = { - ...this.getMenuOptions(ev), - mxEvent: this.props.mxEvent, - chevronFace: "none", - permalinkCreator: this.props.permalinkCreator, - eventTileOps: tile && tile.getEventTileOps ? tile.getEventTileOps() : undefined, - collapseReplyThread: replyThread && replyThread.canCollapse() ? replyThread.collapse : undefined, - e2eInfoCallback: e2eInfoCallback, - onFinished: () => { - this.onFocusChange(false); - }, - }; - - createMenu(MessageContextMenu, menuOptions); - - this.onFocusChange(true); - }; - render() { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); @@ -158,11 +215,7 @@ export default class MessageActionBar extends React.PureComponent { if (isContentActionable(this.props.mxEvent)) { if (this.context.room.canReact) { - reactButton = ; + reactButton = ; } if (this.context.room.canReply) { replyButton =
      ; } diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index dc893f0049..9702b24eff 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -17,8 +17,7 @@ See the License for the specific language governing permissions and limitations under the License. */ - -import React from 'react'; +import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import classNames from 'classnames'; @@ -26,10 +25,9 @@ import dis from '../../../dispatcher'; import MatrixClientPeg from '../../../MatrixClientPeg'; import DMRoomMap from '../../../utils/DMRoomMap'; import sdk from '../../../index'; -import {createMenu} from '../../structures/ContextualMenu'; +import {ContextMenu} from '../../structures/ContextualMenu'; import * as RoomNotifs from '../../../RoomNotifs'; import * as FormattingUtils from '../../../utils/FormattingUtils'; -import AccessibleButton from '../elements/AccessibleButton'; import ActiveRoomObserver from '../../../ActiveRoomObserver'; import RoomViewStore from '../../../stores/RoomViewStore'; import SettingsStore from "../../../settings/SettingsStore"; @@ -147,6 +145,8 @@ module.exports = createReactClass({ }, componentDidMount: function() { + this._contextMenuButton = createRef(); + const cli = MatrixClientPeg.get(); cli.on("accountData", this.onAccountData); cli.on("Room.name", this.onRoomName); @@ -229,32 +229,6 @@ module.exports = createReactClass({ this.badgeOnMouseLeave(); }, - _showContextMenu: function(x, y, chevronOffset) { - const RoomTileContextMenu = sdk.getComponent('context_menus.RoomTileContextMenu'); - - createMenu(RoomTileContextMenu, { - chevronOffset, - left: x, - top: y, - room: this.props.room, - onFinished: () => { - this.setState({ menuDisplayed: false }); - this.props.refreshSubList(); - }, - }); - this.setState({ menuDisplayed: true }); - }, - - onContextMenu: function(e) { - // Prevent the RoomTile onClick event firing as well - e.preventDefault(); - // Only allow non-guests to access the context menu - if (MatrixClientPeg.get().isGuest()) return; - - const chevronOffset = 12; - this._showContextMenu(e.clientX, e.clientY - (chevronOffset + 8), chevronOffset); - }, - badgeOnMouseEnter: function() { // Only allow non-guests to access the context menu // and only change it if it needs to change @@ -267,26 +241,31 @@ module.exports = createReactClass({ this.setState( { badgeHover: false } ); }, - onOpenMenu: function(e) { - // Prevent the RoomTile onClick event firing as well - e.stopPropagation(); + openMenu: function(e) { // Only allow non-guests to access the context menu if (MatrixClientPeg.get().isGuest()) return; + // Prevent the RoomTile onClick event firing as well + e.stopPropagation(); + e.preventDefault(); + + const state = { + menuDisplayed: true, + }; + // If the badge is clicked, then no longer show tooltip if (this.props.collapsed) { - this.setState({ hover: false }); + state.hover = false; } - const elementRect = e.target.getBoundingClientRect(); + this.setState(state); + }, - // The window X and Y offsets are to adjust position when zoomed in to page - const x = elementRect.right + window.pageXOffset + 3; - const chevronOffset = 12; - let y = (elementRect.top + (elementRect.height / 2) + window.pageYOffset); - y = y - (chevronOffset + 8); // where 8 is half the height of the chevron - - this._showContextMenu(x, y, chevronOffset); + closeMenu: function() { + this.setState({ + menuDisplayed: false, + }); + this.props.refreshSubList(); }, render: function() { @@ -360,9 +339,13 @@ module.exports = createReactClass({ // incomingCallBox = ; //} + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + let contextMenuButton; if (!MatrixClientPeg.get().isGuest()) { - contextMenuButton = ; + contextMenuButton = ( + + ); } const RoomAvatar = sdk.getComponent('avatars.RoomAvatar'); @@ -393,32 +376,54 @@ module.exports = createReactClass({ ariaLabel += " " + _t("Unread messages."); } - return -
      -
      - - { dmIndicator } + let contextMenu; + if (this.state.menuDisplayed) { + const elementRect = this._contextMenuButton.current.getBoundingClientRect(); + // The window X and Y offsets are to adjust position when zoomed in to page + const left = elementRect.right + window.pageXOffset + 3; + const chevronOffset = 12; + let top = (elementRect.top + (elementRect.height / 2) + window.pageYOffset); + top = top - (chevronOffset + 8); // where 8 is half the height of the chevron + + const RoomTileContextMenu = sdk.getComponent('context_menus.RoomTileContextMenu'); + contextMenu = ( + + + + ); + } + + return + +
      +
      + + { dmIndicator } +
      -
      -
      -
      - { label } - { subtextLabel } +
      +
      + { label } + { subtextLabel } +
      + { contextMenuButton } + { badge }
      - { contextMenuButton } - { badge } -
      - { /* { incomingCallBox } */ } - { tooltip } - ; + { /* { incomingCallBox } */ } + { tooltip } + + + { contextMenu } + ; }, }); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 5f6e327944..75be4205df 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1494,6 +1494,7 @@ "Report Content": "Report Content", "Failed to set Direct Message status of room": "Failed to set Direct Message status of room", "Failed to forget room %(errCode)s": "Failed to forget room %(errCode)s", + "Notification settings": "Notification settings", "All messages (noisy)": "All messages (noisy)", "All messages": "All messages", "Mentions only": "Mentions only",