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/ContextMenu.js b/src/components/structures/ContextMenu.js new file mode 100644 index 0000000000..1087f48b28 --- /dev/null +++ b/src/components/structures/ContextMenu.js @@ -0,0 +1,476 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +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. +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, {useRef, useState} from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import {Key} from "../../Keyboard"; +import sdk from "../../index"; +import AccessibleButton from "../views/elements/AccessibleButton"; + +// Shamelessly ripped off Modal.js. There's probably a better way +// of doing reusable widgets like dialog boxes & menus where we go and +// pass in a custom control as the actual body. + +const ContextualMenuContainerId = "mx_ContextualMenu_Container"; + +function getOrCreateContainer() { + let container = document.getElementById(ContextualMenuContainerId); + + if (!container) { + container = document.createElement("div"); + container.id = ContextualMenuContainerId; + document.body.appendChild(container); + } + + return container; +} + +const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]); +// 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 class ContextMenu 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.isRequired, + 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, + }; + + static defaultProps = { + hasBackground: true, + }; + + constructor() { + super(); + this.state = { + contextMenuElem: 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; + + let first = element.querySelector('[role^="menuitem"]'); + if (!first) { + first = element.querySelector('[tab-index]'); + } + if (first) { + first.focus(); + } + + this.setState({ + contextMenuElem: element, + }); + }; + + onContextMenu = (e) => { + if (this.props.onFinished) { + this.props.onFinished(); + + 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(); + } + }; + + _onMoveFocusHomeEnd = (element, up) => { + let results = element.querySelectorAll('[role^="menuitem"]'); + if (!results) { + results = element.querySelectorAll('[tab-index]'); + } + if (results && results.length) { + if (up) { + results[0].focus(); + } else { + results[results.length - 1].focus(); + } + } + }; + + _onKeyDown = (ev) => { + let handled = true; + + switch (ev.key) { + case Key.TAB: + case Key.ESCAPE: + this.props.onFinished(); + break; + case Key.ARROW_UP: + this._onMoveFocus(ev.target, true); + break; + case Key.ARROW_DOWN: + this._onMoveFocus(ev.target, false); + break; + case Key.HOME: + this._onMoveFocusHomeEnd(this.state.contextMenuElem, true); + break; + case Key.END: + this._onMoveFocusHomeEnd(this.state.contextMenuElem, false); + break; + default: + handled = false; + } + + if (handled) { + // consume all other keys in context menu + ev.stopPropagation(); + ev.preventDefault(); + } + }; + + renderMenu(hasBackground=this.props.hasBackground) { + 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.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : 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 (hasBackground) { + background = ( +
+ ); + } + + return ( +
+
+ { chevron } + { props.children } +
+ { background } +
+ ); + } + + render() { + return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer()); + } +} + +// Semantic component for representing the AccessibleButton which launches a +export const ContextMenuButton = ({ label, isExpanded, children, ...props }) => { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return ( + + { children } + + ); +}; +ContextMenuButton.propTypes = { + ...AccessibleButton.propTypes, + label: PropTypes.string.isRequired, + isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open +}; + +// Semantic component for representing a role=menuitem +export const MenuItem = ({children, label, ...props}) => { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return ( + + { children } + + ); +}; +MenuItem.propTypes = { + ...AccessibleButton.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 = { + ...AccessibleButton.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 = { + ...AccessibleButton.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 = { + ...AccessibleButton.propTypes, + label: PropTypes.string, // optional + active: PropTypes.bool.isRequired, + disabled: PropTypes.bool, // optional + className: PropTypes.string, // optional + onClick: PropTypes.func.isRequired, +}; + +// Placement method for to position context menu to right of elementRect with chevronOffset +export const toRightOf = (elementRect, chevronOffset=12) => { + const left = elementRect.right + window.pageXOffset + 3; + let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset; + top -= chevronOffset + 8; // where 8 is half the height of the chevron + return {left, top}; +}; + +// Placement method for to position context menu right-aligned and flowing to the left of elementRect +export const aboveLeftOf = (elementRect, chevronFace="none") => { + const menuOptions = { chevronFace }; + + const buttonRight = elementRect.right + window.pageXOffset; + const buttonBottom = elementRect.bottom + window.pageYOffset; + const buttonTop = elementRect.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; +}; + +export 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]; +}; + +export default class LegacyContextMenu extends ContextMenu { + render() { + return this.renderMenu(false); + } +} + +// XXX: Deprecated, used only for dynamic Tooltips. Avoid using at all costs. +export function createMenu(ElementClass, props) { + const onFinished = function(...args) { + ReactDOM.unmountComponentAtNode(getOrCreateContainer()); + + if (props && props.onFinished) { + props.onFinished.apply(null, args); + } + }; + + const menu = + + ; + + ReactDOM.render(menu, getOrCreateContainer()); + + return {close: onFinished}; +} diff --git a/src/components/structures/ContextualMenu.js b/src/components/structures/ContextualMenu.js deleted file mode 100644 index 3f8c87efef..0000000000 --- a/src/components/structures/ContextualMenu.js +++ /dev/null @@ -1,253 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -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. -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 ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import {focusCapturedRef} from "../../utils/Accessibility"; -import {KeyCode} from "../../Keyboard"; - -// Shamelessly ripped off Modal.js. There's probably a better way -// of doing reusable widgets like dialog boxes & menus where we go and -// pass in a custom control as the actual body. - -const ContextualMenuContainerId = "mx_ContextualMenu_Container"; - -function getOrCreateContainer() { - let container = document.getElementById(ContextualMenuContainerId); - - if (!container) { - container = document.createElement("div"); - container.id = ContextualMenuContainerId; - document.body.appendChild(container); - } - - return container; -} - -export default class ContextualMenu 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, - - // The component to render as the context menu - elementClass: PropTypes.element.isRequired, - // on resize callback - windowResize: PropTypes.func, - // method to close menu - closeMenu: PropTypes.func.isRequired, - }; - - constructor() { - super(); - this.state = { - contextMenuRect: null, - }; - - this.onContextMenu = this.onContextMenu.bind(this); - this.collectContextMenuRect = this.collectContextMenuRect.bind(this); - } - - collectContextMenuRect(element) { - // We don't need to clean up when unmounting, so ignore - if (!element) return; - - // For screen readers to find the thing - focusCapturedRef(element); - - 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); - }); - } - } - - _onKeyDown = (ev) => { - if (ev.keyCode === KeyCode.ESCAPE) { - ev.stopPropagation(); - ev.preventDefault(); - this.props.closeMenu(); - } - }; - - 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); - } - - const chevron = hasChevron ? -
: - undefined; - const className = 'mx_ContextualMenu_wrapper'; - - 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; - } - - const ElementClass = props.elementClass; - - // FIXME: If a menu uses getDefaultProps it clobbers the onFinished - // property set here so you can't close the menu from a button click! - return
-
- { chevron } - -
- { props.hasBackground &&
} -
; - } -} - -export function createMenu(ElementClass, props, hasBackground=true) { - const closeMenu = function(...args) { - ReactDOM.unmountComponentAtNode(getOrCreateContainer()); - - if (props && props.onFinished) { - props.onFinished.apply(null, args); - } - }; - - // We only reference closeMenu once per call to createMenu - const menu = ; - - ReactDOM.render(menu, getOrCreateContainer()); - - return {close: closeMenu}; -} diff --git a/src/components/structures/TopLeftMenuButton.js b/src/components/structures/TopLeftMenuButton.js index 42b8623e56..e7928ab4d7 100644 --- a/src/components/structures/TopLeftMenuButton.js +++ b/src/components/structures/TopLeftMenuButton.js @@ -17,15 +17,13 @@ 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, ContextMenuButton} from "./ContextMenu"; const AVATAR_SIZE = 28; @@ -40,11 +38,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 +90,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,14 +115,29 @@ export default class TopLeftMenuButton extends React.Component { chevronElement = ; } - return ( - + + + ); + } + + return + this._buttonRef = r} - aria-label={_t("Your profile")} - aria-haspopup={true} - aria-expanded={this.state.menuDisplayed} + label={_t("Your profile")} + isExpanded={this.state.menuDisplayed} > { 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/avatars/MemberStatusMessageAvatar.js b/src/components/views/avatars/MemberStatusMessageAvatar.js index 0258c4b0c8..ed73dd33b9 100644 --- a/src/components/views/avatars/MemberStatusMessageAvatar.js +++ b/src/components/views/avatars/MemberStatusMessageAvatar.js @@ -14,15 +14,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 MatrixClientPeg from '../../../MatrixClientPeg'; -import AccessibleButton from '../elements/AccessibleButton'; +import {_t} from "../../../languageHandler"; import MemberAvatar from '../avatars/MemberAvatar'; import classNames from 'classnames'; -import * as ContextualMenu from "../../structures/ContextualMenu"; import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu"; import SettingsStore from "../../../settings/SettingsStore"; +import {ContextMenu, ContextMenuButton} from "../../structures/ContextMenu"; export default class MemberStatusMessageAvatar extends React.Component { static propTypes = { @@ -43,7 +43,10 @@ export default class MemberStatusMessageAvatar extends React.Component { this.state = { hasStatus: this.hasStatus, + menuDisplayed: false, }; + + this._button = createRef(); } componentWillMount() { @@ -86,25 +89,12 @@ export default class MemberStatusMessageAvatar extends React.Component { }); }; - _onClick = (e) => { - e.stopPropagation(); + openMenu = () => { + this.setState({ menuDisplayed: true }); + }; - const elementRect = e.target.getBoundingClientRect(); - - const x = (elementRect.left + window.pageXOffset); - const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom - const chevronOffset = (elementRect.width - chevronWidth) / 2; - const chevronMargin = 1; // Add some spacing away from target - const y = elementRect.top + window.pageYOffset - chevronMargin; - - ContextualMenu.createMenu(StatusMessageContextMenu, { - chevronOffset: chevronOffset, - chevronFace: 'bottom', - left: x, - top: y, - menuWidth: 226, - user: this.props.member.user, - }); + closeMenu = () => { + this.setState({ menuDisplayed: false }); }; render() { @@ -124,10 +114,39 @@ export default class MemberStatusMessageAvatar extends React.Component { "mx_MemberStatusMessageAvatar_hasStatus": this.state.hasStatus, }); - return - {avatar} - ; + let contextMenu; + if (this.state.menuDisplayed) { + const elementRect = this._button.current.getBoundingClientRect(); + + const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom + const chevronMargin = 1; // Add some spacing away from target + + contextMenu = ( + + + + ); + } + + return + + {avatar} + + + { contextMenu } + ; } } diff --git a/src/components/views/context_menus/GroupInviteTileContextMenu.js b/src/components/views/context_menus/GroupInviteTileContextMenu.js index 8c0d9c215b..3feffbc0d9 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/ContextMenu"; 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/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index a832b2fbb2..efbfc4322f 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -31,6 +31,7 @@ import Resend from '../../../Resend'; import SettingsStore from '../../../settings/SettingsStore'; import { isUrlPermitted } from '../../../HtmlUtils'; import { isContentActionable } from '../../../utils/EventUtils'; +import {MenuItem} from "../../structures/ContextMenu"; function canCancel(eventStatus) { return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; @@ -289,8 +290,6 @@ module.exports = createReactClass({ }, render: function() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - const cli = MatrixClientPeg.get(); const me = cli.getUserId(); const mxEvent = this.props.mxEvent; @@ -322,89 +321,89 @@ module.exports = createReactClass({ if (!mxEvent.isRedacted()) { if (eventStatus === EventStatus.NOT_SENT) { resendButton = ( - + { _t('Resend') } - + ); } if (editStatus === EventStatus.NOT_SENT) { resendEditButton = ( - + { _t('Resend edit') } - + ); } if (unsentReactionsCount !== 0) { resendReactionsButton = ( - + { _t('Resend %(unsentCount)s reaction(s)', {unsentCount: unsentReactionsCount}) } - + ); } } if (redactStatus === EventStatus.NOT_SENT) { resendRedactionButton = ( - + { _t('Resend removal') } - + ); } if (isSent && this.state.canRedact) { redactButton = ( - + { _t('Remove') } - + ); } if (allowCancel) { cancelButton = ( - + { _t('Cancel Sending') } - + ); } if (isContentActionable(mxEvent)) { forwardButton = ( - + { _t('Forward Message') } - + ); if (this.state.canPin) { pinButton = ( - + { this._isPinned() ? _t('Unpin Message') : _t('Pin Message') } - + ); } } const viewSourceButton = ( - + { _t('View Source') } - + ); if (mxEvent.getType() !== mxEvent.getWireType()) { viewClearSourceButton = ( - + { _t('View Decrypted Source') } - + ); } if (this.props.eventTileOps) { if (this.props.eventTileOps.isWidgetHidden()) { unhidePreviewButton = ( - + { _t('Unhide Preview') } - + ); } } @@ -415,19 +414,19 @@ module.exports = createReactClass({ } // XXX: if we use room ID, we should also include a server where the event can be found (other than in the domain of the event ID) const permalinkButton = ( - + { mxEvent.isRedacted() || mxEvent.getType() !== 'm.room.message' ? _t('Share Permalink') : _t('Share Message') } - + ); if (this.props.eventTileOps && this.props.eventTileOps.getInnerText) { quoteButton = ( - + { _t('Quote') } - + ); } @@ -437,7 +436,7 @@ module.exports = createReactClass({ isUrlPermitted(mxEvent.event.content.external_url) ) { externalURLButton = ( - + { _t('Source URL') } - + ); } if (this.props.collapseReplyThread) { collapseReplyThread = ( - + { _t('Collapse Reply Thread') } - + ); } let e2eInfo; if (this.props.e2eInfoCallback) { e2eInfo = ( - + { _t('End-to-end encryption information') } - + ); } let reportEventButton; if (mxEvent.getSender() !== me) { reportEventButton = ( - + { _t('Report Content') } - + ); } diff --git a/src/components/views/context_menus/RoomTileContextMenu.js b/src/components/views/context_menus/RoomTileContextMenu.js index 97433e1f77..f5e68bd20b 100644 --- a/src/components/views/context_menus/RoomTileContextMenu.js +++ b/src/components/views/context_menus/RoomTileContextMenu.js @@ -32,6 +32,36 @@ import Modal from '../../../Modal'; import RoomListActions from '../../../actions/RoomListActions'; import RoomViewStore from '../../../stores/RoomViewStore'; import {sleep} from "../../../utils/promise"; +import {MenuItem, MenuItemCheckbox, MenuItemRadio} from "../../structures/ContextMenu"; + +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') } - +
); }, @@ -306,8 +318,6 @@ module.exports = createReactClass({ return null; } - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - let leaveClickHandler = null; let leaveText = null; @@ -329,52 +339,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 +382,11 @@ module.exports = createReactClass({ case 'join': return
{ this._renderNotifMenu() } -
+
{ this._renderLeaveMenu(myMembership) } -
+
{ this._renderRoomTagMenu() } -
+
{ this._renderSettingsMenu() }
; case 'invite': @@ -400,7 +396,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..1af0c9ae66 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/ContextMenu"; 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..b9aabdc608 100644 --- a/src/components/views/context_menus/TopLeftMenu.js +++ b/src/components/views/context_menus/TopLeftMenu.js @@ -24,7 +24,7 @@ import Modal from "../../../Modal"; import SdkConfig from '../../../SdkConfig'; import { getHostingLink } from '../../../utils/HostingLink'; import MatrixClientPeg from '../../../MatrixClientPeg'; -import sdk from "../../../index"; +import {MenuItem} from "../../structures/ContextMenu"; export class TopLeftMenu extends React.Component { static propTypes = { @@ -58,8 +58,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 +67,10 @@ export class TopLeftMenu extends React.Component { {_t( "Upgrade to your own domain", {}, { - a: sub => {sub}, + a: sub => {sub}, }, )} - +
; @@ -81,40 +79,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/context_menus/WidgetContextMenu.js b/src/components/views/context_menus/WidgetContextMenu.js index 43e7e172cc..1ec74b2e6c 100644 --- a/src/components/views/context_menus/WidgetContextMenu.js +++ b/src/components/views/context_menus/WidgetContextMenu.js @@ -16,8 +16,8 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import sdk from '../../../index'; import {_t} from '../../../languageHandler'; +import {MenuItem} from "../../structures/ContextMenu"; export default class WidgetContextMenu extends React.Component { static propTypes = { @@ -71,50 +71,45 @@ export default class WidgetContextMenu extends React.Component { }; render() { - const AccessibleButton = sdk.getComponent("views.elements.AccessibleButton"); - const options = []; if (this.props.onEditClicked) { options.push( - + {_t("Edit")} - , + , ); } if (this.props.onReloadClicked) { options.push( - + {_t("Reload")} - , + , ); } if (this.props.onSnapshotClicked) { options.push( - + {_t("Take picture")} - , + , ); } if (this.props.onDeleteClicked) { options.push( - + {_t("Remove for everyone")} - , + , ); } // Push this last so it appears last. It's always present. options.push( - + {_t("Remove for me")} - , + , ); // Put separators between the options diff --git a/src/components/views/dialogs/ShareDialog.js b/src/components/views/dialogs/ShareDialog.js index 65d581c3bc..a45eff0d53 100644 --- a/src/components/views/dialogs/ShareDialog.js +++ b/src/components/views/dialogs/ShareDialog.js @@ -21,7 +21,8 @@ import sdk from '../../../index'; import { _t } from '../../../languageHandler'; import QRCode from 'qrcode-react'; import {RoomPermalinkCreator, makeGroupPermalink, makeUserPermalink} from "../../../utils/permalinks/Permalinks"; -import * as ContextualMenu from "../../structures/ContextualMenu"; +import * as ContextMenu from "../../structures/ContextMenu"; +import {toRightOf} from "../../structures/ContextMenu"; const socials = [ { @@ -102,18 +103,12 @@ export default class ShareDialog extends React.Component { console.error('Failed to copy: ', err); } - const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu'); const buttonRect = e.target.getBoundingClientRect(); - - // The window X and Y offsets are to adjust position when zoomed in to page - const x = buttonRect.right + window.pageXOffset; - const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19; - const {close} = ContextualMenu.createMenu(GenericTextContextMenu, { - chevronOffset: 10, - left: x, - top: y, + const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu'); + const {close} = ContextMenu.createMenu(GenericTextContextMenu, { + ...toRightOf(buttonRect, 11), message: successful ? _t('Copied!') : _t('Failed to copy'), - }, false); + }); // Drop a reference to this close handler for componentWillUnmount this.closeCopiedTooltip = e.target.onmouseleave = close; } diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index 9a29843d3b..55cb9a5487 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -18,7 +18,7 @@ limitations under the License. import url from 'url'; import qs from 'querystring'; -import React from 'react'; +import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import MatrixClientPeg from '../../../MatrixClientPeg'; import WidgetMessaging from '../../../WidgetMessaging'; @@ -35,7 +35,7 @@ import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import classNames from 'classnames'; import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; -import {createMenu} from "../../structures/ContextualMenu"; +import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu"; import PersistedElement from "./PersistedElement"; const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; @@ -62,6 +62,8 @@ export default class AppTile extends React.Component { this._revokeWidgetPermission = this._revokeWidgetPermission.bind(this); this._onPopoutWidgetClick = this._onPopoutWidgetClick.bind(this); this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this); + + this._contextMenuButton = createRef(); } /** @@ -89,6 +91,7 @@ export default class AppTile extends React.Component { error: null, deleting: false, widgetPageTitle: newProps.widgetPageTitle, + menuDisplayed: false, }; } @@ -555,45 +558,12 @@ export default class AppTile extends React.Component { this.refs.appFrame.src = this.refs.appFrame.src; } - _getMenuOptions(ev) { - // TODO: This block of code gets copy/pasted a lot. We should make that happen less. - const menuOptions = {}; - const buttonRect = ev.target.getBoundingClientRect(); - // The window X and Y offsets are to adjust position when zoomed in to page - const buttonLeft = buttonRect.left + window.pageXOffset; - const buttonTop = buttonRect.top + window.pageYOffset; - // Align the right edge of the menu to the left edge of the button - menuOptions.right = window.innerWidth - buttonLeft; - // Align the menu vertically on whichever side of the button has more - // space available. - if (buttonTop < window.innerHeight / 2) { - menuOptions.top = buttonTop; - } else { - menuOptions.bottom = window.innerHeight - buttonTop; - } - return menuOptions; - } + _onContextMenuClick = () => { + this.setState({ menuDisplayed: true }); + }; - _onContextMenuClick = (ev) => { - const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu'); - const menuOptions = { - ...this._getMenuOptions(ev), - - // A revoke handler is always required - onRevokeClicked: this._onRevokeClicked, - }; - - const canUserModify = this._canUserModify(); - const showEditButton = Boolean(this._scalarClient && canUserModify); - const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify; - const showPictureSnapshotButton = this._hasCapability('m.capability.screenshot') && this.props.show; - - if (showEditButton) menuOptions.onEditClicked = this._onEditClick; - if (showDeleteButton) menuOptions.onDeleteClicked = this._onDeleteClick; - if (showPictureSnapshotButton) menuOptions.onSnapshotClicked = this._onSnapshotClick; - if (this.props.showReload) menuOptions.onReloadClicked = this._onReloadWidgetClick; - - createMenu(WidgetContextMenu, menuOptions); + _closeContextMenu = () => { + this.setState({ menuDisplayed: false }); }; render() { @@ -601,7 +571,7 @@ export default class AppTile extends React.Component { // Don't render widget if it is in the process of being deleted if (this.state.deleting) { - return
      ; + return
      ; } // Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin @@ -697,7 +667,31 @@ export default class AppTile extends React.Component { mx_AppTileMenuBar_expanded: this.props.show, }); - return ( + let contextMenu; + if (this.state.menuDisplayed) { + const elementRect = this._contextMenuButton.current.getBoundingClientRect(); + + const canUserModify = this._canUserModify(); + const showEditButton = Boolean(this._scalarClient && canUserModify); + const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify; + const showPictureSnapshotButton = this._hasCapability('m.capability.screenshot') && this.props.show; + + const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu'); + contextMenu = ( + + + + ); + } + + return
      { this.props.showMenubar &&
      @@ -725,20 +719,24 @@ export default class AppTile extends React.Component { onClick={this._onPopoutWidgetClick} /> } { /* Context menu */ } - { }
      } { appTileBody }
      - ); + + { contextMenu } +
      ; } } -AppTile.displayName ='AppTile'; +AppTile.displayName = 'AppTile'; AppTile.propTypes = { id: PropTypes.string.isRequired, diff --git a/src/components/views/elements/TagTile.js b/src/components/views/elements/TagTile.js index 2355cae820..767980f0a0 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,20 +16,21 @@ 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'; import { MatrixClient } from 'matrix-js-sdk'; import sdk from '../../../index'; import dis from '../../../dispatcher'; +import {_t} from '../../../languageHandler'; 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, ContextMenuButton, toRightOf} from "../../structures/ContextMenu"; // 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 +60,8 @@ export default createReactClass({ }, componentDidMount() { + this._contextMenuButton = createRef(); + this.unmounted = false; if (this.props.tag[0] === '+') { FlairStore.addListener('updateGroupProfile', this._onFlairStoreUpdated); @@ -107,56 +111,35 @@ 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'); const Tooltip = sdk.getComponent('elements.Tooltip'); const profile = this.state.profile || {}; const name = profile.name || this.props.tag; @@ -184,23 +167,46 @@ 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(); + 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..bc861eb156 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,16 @@ 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 {_t} from '../../../languageHandler'; import classNames from 'classnames'; import MatrixClientPeg from "../../../MatrixClientPeg"; -import {createMenu} from "../../structures/ContextualMenu"; +import {ContextMenu, ContextMenuButton, toRightOf} from "../../structures/ContextMenu"; export default createReactClass({ displayName: 'GroupInviteTile', @@ -46,6 +47,10 @@ export default createReactClass({ }); }, + componentDidMount: function() { + this._contextMenuButton = createRef(); + }, + onClick: function(e) { dis.dispatch({ action: 'view_group', @@ -69,54 +74,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 +124,17 @@ 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 +148,24 @@ export default createReactClass({ 'mx_GroupInviteTile': true, }); - return ( - + + + ); + } + + return +
      { av } @@ -169,6 +176,8 @@ export default createReactClass({
      { tooltip }
      - ); + + { contextMenu } +
      ; }, }); diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index acd8263410..5a12717762 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,93 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {useEffect} 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 {aboveLeftOf, ContextMenu, ContextMenuButton, useContextMenu} from '../../structures/ContextMenu'; import { isContentActionable, canEditContent } from '../../../utils/EventUtils'; import {RoomContext} from "../../structures/RoomView"; +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 buttonRect = button.current.getBoundingClientRect(); + contextMenu = + + ; + } + + return + + + { contextMenu } + ; +}; + +const ReactButton = ({mxEvent, reactions}) => { + const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); + + let contextMenu; + if (menuDisplayed) { + const buttonRect = button.current.getBoundingClientRect(); + const ReactionPicker = sdk.getComponent('emojipicker.ReactionPicker'); + contextMenu = + + ; + } + + return + + + { contextMenu } + ; +}; + export default class MessageActionBar extends React.PureComponent { static propTypes = { mxEvent: PropTypes.object.isRequired, @@ -62,14 +139,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 +153,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 +162,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/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index dab03cd537..2680c13512 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -27,12 +27,13 @@ import sdk from '../../../index'; import Modal from '../../../Modal'; import dis from '../../../dispatcher'; import { _t } from '../../../languageHandler'; -import * as ContextualMenu from '../../structures/ContextualMenu'; +import * as ContextMenu from '../../structures/ContextMenu'; import SettingsStore from "../../../settings/SettingsStore"; import ReplyThread from "../elements/ReplyThread"; import {pillifyLinks} from '../../../utils/pillify'; import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; import {isPermalinkHost} from "../../../utils/permalinks/Permalinks"; +import {toRightOf} from "../../structures/ContextMenu"; module.exports = createReactClass({ displayName: 'TextualBody', @@ -272,18 +273,12 @@ module.exports = createReactClass({ const copyCode = button.parentNode.getElementsByTagName("code")[0]; const successful = this.copyToClipboard(copyCode.textContent); - const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu'); const buttonRect = e.target.getBoundingClientRect(); - - // The window X and Y offsets are to adjust position when zoomed in to page - const x = buttonRect.right + window.pageXOffset; - const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19; - const {close} = ContextualMenu.createMenu(GenericTextContextMenu, { - chevronOffset: 10, - left: x, - top: y, + const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu'); + const {close} = ContextMenu.createMenu(GenericTextContextMenu, { + ...toRightOf(buttonRect, 11), message: successful ? _t('Copied!') : _t('Failed to copy'), - }, false); + }); e.target.onmouseleave = close; }; diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index dc893f0049..313f013de4 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, ContextMenuButton, toRightOf} from '../../structures/ContextMenu'; 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,18 @@ 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 +381,48 @@ module.exports = createReactClass({ ariaLabel += " " + _t("Unread messages."); } - return -
      -
      - - { dmIndicator } + let contextMenu; + if (this.state.menuDisplayed && this._contextMenuButton.current) { + const elementRect = this._contextMenuButton.current.getBoundingClientRect(); + 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/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js index 7eabf27528..8a00725718 100644 --- a/src/components/views/rooms/Stickerpicker.js +++ b/src/components/views/rooms/Stickerpicker.js @@ -25,6 +25,7 @@ import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import PersistedElement from "../elements/PersistedElement"; import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; import SettingsStore from "../../../settings/SettingsStore"; +import {ContextMenu} from "../../structures/ContextMenu"; const widgetType = 'm.stickerpicker'; @@ -371,26 +372,8 @@ export default class Stickerpicker extends React.Component { } render() { - const ContextualMenu = sdk.getComponent('structures.ContextualMenu'); - const GenericElementContextMenu = sdk.getComponent('context_menus.GenericElementContextMenu'); + let stickerPicker; let stickersButton; - - const stickerPicker = ; - if (this.state.showStickers) { // Show hide-stickers button stickersButton = @@ -402,6 +385,23 @@ export default class Stickerpicker extends React.Component { title={_t("Hide Stickers")} > ; + + const GenericElementContextMenu = sdk.getComponent('context_menus.GenericElementContextMenu'); + stickerPicker = + + ; } else { // Show show-stickers button stickersButton = @@ -415,8 +415,8 @@ export default class Stickerpicker extends React.Component { ; } return - {stickersButton} - {this.state.showStickers && stickerPicker} + { stickersButton } + { stickerPicker } ; } } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 7f7e6442d7..03838de292 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -995,6 +995,7 @@ "Securely back up your keys to avoid losing them. Learn more.": "Securely back up your keys to avoid losing them. Learn more.", "Not now": "Not now", "Don't ask me again": "Don't ask me again", + "Options": "Options", "%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.", "%(count)s unread messages including mentions.|one": "1 unread mention.", "%(count)s unread messages.|other": "%(count)s unread messages.", @@ -1071,7 +1072,6 @@ "%(count)s verified Sign-In's|one": "1 verified Sign-In", "Direct message": "Direct message", "Unverify user": "Unverify user", - "Options": "Options", "Remove from community": "Remove from community", "Disinvite this user from community?": "Disinvite this user from community?", "Remove this user from community?": "Remove this user from community?", @@ -1553,6 +1553,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", @@ -1572,6 +1573,7 @@ "Take picture": "Take picture", "Remove for everyone": "Remove for everyone", "Remove for me": "Remove for me", + "User Status": "User Status", "powered by Matrix": "powered by Matrix", "This homeserver would like to make sure you are not a robot.": "This homeserver would like to make sure you are not a robot.", "Custom Server Options": "Custom Server Options",