From 58718dab37d145566c70b8f2731acd35ed798cd6 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 1 Jul 2020 23:51:12 +0100 Subject: [PATCH 01/19] Convert ContextMenu to TypeScript Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/@types/common.ts | 1 + .../{ContextMenu.js => ContextMenu.tsx} | 259 ++++++++++-------- .../views/elements/AccessibleButton.tsx | 2 +- 3 files changed, 148 insertions(+), 114 deletions(-) rename src/components/structures/{ContextMenu.js => ContextMenu.tsx} (71%) diff --git a/src/@types/common.ts b/src/@types/common.ts index 9109993541..a24d47ac9e 100644 --- a/src/@types/common.ts +++ b/src/@types/common.ts @@ -17,3 +17,4 @@ limitations under the License. // Based on https://stackoverflow.com/a/53229857/3532235 export type Without = {[P in Exclude] ? : never}; export type XOR = (T | U) extends object ? (Without & U) | (Without & T) : T | U; +export type Writeable = { -readonly [P in keyof T]: T[P] }; diff --git a/src/components/structures/ContextMenu.js b/src/components/structures/ContextMenu.tsx similarity index 71% rename from src/components/structures/ContextMenu.js rename to src/components/structures/ContextMenu.tsx index 5ba2662796..f07c12f0b3 100644 --- a/src/components/structures/ContextMenu.js +++ b/src/components/structures/ContextMenu.tsx @@ -1,28 +1,28 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. + * + * Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> + * + * 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. + * / + */ -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 +import React, {CSSProperties, useRef, useState} from "react"; +import ReactDOM from "react-dom"; +import classNames from "classnames"; - 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 * as sdk from "../../index"; -import AccessibleButton from "../views/elements/AccessibleButton"; +import AccessibleButton, { IAccessibleButtonProps } from "../views/elements/AccessibleButton"; +import {Writeable} from "../../@types/common"; // Shamelessly ripped off Modal.js. There's probably a better way // of doing reusable widgets like dialog boxes & menus where we go and @@ -30,8 +30,8 @@ import AccessibleButton from "../views/elements/AccessibleButton"; const ContextualMenuContainerId = "mx_ContextualMenu_Container"; -function getOrCreateContainer() { - let container = document.getElementById(ContextualMenuContainerId); +function getOrCreateContainer(): HTMLDivElement { + let container = document.getElementById(ContextualMenuContainerId) as HTMLDivElement; if (!container) { container = document.createElement("div"); @@ -43,50 +43,70 @@ function getOrCreateContainer() { } const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]); + +interface IPosition { + top?: number; + bottom?: number; + left?: number; + right?: number; +} + +export enum ChevronFace { + Top = "top", + Bottom = "bottom", + Left = "left", + Right = "right", + None = "none", +} + +interface IProps extends IPosition { + menuWidth?: number; + menuHeight?: number; + + chevronOffset?: number; + chevronFace?: ChevronFace; + + menuPaddingTop?: number; + menuPaddingBottom?: number; + menuPaddingLeft?: number; + menuPaddingRight?: number; + + zIndex?: number; + + // If true, insert an invisible screen-sized element behind the menu that when clicked will close it. + hasBackground?: boolean; + // whether this context menu should be focus managed. If false it must handle itself + managed?: boolean; + + // Function to be called on menu close + onFinished(); + // on resize callback + windowResize(); +} + +interface IState { + contextMenuElem: HTMLDivElement; +} + // 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 { - static 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, - - managed: PropTypes.bool, // whether this context menu should be focus managed. If false it must handle itself - }; +export class ContextMenu extends React.PureComponent { + private initialFocus: HTMLElement; static defaultProps = { hasBackground: true, managed: true, }; - constructor() { - super(); + constructor(props, context) { + super(props, context); this.state = { contextMenuElem: null, }; // persist what had focus when we got initialized so we can return it after - this.initialFocus = document.activeElement; + this.initialFocus = document.activeElement as HTMLElement; } componentWillUnmount() { @@ -232,9 +252,8 @@ export class ContextMenu extends React.Component { } }; - renderMenu(hasBackground=this.props.hasBackground) { - const position = {}; - let chevronFace = null; + renderMenu(hasBackground = this.props.hasBackground) { + const position: Partial> = {}; const props = this.props; if (props.top) { @@ -243,23 +262,24 @@ export class ContextMenu extends React.Component { position.bottom = props.bottom; } + let chevronFace: IProps["chevronFace"]; if (props.left) { position.left = props.left; - chevronFace = 'left'; + chevronFace = ChevronFace.Left; } else { position.right = props.right; - chevronFace = 'right'; + chevronFace = ChevronFace.Right; } const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null; - const chevronOffset = {}; + const chevronOffset: CSSProperties = {}; if (props.chevronFace) { chevronFace = props.chevronFace; } - const hasChevron = chevronFace && chevronFace !== "none"; + const hasChevron = chevronFace && chevronFace !== ChevronFace.None; - if (chevronFace === 'top' || chevronFace === 'bottom') { + if (chevronFace === ChevronFace.Top || chevronFace === ChevronFace.Bottom) { chevronOffset.left = props.chevronOffset; } else if (position.top !== undefined) { const target = position.top; @@ -289,13 +309,13 @@ export class ContextMenu extends React.Component { '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', + 'mx_ContextualMenu_withChevron_left': chevronFace === ChevronFace.Left, + 'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right, + 'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top, + 'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom, }); - const menuStyle = {}; + const menuStyle: CSSProperties = {}; if (props.menuWidth) { menuStyle.width = props.menuWidth; } @@ -326,13 +346,28 @@ export class ContextMenu extends React.Component { let background; if (hasBackground) { background = ( -
+
); } return ( -
-
+
+
{ chevron } { props.children }
@@ -341,14 +376,19 @@ export class ContextMenu extends React.Component { ); } - render() { + render(): React.ReactChild { return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer()); } } +interface IContextMenuButtonProps extends IAccessibleButtonProps { + label?: string; + // whether or not the context menu is currently open + isExpanded: boolean; +} + // Semantic component for representing the AccessibleButton which launches a -export const ContextMenuButton = ({ label, isExpanded, children, onClick, onContextMenu, ...props }) => { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); +export const ContextMenuButton: React.FC = ({ label, isExpanded, children, onClick, onContextMenu, ...props }) => { return ( ); }; -ContextMenuButton.propTypes = { - ...AccessibleButton.propTypes, - label: PropTypes.string, - isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open -}; + +interface IMenuItemProps extends IAccessibleButtonProps { + label?: string; + className?: string; + onClick(); +} // Semantic component for representing a role=menuitem -export const MenuItem = ({children, label, ...props}) => { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); +export const MenuItem: React.FC = ({children, label, ...props}) => { return ( { children } ); }; -MenuItem.propTypes = { - ...AccessibleButton.propTypes, - label: PropTypes.string, // optional - className: PropTypes.string, // optional - onClick: PropTypes.func.isRequired, -}; + +interface IMenuGroupProps extends React.HTMLAttributes { + label: string; + className?: string; +} // Semantic component for representing a role=group for grouping menu radios/checkboxes -export const MenuGroup = ({children, label, ...props}) => { +export const MenuGroup: React.FC = ({children, label, ...props}) => { return
{ children }
; }; -MenuGroup.propTypes = { - label: PropTypes.string.isRequired, - className: PropTypes.string, // optional -}; + +interface IMenuItemCheckboxProps extends IAccessibleButtonProps { + label?: string; + active: boolean; + disabled?: boolean; + className?: string; + onClick(); +} // Semantic component for representing a role=menuitemcheckbox -export const MenuItemCheckbox = ({children, label, active=false, disabled=false, ...props}) => { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); +export const MenuItemCheckbox: React.FC = ({children, label, active = false, disabled = false, ...props}) => { 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, -}; + +interface IMenuItemRadioProps extends IAccessibleButtonProps { + label?: string; + active: boolean; + disabled?: boolean; + className?: string; + onClick(); +} // Semantic component for representing a role=menuitemradio -export const MenuItemRadio = ({children, label, active=false, disabled=false, ...props}) => { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); +export const MenuItemRadio: React.FC = ({children, label, active = false, disabled = false, ...props}) => { 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) => { +export const toRightOf = (elementRect: DOMRect, 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 @@ -441,8 +474,8 @@ export const toRightOf = (elementRect, chevronOffset=12) => { }; // 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 }; +export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None) => { + const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; const buttonRight = elementRect.right + window.pageXOffset; const buttonBottom = elementRect.bottom + window.pageYOffset; diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index 01a27d9522..489f11699a 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -42,7 +42,7 @@ interface IProps extends React.InputHTMLAttributes { onClick?(e?: ButtonEvent): void; } -interface IAccessibleButtonProps extends React.InputHTMLAttributes { +export interface IAccessibleButtonProps extends React.InputHTMLAttributes { ref?: React.Ref; } From 6802f9b4dfc237a61e465e5b445fe28604331a45 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 1 Jul 2020 23:52:49 +0100 Subject: [PATCH 02/19] unbreak copyright Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/structures/ContextMenu.tsx | 32 +++++++++++------------ 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index f07c12f0b3..3e38a18d98 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -1,20 +1,20 @@ /* - * - * Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> - * - * 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. - * / - */ +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, {CSSProperties, useRef, useState} from "react"; import ReactDOM from "react-dom"; From 07e0a017e7885692e308b02a63c947d147c68cc2 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 1 Jul 2020 23:56:57 +0100 Subject: [PATCH 03/19] fix types Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/structures/ContextMenu.tsx | 10 ++++----- src/components/structures/UserMenu.tsx | 22 ++++++++++---------- src/components/views/rooms/RoomSublist2.tsx | 23 ++++++++++----------- src/components/views/rooms/RoomTile2.tsx | 8 +++---- 4 files changed, 31 insertions(+), 32 deletions(-) diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 3e38a18d98..be41ec1569 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -21,7 +21,7 @@ import ReactDOM from "react-dom"; import classNames from "classnames"; import {Key} from "../../Keyboard"; -import AccessibleButton, { IAccessibleButtonProps } from "../views/elements/AccessibleButton"; +import AccessibleButton, { IAccessibleButtonProps, ButtonEvent } from "../views/elements/AccessibleButton"; import {Writeable} from "../../@types/common"; // Shamelessly ripped off Modal.js. There's probably a better way @@ -81,7 +81,7 @@ interface IProps extends IPosition { // Function to be called on menu close onFinished(); // on resize callback - windowResize(); + windowResize?(); } interface IState { @@ -407,7 +407,7 @@ export const ContextMenuButton: React.FC = ({ label, is interface IMenuItemProps extends IAccessibleButtonProps { label?: string; className?: string; - onClick(); + onClick(ev: ButtonEvent); } // Semantic component for representing a role=menuitem @@ -436,7 +436,7 @@ interface IMenuItemCheckboxProps extends IAccessibleButtonProps { active: boolean; disabled?: boolean; className?: string; - onClick(); + onClick(ev: ButtonEvent); } // Semantic component for representing a role=menuitemcheckbox @@ -453,7 +453,7 @@ interface IMenuItemRadioProps extends IAccessibleButtonProps { active: boolean; disabled?: boolean; className?: string; - onClick(); + onClick(ev: ButtonEvent); } // Semantic component for representing a role=menuitemradio diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 1cfe244845..aba0a3f053 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -15,15 +15,15 @@ limitations under the License. */ import * as React from "react"; -import { MatrixClientPeg } from "../../MatrixClientPeg"; +import {createRef} from "react"; +import {MatrixClientPeg} from "../../MatrixClientPeg"; import defaultDispatcher from "../../dispatcher/dispatcher"; -import { ActionPayload } from "../../dispatcher/payloads"; -import { Action } from "../../dispatcher/actions"; -import { createRef } from "react"; -import { _t } from "../../languageHandler"; -import {ContextMenu, ContextMenuButton} from "./ContextMenu"; +import {ActionPayload} from "../../dispatcher/payloads"; +import {Action} from "../../dispatcher/actions"; +import {_t} from "../../languageHandler"; +import {ChevronFace, ContextMenu, ContextMenuButton} from "./ContextMenu"; import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog"; -import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; +import {OpenToTabPayload} from "../../dispatcher/payloads/OpenToTabPayload"; import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog"; import Modal from "../../Modal"; import LogoutDialog from "../views/dialogs/LogoutDialog"; @@ -33,8 +33,8 @@ import {getHostingLink} from "../../utils/HostingLink"; import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton"; import SdkConfig from "../../SdkConfig"; import {getHomePageUrl} from "../../utils/pages"; -import { OwnProfileStore } from "../../stores/OwnProfileStore"; -import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import {OwnProfileStore} from "../../stores/OwnProfileStore"; +import {UPDATE_EVENT} from "../../stores/AsyncStore"; import BaseAvatar from '../views/avatars/BaseAvatar'; import classNames from "classnames"; @@ -105,7 +105,7 @@ export default class UserMenu extends React.Component { if (this.buttonRef.current) this.buttonRef.current.click(); }; - private onOpenMenuClick = (ev: InputEvent) => { + private onOpenMenuClick = (ev: React.MouseEvent) => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; @@ -214,7 +214,7 @@ export default class UserMenu extends React.Component { return ( { this.forceUpdate(); // because the layout doesn't trigger a re-render }; - private onOpenMenuClick = (ev: InputEvent) => { + private onOpenMenuClick = (ev: React.MouseEvent) => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; @@ -219,7 +218,7 @@ export default class RoomSublist2 extends React.Component { const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; contextMenu = ( { // align the context menu's icons with the icon which opened the context menu const left = elementRect.left + window.pageXOffset - 9; const top = elementRect.bottom + window.pageYOffset + 17; - const chevronFace = "none"; + const chevronFace = ChevronFace.None; return {left, top, chevronFace}; }; @@ -151,7 +151,7 @@ export default class RoomTile2 extends React.Component { this.setState({selected: isActive}); }; - private onNotificationsMenuOpenClick = (ev: InputEvent) => { + private onNotificationsMenuOpenClick = (ev: React.MouseEvent) => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; @@ -162,7 +162,7 @@ export default class RoomTile2 extends React.Component { this.setState({notificationsMenuPosition: null}); }; - private onGeneralMenuOpenClick = (ev: InputEvent) => { + private onGeneralMenuOpenClick = (ev: React.MouseEvent) => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; From 9fcc2ced3d3ed7af84169d2a80c61733033aac22 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 1 Jul 2020 23:59:06 +0100 Subject: [PATCH 04/19] fix types some more Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/structures/ContextMenu.tsx | 2 +- src/components/views/elements/AccessibleButton.tsx | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index be41ec1569..cec924c022 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -21,7 +21,7 @@ import ReactDOM from "react-dom"; import classNames from "classnames"; import {Key} from "../../Keyboard"; -import AccessibleButton, { IAccessibleButtonProps, ButtonEvent } from "../views/elements/AccessibleButton"; +import AccessibleButton, { IProps as IAccessibleButtonProps, ButtonEvent } from "../views/elements/AccessibleButton"; import {Writeable} from "../../@types/common"; // Shamelessly ripped off Modal.js. There's probably a better way diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index 489f11699a..34481601f7 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -27,7 +27,7 @@ export type ButtonEvent = React.MouseEvent | React.KeyboardEvent { +export interface IProps extends React.InputHTMLAttributes { inputRef?: React.Ref; element?: string; // The kind of button, similar to how Bootstrap works. @@ -42,7 +42,7 @@ interface IProps extends React.InputHTMLAttributes { onClick?(e?: ButtonEvent): void; } -export interface IAccessibleButtonProps extends React.InputHTMLAttributes { +interface IAccessibleButtonProps extends React.InputHTMLAttributes { ref?: React.Ref; } @@ -64,7 +64,6 @@ export default function AccessibleButton({ className, ...restProps }: IProps) { - const newProps: IAccessibleButtonProps = restProps; if (!disabled) { newProps.onClick = onClick; From 0854924b8dd3b84850649cf5c257293e4330ef60 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 2 Jul 2020 23:51:02 +0100 Subject: [PATCH 05/19] iterate some more Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/structures/ContextMenu.tsx | 34 +++++++++++------------ 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index c76bbe6133..e45d846bd0 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -114,7 +114,7 @@ export class ContextMenu extends React.PureComponent { this.initialFocus.focus(); } - collectContextMenuRect = (element) => { + private collectContextMenuRect = (element) => { // We don't need to clean up when unmounting, so ignore if (!element) return; @@ -131,7 +131,7 @@ export class ContextMenu extends React.PureComponent { }); }; - onContextMenu = (e) => { + private onContextMenu = (e) => { if (this.props.onFinished) { this.props.onFinished(); @@ -154,20 +154,20 @@ export class ContextMenu extends React.PureComponent { } }; - onContextMenuPreventBubbling = (e) => { + private onContextMenuPreventBubbling = (e) => { // stop propagation so that any context menu handlers don't leak out of this context menu // but do not inhibit the default browser menu e.stopPropagation(); }; // Prevent clicks on the background from going through to the component which opened the menu. - _onFinished = (ev: InputEvent) => { + private onFinished = (ev: React.MouseEvent) => { ev.stopPropagation(); ev.preventDefault(); if (this.props.onFinished) this.props.onFinished(); }; - _onMoveFocus = (element, up) => { + private onMoveFocus = (element: Element, up: boolean) => { let descending = false; // are we currently descending or ascending through the DOM tree? do { @@ -201,25 +201,25 @@ export class ContextMenu extends React.PureComponent { } while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role"))); if (element) { - element.focus(); + (element as HTMLElement).focus(); } }; - _onMoveFocusHomeEnd = (element, up) => { + private onMoveFocusHomeEnd = (element: Element, up: boolean) => { let results = element.querySelectorAll('[role^="menuitem"]'); if (!results) { results = element.querySelectorAll('[tab-index]'); } if (results && results.length) { if (up) { - results[0].focus(); + (results[0] as HTMLElement).focus(); } else { - results[results.length - 1].focus(); + (results[results.length - 1] as HTMLElement).focus(); } } }; - _onKeyDown = (ev) => { + private onKeyDown = (ev: React.KeyboardEvent) => { if (!this.props.managed) { if (ev.key === Key.ESCAPE) { this.props.onFinished(); @@ -237,16 +237,16 @@ export class ContextMenu extends React.PureComponent { this.props.onFinished(); break; case Key.ARROW_UP: - this._onMoveFocus(ev.target, true); + this.onMoveFocus(ev.target as Element, true); break; case Key.ARROW_DOWN: - this._onMoveFocus(ev.target, false); + this.onMoveFocus(ev.target as Element, false); break; case Key.HOME: - this._onMoveFocusHomeEnd(this.state.contextMenuElem, true); + this.onMoveFocusHomeEnd(this.state.contextMenuElem, true); break; case Key.END: - this._onMoveFocusHomeEnd(this.state.contextMenuElem, false); + this.onMoveFocusHomeEnd(this.state.contextMenuElem, false); break; default: handled = false; @@ -259,7 +259,7 @@ export class ContextMenu extends React.PureComponent { } }; - renderMenu(hasBackground = this.props.hasBackground) { + protected renderMenu(hasBackground = this.props.hasBackground) { const position: Partial> = {}; const props = this.props; @@ -356,7 +356,7 @@ export class ContextMenu extends React.PureComponent {
); @@ -366,7 +366,7 @@ export class ContextMenu extends React.PureComponent {
Date: Tue, 7 Jul 2020 00:11:32 +0100 Subject: [PATCH 06/19] Apply scroll margins to RoomTile so that they don't scroll under the "sticky" headers Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- res/css/views/rooms/_RoomTile2.scss | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/res/css/views/rooms/_RoomTile2.scss b/res/css/views/rooms/_RoomTile2.scss index 7b606ab947..23cecff477 100644 --- a/res/css/views/rooms/_RoomTile2.scss +++ b/res/css/views/rooms/_RoomTile2.scss @@ -21,6 +21,10 @@ limitations under the License. margin-bottom: 4px; padding: 4px; + // allow scrollIntoView to ignore the sticky headers, must match combined height of .mx_RoomSublist2_headerContainer + scroll-margin-top: 32px; + scroll-margin-bottom: 32px; + // The tile is also a flexbox row itself display: flex; @@ -164,6 +168,11 @@ limitations under the License. } } +// do not apply scroll-margin-bottom to the sublist which will not have a sticky header below it +.mx_RoomSublist2:last-child .mx_RoomTile2 { + scroll-margin-bottom: 0; +} + // We use these both in context menus and the room tiles .mx_RoomTile2_iconBell::before { mask-image: url('$(res)/img/feather-customised/bell.svg'); From 63ec793fadda6bfa09d5fa019850d35102d79b79 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 7 Jul 2020 10:34:42 +0100 Subject: [PATCH 07/19] Support view_room's show_room_tile in the new room list Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/views/rooms/RoomSublist2.tsx | 25 +++++++++++++++ src/components/views/rooms/RoomTile2.tsx | 35 +++++++++++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index 9a36ea00e9..5dce3f769f 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -40,6 +40,8 @@ import NotificationBadge from "./NotificationBadge"; import { ListNotificationState } from "../../../stores/notifications/ListNotificationState"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { Key } from "../../../Keyboard"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import {ActionPayload} from "../../../dispatcher/payloads"; // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 @@ -88,6 +90,7 @@ interface IState { export default class RoomSublist2 extends React.Component { private headerButton = createRef(); private sublistRef = createRef(); + private dispatcherRef: string; constructor(props: IProps) { super(props); @@ -98,6 +101,7 @@ export default class RoomSublist2 extends React.Component { isResizing: false, }; this.state.notificationState.setRooms(this.props.rooms); + this.dispatcherRef = defaultDispatcher.register(this.onAction); } private get numTiles(): number { @@ -116,8 +120,29 @@ export default class RoomSublist2 extends React.Component { public componentWillUnmount() { this.state.notificationState.destroy(); + defaultDispatcher.unregister(this.dispatcherRef); } + private onAction = (payload: ActionPayload) => { + if (payload.action === "view_room" && payload.show_room_tile && this.props.rooms) { + // XXX: we have to do this a tick later because we have incorrect intermediate props during a room change + // where we lose the room we are changing from temporarily and then it comes back in an update right after. + setImmediate(() => { + const isCollapsed = this.props.layout.isCollapsed; + const roomIndex = this.props.rooms.findIndex((r) => r.roomId === payload.room_id); + + if (isCollapsed && roomIndex > -1) { + this.toggleCollapsed(); + } + // extend the visible section to include the room + if (roomIndex >= this.numVisibleTiles) { + this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(roomIndex + 1, MAX_PADDING_HEIGHT); + this.forceUpdate(); // because the layout doesn't trigger a re-render + } + }); + } + }; + private onAddRoom = (e) => { e.stopPropagation(); if (this.props.onAddRoom) this.props.onAddRoom(); diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx index 8ee838fbba..21901026a5 100644 --- a/src/components/views/rooms/RoomTile2.tsx +++ b/src/components/views/rooms/RoomTile2.tsx @@ -17,7 +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 { Room } from "matrix-js-sdk/src/models/room"; import classNames from "classnames"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; @@ -49,6 +49,8 @@ import { TagSpecificNotificationState } from "../../../stores/notifications/TagS import { INotificationState } from "../../../stores/notifications/INotificationState"; import NotificationBadge from "./NotificationBadge"; import { NotificationColor } from "../../../stores/notifications/NotificationColor"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import {ActionPayload} from "../../../dispatcher/payloads"; // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 @@ -119,6 +121,8 @@ const NotifOption: React.FC = ({active, onClick, iconClassNam }; export default class RoomTile2 extends React.Component { + private dispatcherRef: string; + private roomTileRef = createRef(); // TODO: a11y: https://github.com/vector-im/riot-web/issues/14180 constructor(props: IProps) { @@ -133,6 +137,7 @@ export default class RoomTile2 extends React.Component { }; ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate); + this.dispatcherRef = defaultDispatcher.register(this.onAction); } private get showContextMenu(): boolean { @@ -143,12 +148,37 @@ export default class RoomTile2 extends React.Component { return !this.props.isMinimized && this.props.showMessagePreview; } + public componentDidMount() { + // when we're first rendered (or our sublist is expanded) make sure we are visible if we're active + if (this.state.selected) { + this.scrollIntoView(); + } + } + public componentWillUnmount() { if (this.props.room) { ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate); } + defaultDispatcher.unregister(this.dispatcherRef); } + private onAction = (payload: ActionPayload) => { + if (payload.action === "view_room" && payload.room_id === this.props.room.roomId && payload.show_room_tile) { + setImmediate(() => { + this.scrollIntoView(); + }); + } + }; + + private scrollIntoView = () => { + console.log("DEBUG scrollIntoView", this.roomTileRef.current); + if (!this.roomTileRef.current) return; + this.roomTileRef.current.scrollIntoView({ + block: "nearest", + behavior: "auto", + }); + }; + private onTileMouseEnter = () => { this.setState({hover: true}); }; @@ -162,7 +192,6 @@ export default class RoomTile2 extends React.Component { ev.stopPropagation(); dis.dispatch({ action: 'view_room', - // TODO: Support show_room_tile in new room list: https://github.com/vector-im/riot-web/issues/14233 show_room_tile: true, // make sure the room is visible in the list room_id: this.props.room.roomId, clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)), @@ -481,7 +510,7 @@ export default class RoomTile2 extends React.Component { return ( - + {({onFocus, isActive, ref}) => Date: Tue, 7 Jul 2020 10:35:16 +0100 Subject: [PATCH 08/19] update comment Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/views/rooms/RoomSublist2.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index 5dce3f769f..4dc2097421 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -134,7 +134,7 @@ export default class RoomSublist2 extends React.Component { if (isCollapsed && roomIndex > -1) { this.toggleCollapsed(); } - // extend the visible section to include the room + // extend the visible section to include the room if it is entirely invisible if (roomIndex >= this.numVisibleTiles) { this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(roomIndex + 1, MAX_PADDING_HEIGHT); this.forceUpdate(); // because the layout doesn't trigger a re-render From 8c2286a0447de504d6cb717271df18de1c2e39a6 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 7 Jul 2020 15:24:46 +0100 Subject: [PATCH 09/19] Move all the ContextMenu semantic helper (ARIA) components out to separate modules Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../context_menu/ContextMenuButton.tsx | 51 +++++ src/accessibility/context_menu/MenuGroup.tsx | 31 +++ src/accessibility/context_menu/MenuItem.tsx | 36 ++++ .../context_menu/MenuItemCheckbox.tsx | 45 ++++ .../context_menu/MenuItemRadio.tsx | 45 ++++ .../context_menu/StyledMenuItemCheckbox.tsx | 64 ++++++ .../context_menu/StyledMenuItemRadio.tsx | 65 ++++++ src/components/structures/ContextMenu.tsx | 193 +----------------- 8 files changed, 346 insertions(+), 184 deletions(-) create mode 100644 src/accessibility/context_menu/ContextMenuButton.tsx create mode 100644 src/accessibility/context_menu/MenuGroup.tsx create mode 100644 src/accessibility/context_menu/MenuItem.tsx create mode 100644 src/accessibility/context_menu/MenuItemCheckbox.tsx create mode 100644 src/accessibility/context_menu/MenuItemRadio.tsx create mode 100644 src/accessibility/context_menu/StyledMenuItemCheckbox.tsx create mode 100644 src/accessibility/context_menu/StyledMenuItemRadio.tsx diff --git a/src/accessibility/context_menu/ContextMenuButton.tsx b/src/accessibility/context_menu/ContextMenuButton.tsx new file mode 100644 index 0000000000..c358155e10 --- /dev/null +++ b/src/accessibility/context_menu/ContextMenuButton.tsx @@ -0,0 +1,51 @@ +/* +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 AccessibleButton, {IProps as IAccessibleButtonProps} from "../../components/views/elements/AccessibleButton"; + +interface IProps extends IAccessibleButtonProps { + label?: string; + // whether or not the context menu is currently open + isExpanded: boolean; +} + +// Semantic component for representing the AccessibleButton which launches a +export const ContextMenuButton: React.FC = ({ + label, + isExpanded, + children, + onClick, + onContextMenu, + ...props +}) => { + return ( + + { children } + + ); +}; diff --git a/src/accessibility/context_menu/MenuGroup.tsx b/src/accessibility/context_menu/MenuGroup.tsx new file mode 100644 index 0000000000..f4b7b6bc56 --- /dev/null +++ b/src/accessibility/context_menu/MenuGroup.tsx @@ -0,0 +1,31 @@ +/* +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"; + +interface IProps extends React.HTMLAttributes { + label: string; + className?: string; +} + +// Semantic component for representing a role=group for grouping menu radios/checkboxes +export const MenuGroup: React.FC = ({children, label, ...props}) => { + return
+ { children } +
; +}; diff --git a/src/accessibility/context_menu/MenuItem.tsx b/src/accessibility/context_menu/MenuItem.tsx new file mode 100644 index 0000000000..8e33d55de4 --- /dev/null +++ b/src/accessibility/context_menu/MenuItem.tsx @@ -0,0 +1,36 @@ +/* +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 AccessibleButton, {ButtonEvent, IProps as IAccessibleButtonProps} from "../../components/views/elements/AccessibleButton"; + +interface IProps extends IAccessibleButtonProps { + label?: string; + className?: string; + onClick(ev: ButtonEvent); +} + +// Semantic component for representing a role=menuitem +export const MenuItem: React.FC = ({children, label, ...props}) => { + return ( + + { children } + + ); +}; diff --git a/src/accessibility/context_menu/MenuItemCheckbox.tsx b/src/accessibility/context_menu/MenuItemCheckbox.tsx new file mode 100644 index 0000000000..e2cc04b5a6 --- /dev/null +++ b/src/accessibility/context_menu/MenuItemCheckbox.tsx @@ -0,0 +1,45 @@ +/* +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 AccessibleButton, {ButtonEvent, IProps as IAccessibleButtonProps} from "../../components/views/elements/AccessibleButton"; + +interface IProps extends IAccessibleButtonProps { + label?: string; + active: boolean; + disabled?: boolean; + className?: string; + onClick(ev: ButtonEvent); +} + +// Semantic component for representing a role=menuitemcheckbox +export const MenuItemCheckbox: React.FC = ({children, label, active, disabled, ...props}) => { + return ( + + { children } + + ); +}; diff --git a/src/accessibility/context_menu/MenuItemRadio.tsx b/src/accessibility/context_menu/MenuItemRadio.tsx new file mode 100644 index 0000000000..21732220df --- /dev/null +++ b/src/accessibility/context_menu/MenuItemRadio.tsx @@ -0,0 +1,45 @@ +/* +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 AccessibleButton, {ButtonEvent, IProps as IAccessibleButtonProps} from "../../components/views/elements/AccessibleButton"; + +interface IProps extends IAccessibleButtonProps { + label?: string; + active: boolean; + disabled?: boolean; + className?: string; + onClick(ev: ButtonEvent); +} + +// Semantic component for representing a role=menuitemradio +export const MenuItemRadio: React.FC = ({children, label, active, disabled, ...props}) => { + return ( + + { children } + + ); +}; diff --git a/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx b/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx new file mode 100644 index 0000000000..f5a510f517 --- /dev/null +++ b/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx @@ -0,0 +1,64 @@ +/* +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 {Key} from "../../Keyboard"; +import StyledCheckbox from "../../components/views/elements/StyledCheckbox"; + +interface IProps extends React.ComponentProps { + label?: string; + onChange(); + onClose(): void; // gets called after onChange on Key.ENTER +} + +// Semantic component for representing a styled role=menuitemcheckbox +export const StyledMenuItemCheckbox: React.FC = ({children, label, onChange, onClose, ...props}) => { + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === Key.ENTER || e.key === Key.SPACE) { + e.stopPropagation(); + e.preventDefault(); + onChange(); + // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 + if (e.key === Key.ENTER) { + onClose(); + } + } + }; + const onKeyUp = (e: React.KeyboardEvent) => { + // prevent the input default handler as we handle it on keydown to match + // https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html + if (e.key === Key.SPACE || e.key === Key.ENTER) { + e.stopPropagation(); + e.preventDefault(); + } + }; + return ( + + { children } + + ); +}; diff --git a/src/accessibility/context_menu/StyledMenuItemRadio.tsx b/src/accessibility/context_menu/StyledMenuItemRadio.tsx new file mode 100644 index 0000000000..be87ccc683 --- /dev/null +++ b/src/accessibility/context_menu/StyledMenuItemRadio.tsx @@ -0,0 +1,65 @@ +/* +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 {Key} from "../../Keyboard"; +import StyledRadioButton from "../../components/views/elements/StyledRadioButton"; + +interface IProps extends React.ComponentProps { + label?: string; + disabled?: boolean; + onChange(): void; + onClose(): void; // gets called after onChange on Key.ENTER +} + +// Semantic component for representing a styled role=menuitemradio +export const StyledMenuItemRadio: React.FC = ({children, label, onChange, onClose, ...props}) => { + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === Key.ENTER || e.key === Key.SPACE) { + e.stopPropagation(); + e.preventDefault(); + onChange(); + // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 + if (e.key === Key.ENTER) { + onClose(); + } + } + }; + const onKeyUp = (e: React.KeyboardEvent) => { + // prevent the input default handler as we handle it on keydown to match + // https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html + if (e.key === Key.SPACE || e.key === Key.ENTER) { + e.stopPropagation(); + e.preventDefault(); + } + }; + return ( + + { children } + + ); +}; diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 872a8b0cd9..cb1349da4b 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -21,10 +21,7 @@ import ReactDOM from "react-dom"; import classNames from "classnames"; import {Key} from "../../Keyboard"; -import AccessibleButton, { IProps as IAccessibleButtonProps, ButtonEvent } from "../views/elements/AccessibleButton"; import {Writeable} from "../../@types/common"; -import StyledCheckbox from "../views/elements/StyledCheckbox"; -import StyledRadioButton from "../views/elements/StyledRadioButton"; // Shamelessly ripped off Modal.js. There's probably a better way // of doing reusable widgets like dialog boxes & menus where we go and @@ -390,187 +387,6 @@ export class ContextMenu extends React.PureComponent { } } -interface IContextMenuButtonProps extends IAccessibleButtonProps { - label?: string; - // whether or not the context menu is currently open - isExpanded: boolean; -} - -// Semantic component for representing the AccessibleButton which launches a -export const ContextMenuButton: React.FC = ({ label, isExpanded, children, onClick, onContextMenu, ...props }) => { - return ( - - { children } - - ); -}; - -interface IMenuItemProps extends IAccessibleButtonProps { - label?: string; - className?: string; - onClick(ev: ButtonEvent); -} - -// Semantic component for representing a role=menuitem -export const MenuItem: React.FC = ({children, label, ...props}) => { - return ( - - { children } - - ); -}; - -interface IMenuGroupProps extends React.HTMLAttributes { - label: string; - className?: string; -} - -// Semantic component for representing a role=group for grouping menu radios/checkboxes -export const MenuGroup: React.FC = ({children, label, ...props}) => { - return
- { children } -
; -}; - -interface IMenuItemCheckboxProps extends IAccessibleButtonProps { - label?: string; - active: boolean; - disabled?: boolean; - className?: string; - onClick(ev: ButtonEvent); -} - -// Semantic component for representing a role=menuitemcheckbox -export const MenuItemCheckbox: React.FC = ({children, label, active = false, disabled = false, ...props}) => { - return ( - - { children } - - ); -}; - -interface IStyledMenuItemCheckboxProps extends IAccessibleButtonProps { - label?: string; - active: boolean; - disabled?: boolean; - className?: string; - onChange(); - onClose(): void; // gets called after onChange on Key.ENTER -} - -// Semantic component for representing a styled role=menuitemcheckbox -export const StyledMenuItemCheckbox: React.FC = ({children, label, onChange, onClose, checked, disabled=false, ...props}) => { - const onKeyDown = (e) => { - if (e.key === Key.ENTER || e.key === Key.SPACE) { - e.stopPropagation(); - e.preventDefault(); - onChange(); - // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 - if (e.key === Key.ENTER) { - onClose(); - } - } - }; - const onKeyUp = (e) => { - // prevent the input default handler as we handle it on keydown to match - // https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html - if (e.key === Key.SPACE || e.key === Key.ENTER) { - e.stopPropagation(); - e.preventDefault(); - } - }; - return ( - - { children } - - ); -}; - -interface IMenuItemRadioProps extends IAccessibleButtonProps { - label?: string; - active: boolean; - disabled?: boolean; - className?: string; - onClick(ev: ButtonEvent); -} - -// Semantic component for representing a role=menuitemradio -export const MenuItemRadio: React.FC = ({children, label, active = false, disabled = false, ...props}) => { - return ( - - { children } - - ); -}; - - -interface IStyledMenuItemRadioProps extends IAccessibleButtonProps { - label?: string; - active: boolean; - disabled?: boolean; - className?: string; - onChange(); - onClose(): void; // gets called after onChange on Key.ENTER -} - -// Semantic component for representing a styled role=menuitemradio -export const StyledMenuItemRadio: React.FC = ({children, label, onChange, onClose, checked=false, disabled=false, ...props}) => { - const onKeyDown = (e) => { - if (e.key === Key.ENTER || e.key === Key.SPACE) { - e.stopPropagation(); - e.preventDefault(); - onChange(); - // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 - if (e.key === Key.ENTER) { - onClose(); - } - } - }; - const onKeyUp = (e) => { - // prevent the input default handler as we handle it on keydown to match - // https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html - if (e.key === Key.SPACE || e.key === Key.ENTER) { - e.stopPropagation(); - e.preventDefault(); - } - }; - return ( - - { children } - - ); -}; - // Placement method for to position context menu to right of elementRect with chevronOffset export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => { const left = elementRect.right + window.pageXOffset + 3; @@ -639,3 +455,12 @@ export function createMenu(ElementClass, props) { return {close: onFinished}; } + +// re-export the semantic helper components for simplicity +export {ContextMenuButton} from "../../accessibility/context_menu/ContextMenuButton"; +export {MenuGroup} from "../../accessibility/context_menu/MenuGroup"; +export {MenuItem} from "../../accessibility/context_menu/MenuItem"; +export {MenuItemCheckbox} from "../../accessibility/context_menu/MenuItemCheckbox"; +export {MenuItemRadio} from "../../accessibility/context_menu/MenuItemRadio"; +export {StyledMenuItemCheckbox} from "../../accessibility/context_menu/StyledMenuItemCheckbox"; +export {StyledMenuItemRadio} from "../../accessibility/context_menu/StyledMenuItemRadio"; From eb05c86e506c7379205e752dee0969204cd117ea Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 7 Jul 2020 15:32:20 +0100 Subject: [PATCH 10/19] clean-up Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/accessibility/context_menu/MenuGroup.tsx | 1 - src/accessibility/context_menu/MenuItem.tsx | 7 +++---- src/accessibility/context_menu/MenuItemCheckbox.tsx | 8 +++----- src/accessibility/context_menu/MenuItemRadio.tsx | 8 +++----- src/accessibility/context_menu/StyledMenuItemCheckbox.tsx | 2 +- src/accessibility/context_menu/StyledMenuItemRadio.tsx | 3 +-- 6 files changed, 11 insertions(+), 18 deletions(-) diff --git a/src/accessibility/context_menu/MenuGroup.tsx b/src/accessibility/context_menu/MenuGroup.tsx index f4b7b6bc56..9334e17a18 100644 --- a/src/accessibility/context_menu/MenuGroup.tsx +++ b/src/accessibility/context_menu/MenuGroup.tsx @@ -20,7 +20,6 @@ import React from "react"; interface IProps extends React.HTMLAttributes { label: string; - className?: string; } // Semantic component for representing a role=group for grouping menu radios/checkboxes diff --git a/src/accessibility/context_menu/MenuItem.tsx b/src/accessibility/context_menu/MenuItem.tsx index 8e33d55de4..64233e51ad 100644 --- a/src/accessibility/context_menu/MenuItem.tsx +++ b/src/accessibility/context_menu/MenuItem.tsx @@ -18,12 +18,10 @@ limitations under the License. import React from "react"; -import AccessibleButton, {ButtonEvent, IProps as IAccessibleButtonProps} from "../../components/views/elements/AccessibleButton"; +import AccessibleButton from "../../components/views/elements/AccessibleButton"; -interface IProps extends IAccessibleButtonProps { +interface IProps extends React.ComponentProps { label?: string; - className?: string; - onClick(ev: ButtonEvent); } // Semantic component for representing a role=menuitem @@ -34,3 +32,4 @@ export const MenuItem: React.FC = ({children, label, ...props}) => {
); }; + diff --git a/src/accessibility/context_menu/MenuItemCheckbox.tsx b/src/accessibility/context_menu/MenuItemCheckbox.tsx index e2cc04b5a6..5eb8cc4819 100644 --- a/src/accessibility/context_menu/MenuItemCheckbox.tsx +++ b/src/accessibility/context_menu/MenuItemCheckbox.tsx @@ -18,14 +18,11 @@ limitations under the License. import React from "react"; -import AccessibleButton, {ButtonEvent, IProps as IAccessibleButtonProps} from "../../components/views/elements/AccessibleButton"; +import AccessibleButton from "../../components/views/elements/AccessibleButton"; -interface IProps extends IAccessibleButtonProps { +interface IProps extends React.ComponentProps { label?: string; active: boolean; - disabled?: boolean; - className?: string; - onClick(ev: ButtonEvent); } // Semantic component for representing a role=menuitemcheckbox @@ -36,6 +33,7 @@ export const MenuItemCheckbox: React.FC = ({children, label, active, dis role="menuitemcheckbox" aria-checked={active} aria-disabled={disabled} + disabled={disabled} tabIndex={-1} aria-label={label} > diff --git a/src/accessibility/context_menu/MenuItemRadio.tsx b/src/accessibility/context_menu/MenuItemRadio.tsx index 21732220df..472f13ff14 100644 --- a/src/accessibility/context_menu/MenuItemRadio.tsx +++ b/src/accessibility/context_menu/MenuItemRadio.tsx @@ -18,14 +18,11 @@ limitations under the License. import React from "react"; -import AccessibleButton, {ButtonEvent, IProps as IAccessibleButtonProps} from "../../components/views/elements/AccessibleButton"; +import AccessibleButton from "../../components/views/elements/AccessibleButton"; -interface IProps extends IAccessibleButtonProps { +interface IProps extends React.ComponentProps { label?: string; active: boolean; - disabled?: boolean; - className?: string; - onClick(ev: ButtonEvent); } // Semantic component for representing a role=menuitemradio @@ -36,6 +33,7 @@ export const MenuItemRadio: React.FC = ({children, label, active, disabl role="menuitemradio" aria-checked={active} aria-disabled={disabled} + disabled={disabled} tabIndex={-1} aria-label={label} > diff --git a/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx b/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx index f5a510f517..d373f892c9 100644 --- a/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx +++ b/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx @@ -23,7 +23,7 @@ import StyledCheckbox from "../../components/views/elements/StyledCheckbox"; interface IProps extends React.ComponentProps { label?: string; - onChange(); + onChange(); // we handle keyup/down ourselves so lose the ChangeEvent onClose(): void; // gets called after onChange on Key.ENTER } diff --git a/src/accessibility/context_menu/StyledMenuItemRadio.tsx b/src/accessibility/context_menu/StyledMenuItemRadio.tsx index be87ccc683..5e5aa90a38 100644 --- a/src/accessibility/context_menu/StyledMenuItemRadio.tsx +++ b/src/accessibility/context_menu/StyledMenuItemRadio.tsx @@ -23,8 +23,7 @@ import StyledRadioButton from "../../components/views/elements/StyledRadioButton interface IProps extends React.ComponentProps { label?: string; - disabled?: boolean; - onChange(): void; + onChange(); // we handle keyup/down ourselves so lose the ChangeEvent onClose(): void; // gets called after onChange on Key.ENTER } From 853b2806738eeccf1a5e2e550f437b69eb6c90d7 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 7 Jul 2020 18:30:57 +0100 Subject: [PATCH 11/19] Fix MELS summary of 3pid invite revocations Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/views/elements/MemberEventListSummary.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.js index fc79fc87d0..956b69ca7b 100644 --- a/src/components/views/elements/MemberEventListSummary.js +++ b/src/components/views/elements/MemberEventListSummary.js @@ -1,6 +1,6 @@ /* Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); @@ -23,6 +23,7 @@ import { _t } from '../../../languageHandler'; import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; import * as sdk from "../../../index"; import {MatrixEvent} from "matrix-js-sdk"; +import {isValid3pidInvite} from "../../../RoomInvite"; export default createReactClass({ displayName: 'MemberEventListSummary', @@ -284,6 +285,9 @@ export default createReactClass({ _getTransition: function(e) { if (e.mxEvent.getType() === 'm.room.third_party_invite') { // Handle 3pid invites the same as invites so they get bundled together + if (!isValid3pidInvite(e.mxEvent)) { + return 'invite_withdrawal'; + } return 'invited'; } From c5e8a0b5afc30ed5b97867e826c1e3fc1847ab4d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 8 Jul 2020 08:40:58 +0100 Subject: [PATCH 12/19] Convert HtmlUtils to TypeScript Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- package.json | 2 + src/{HtmlUtils.js => HtmlUtils.tsx} | 127 +++++++++++++++------------- yarn.lock | 14 +++ 3 files changed, 83 insertions(+), 60 deletions(-) rename src/{HtmlUtils.js => HtmlUtils.tsx} (84%) diff --git a/package.json b/package.json index 3fd7703afb..7251d76498 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "@types/classnames": "^2.2.10", "@types/counterpart": "^0.18.1", "@types/flux": "^3.1.9", + "@types/linkifyjs": "^2.1.3", "@types/lodash": "^4.14.152", "@types/modernizr": "^3.5.3", "@types/node": "^12.12.41", @@ -129,6 +130,7 @@ "@types/react": "^16.9", "@types/react-dom": "^16.9.8", "@types/react-transition-group": "^4.4.0", + "@types/sanitize-html": "^1.23.3", "@types/zxcvbn": "^4.4.0", "babel-eslint": "^10.0.3", "babel-jest": "^24.9.0", diff --git a/src/HtmlUtils.js b/src/HtmlUtils.tsx similarity index 84% rename from src/HtmlUtils.js rename to src/HtmlUtils.tsx index 34e9e55d25..6746f68812 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.tsx @@ -17,10 +17,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - -import ReplyThread from "./components/views/elements/ReplyThread"; - import React from 'react'; import sanitizeHtml from 'sanitize-html'; import * as linkify from 'linkifyjs'; @@ -28,12 +24,13 @@ import linkifyMatrix from './linkify-matrix'; import _linkifyElement from 'linkifyjs/element'; import _linkifyString from 'linkifyjs/string'; import classNames from 'classnames'; -import {MatrixClientPeg} from './MatrixClientPeg'; +import EMOJIBASE_REGEX from 'emojibase-regex'; import url from 'url'; -import EMOJIBASE_REGEX from 'emojibase-regex'; +import {MatrixClientPeg} from './MatrixClientPeg'; import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji"; +import ReplyThread from "./components/views/elements/ReplyThread"; linkifyMatrix(linkify); @@ -64,7 +61,7 @@ const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; * need emojification. * unicodeToImage uses this function. */ -function mightContainEmoji(str) { +function mightContainEmoji(str: string) { return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str); } @@ -74,7 +71,7 @@ function mightContainEmoji(str) { * @param {String} char The emoji character * @return {String} The shortcode (such as :thumbup:) */ -export function unicodeToShortcode(char) { +export function unicodeToShortcode(char: string) { const data = getEmojiFromUnicode(char); return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : ''); } @@ -85,7 +82,7 @@ export function unicodeToShortcode(char) { * @param {String} shortcode The shortcode (such as :thumbup:) * @return {String} The emoji character; null if none exists */ -export function shortcodeToUnicode(shortcode) { +export function shortcodeToUnicode(shortcode: string) { shortcode = shortcode.slice(1, shortcode.length - 1); const data = SHORTCODE_TO_EMOJI.get(shortcode); return data ? data.unicode : null; @@ -100,7 +97,7 @@ export function processHtmlForSending(html: string): string { } let contentHTML = ""; - for (let i=0; i < contentDiv.children.length; i++) { + for (let i = 0; i < contentDiv.children.length; i++) { const element = contentDiv.children[i]; if (element.tagName.toLowerCase() === 'p') { contentHTML += element.innerHTML; @@ -122,7 +119,7 @@ export function processHtmlForSending(html: string): string { * Given an untrusted HTML string, return a React node with an sanitized version * of that HTML. */ -export function sanitizedHtmlNode(insaneHtml) { +export function sanitizedHtmlNode(insaneHtml: string) { const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); return
; @@ -136,7 +133,7 @@ export function sanitizedHtmlNode(insaneHtml) { * other places we need to sanitise URLs. * @return true if permitted, otherwise false */ -export function isUrlPermitted(inputUrl) { +export function isUrlPermitted(inputUrl: string) { try { const parsed = url.parse(inputUrl); if (!parsed.protocol) return false; @@ -147,9 +144,9 @@ export function isUrlPermitted(inputUrl) { } } -const transformTags = { // custom to matrix +const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to matrix // add blank targets to all hyperlinks except vector URLs - 'a': function(tagName, attribs) { + 'a': function(tagName: string, attribs: sanitizeHtml.Attributes) { if (attribs.href) { attribs.target = '_blank'; // by default @@ -162,7 +159,7 @@ const transformTags = { // custom to matrix attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/ return { tagName, attribs }; }, - 'img': function(tagName, attribs) { + 'img': function(tagName: string, attribs: sanitizeHtml.Attributes) { // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag // because transformTags is used _before_ we filter by allowedSchemesByTag and // we don't want to allow images with `https?` `src`s. @@ -176,7 +173,7 @@ const transformTags = { // custom to matrix ); return { tagName, attribs }; }, - 'code': function(tagName, attribs) { + 'code': function(tagName: string, attribs: sanitizeHtml.Attributes) { if (typeof attribs.class !== 'undefined') { // Filter out all classes other than ones starting with language- for syntax highlighting. const classes = attribs.class.split(/\s/).filter(function(cl) { @@ -186,7 +183,7 @@ const transformTags = { // custom to matrix } return { tagName, attribs }; }, - '*': function(tagName, attribs) { + '*': function(tagName: string, attribs: sanitizeHtml.Attributes) { // Delete any style previously assigned, style is an allowedTag for font and span // because attributes are stripped after transforming delete attribs.style; @@ -220,7 +217,7 @@ const transformTags = { // custom to matrix }, }; -const sanitizeHtmlParams = { +const sanitizeHtmlParams: sanitizeHtml.IOptions = { allowedTags: [ 'font', // custom to matrix for IRC-style font coloring 'del', // for markdown @@ -247,16 +244,16 @@ const sanitizeHtmlParams = { }; // this is the same as the above except with less rewriting -const composerSanitizeHtmlParams = Object.assign({}, sanitizeHtmlParams); -composerSanitizeHtmlParams.transformTags = { - 'code': transformTags['code'], - '*': transformTags['*'], +const composerSanitizeHtmlParams: sanitizeHtml.IOptions = { + ...sanitizeHtmlParams, + transformTags: { + 'code': transformTags['code'], + '*': transformTags['*'], + }, }; -class BaseHighlighter { - constructor(highlightClass, highlightLink) { - this.highlightClass = highlightClass; - this.highlightLink = highlightLink; +abstract class BaseHighlighter { + constructor(public highlightClass: string, public highlightLink: string) { } /** @@ -270,47 +267,49 @@ class BaseHighlighter { * returns a list of results (strings for HtmlHighligher, react nodes for * TextHighlighter). */ - applyHighlights(safeSnippet, safeHighlights) { + public applyHighlights(safeSnippet: string, safeHighlights: string[]): T[] { let lastOffset = 0; let offset; - let nodes = []; + let nodes: T[] = []; const safeHighlight = safeHighlights[0]; while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) { // handle preamble if (offset > lastOffset) { - var subSnippet = safeSnippet.substring(lastOffset, offset); - nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights)); + const subSnippet = safeSnippet.substring(lastOffset, offset); + nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights)); } // do highlight. use the original string rather than safeHighlight // to preserve the original casing. const endOffset = offset + safeHighlight.length; - nodes.push(this._processSnippet(safeSnippet.substring(offset, endOffset), true)); + nodes.push(this.processSnippet(safeSnippet.substring(offset, endOffset), true)); lastOffset = endOffset; } // handle postamble if (lastOffset !== safeSnippet.length) { - subSnippet = safeSnippet.substring(lastOffset, undefined); - nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights)); + const subSnippet = safeSnippet.substring(lastOffset, undefined); + nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights)); } return nodes; } - _applySubHighlights(safeSnippet, safeHighlights) { + private applySubHighlights(safeSnippet: string, safeHighlights: string[]): T[] { if (safeHighlights[1]) { // recurse into this range to check for the next set of highlight matches return this.applyHighlights(safeSnippet, safeHighlights.slice(1)); } else { // no more highlights to be found, just return the unhighlighted string - return [this._processSnippet(safeSnippet, false)]; + return [this.processSnippet(safeSnippet, false)]; } } + + protected abstract processSnippet(snippet: string, highlight: boolean): T; } -class HtmlHighlighter extends BaseHighlighter { +class HtmlHighlighter extends BaseHighlighter { /* highlight the given snippet if required * * snippet: content of the span; must have been sanitised @@ -318,28 +317,23 @@ class HtmlHighlighter extends BaseHighlighter { * * returns an HTML string */ - _processSnippet(snippet, highlight) { + protected processSnippet(snippet: string, highlight: boolean): string { if (!highlight) { // nothing required here return snippet; } - let span = "" - + snippet + ""; + let span = `${snippet}`; if (this.highlightLink) { - span = "" - +span+""; + span = `${span}`; } return span; } } -class TextHighlighter extends BaseHighlighter { - constructor(highlightClass, highlightLink) { - super(highlightClass, highlightLink); - this._key = 0; - } +class TextHighlighter extends BaseHighlighter { + private key = 0; /* create a node to hold the given content * @@ -348,13 +342,12 @@ class TextHighlighter extends BaseHighlighter { * * returns a React node */ - _processSnippet(snippet, highlight) { - const key = this._key++; + protected processSnippet(snippet: string, highlight: boolean): React.ReactNode { + const key = this.key++; - let node = - - { snippet } - ; + let node = + { snippet } + ; if (highlight && this.highlightLink) { node = { node }; @@ -364,6 +357,20 @@ class TextHighlighter extends BaseHighlighter { } } +interface IContent { + format?: string; + formatted_body?: string; + body: string; +} + +interface IOpts { + highlightLink?: string; + disableBigEmoji?: boolean; + stripReplyFallback?: boolean; + returnString?: boolean; + forComposerQuote?: boolean; + ref?: React.Ref; +} /* turn a matrix event body into html * @@ -378,7 +385,7 @@ class TextHighlighter extends BaseHighlighter { * opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer * opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString) */ -export function bodyToHtml(content, highlights, opts={}) { +export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) { const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body; let bodyHasEmoji = false; @@ -387,9 +394,9 @@ export function bodyToHtml(content, highlights, opts={}) { sanitizeParams = composerSanitizeHtmlParams; } - let strippedBody; - let safeBody; - let isDisplayedWithHtml; + let strippedBody: string; + let safeBody: string; + let isDisplayedWithHtml: boolean; // XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying // to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which // are interrupted by HTML tags (not that we did before) - e.g. foobar won't get highlighted @@ -471,7 +478,7 @@ export function bodyToHtml(content, highlights, opts={}) { * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options * @returns {string} Linkified string */ -export function linkifyString(str, options = linkifyMatrix.options) { +export function linkifyString(str: string, options = linkifyMatrix.options) { return _linkifyString(str, options); } @@ -482,7 +489,7 @@ export function linkifyString(str, options = linkifyMatrix.options) { * @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options * @returns {object} */ -export function linkifyElement(element, options = linkifyMatrix.options) { +export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options) { return _linkifyElement(element, options); } @@ -493,7 +500,7 @@ export function linkifyElement(element, options = linkifyMatrix.options) { * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options * @returns {string} */ -export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.options) { +export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) { return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams); } @@ -504,7 +511,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.option * @param {Node} node * @returns {bool} */ -export function checkBlockNode(node) { +export function checkBlockNode(node: Node) { switch (node.nodeName) { case "H1": case "H2": diff --git a/yarn.lock b/yarn.lock index d8106febab..972891f4ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1308,6 +1308,13 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339" integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA== +"@types/linkifyjs@^2.1.3": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@types/linkifyjs/-/linkifyjs-2.1.3.tgz#80195c3c88c5e75d9f660e3046ce4a42be2c2fa4" + integrity sha512-V3Xt9wgaOvDPXcpOy3dC8qXCxy3cs0Lr/Hqgd9Bi6m3sf/vpbpTtfmVR0LJklrqYEjaAmc7e3Xh/INT2rCAKjQ== + dependencies: + "@types/react" "*" + "@types/lodash@^4.14.152": version "4.14.155" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.155.tgz#e2b4514f46a261fd11542e47519c20ebce7bc23a" @@ -1372,6 +1379,13 @@ "@types/prop-types" "*" csstype "^2.2.0" +"@types/sanitize-html@^1.23.3": + version "1.23.3" + resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-1.23.3.tgz#26527783aba3bf195ad8a3c3e51bd3713526fc0d" + integrity sha512-Isg8N0ifKdDq6/kaNlIcWfapDXxxquMSk2XC5THsOICRyOIhQGds95XH75/PL/g9mExi4bL8otIqJM/Wo96WxA== + dependencies: + htmlparser2 "^4.1.0" + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" From 8d5d3b1c926da3246119e8c6f8d51fda6580825c Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 8 Jul 2020 08:50:25 +0100 Subject: [PATCH 13/19] Use html innerText for org.matrix.custom.html m.room.message room list previews Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/HtmlUtils.tsx | 7 +++++++ .../room-list/previews/MessageEventPreview.ts | 16 +++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 6746f68812..6dba041685 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -125,6 +125,13 @@ export function sanitizedHtmlNode(insaneHtml: string) { return
; } +export function sanitizedHtmlNodeInnerText(insaneHtml: string) { + const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); + const contentDiv = document.createElement("div"); + contentDiv.innerHTML = saneHtml; + return contentDiv.innerText; +} + /** * Tests if a URL from an untrusted source may be safely put into the DOM * The biggest threat here is javascript: URIs. diff --git a/src/stores/room-list/previews/MessageEventPreview.ts b/src/stores/room-list/previews/MessageEventPreview.ts index 86ec4c539b..86cb51ef15 100644 --- a/src/stores/room-list/previews/MessageEventPreview.ts +++ b/src/stores/room-list/previews/MessageEventPreview.ts @@ -20,6 +20,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { _t } from "../../../languageHandler"; import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; import ReplyThread from "../../../components/views/elements/ReplyThread"; +import { sanitizedHtmlNodeInnerText } from "../../../HtmlUtils"; export class MessageEventPreview implements IPreview { public getTextFor(event: MatrixEvent, tagId?: TagID): string { @@ -36,14 +37,27 @@ export class MessageEventPreview implements IPreview { const msgtype = eventContent['msgtype']; if (!body || !msgtype) return null; // invalid event, no preview + const hasHtml = eventContent.format === "org.matrix.custom.html" && eventContent.formatted_body; + if (hasHtml) { + body = eventContent.formatted_body; + } + // XXX: Newer relations have a getRelation() function which is not compatible with replies. const mRelatesTo = event.getWireContent()['m.relates_to']; if (mRelatesTo && mRelatesTo['m.in_reply_to']) { // If this is a reply, get the real reply and use that - body = (ReplyThread.stripPlainReply(body) || '').trim(); + if (hasHtml) { + body = (ReplyThread.stripHTMLReply(body) || '').trim(); + } else { + body = (ReplyThread.stripPlainReply(body) || '').trim(); + } if (!body) return null; // invalid event, no preview } + if (hasHtml) { + body = sanitizedHtmlNodeInnerText(body); + } + if (msgtype === 'm.emote') { return _t("%(senderName)s %(emote)s", {senderName: getSenderName(event), emote: body}); } From 7b115056b0ec64dba01f1758aa6aab7c88b418b0 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 8 Jul 2020 09:21:33 +0100 Subject: [PATCH 14/19] Fix sticky headers being left on display:none if they change too quickly Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/structures/LeftPanel2.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index 7fac6cbff1..e67a85db25 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -146,6 +146,7 @@ export default class LeftPanel2 extends React.Component { const slRect = sublist.getBoundingClientRect(); const header = sublist.querySelector(".mx_RoomSublist2_stickable"); + header.style.removeProperty("display"); // always clear display:none first if (slRect.top + headerHeight > bottom && !gotBottom) { header.classList.add("mx_RoomSublist2_headerContainer_sticky"); @@ -161,8 +162,6 @@ export default class LeftPanel2 extends React.Component { if (lastTopHeader) { lastTopHeader.style.display = "none"; } - // first unset it, if set in last iteration - header.style.removeProperty("display"); lastTopHeader = header; } else { header.classList.remove("mx_RoomSublist2_headerContainer_sticky"); From ec54d509e52e225e47406b3ed52085a00c3f0d5c Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 8 Jul 2020 13:24:40 +0100 Subject: [PATCH 15/19] remove stale debug log Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/views/rooms/RoomTile2.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx index 90edbfb895..67d7ae34ba 100644 --- a/src/components/views/rooms/RoomTile2.tsx +++ b/src/components/views/rooms/RoomTile2.tsx @@ -170,7 +170,6 @@ export default class RoomTile2 extends React.Component { }; private scrollIntoView = () => { - console.log("DEBUG scrollIntoView", this.roomTileRef.current); if (!this.roomTileRef.current) return; this.roomTileRef.current.scrollIntoView({ block: "nearest", From 75751abc60d9b75438c1a55d00053e5f10e40008 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 8 Jul 2020 14:49:04 +0200 Subject: [PATCH 16/19] add wrapper we can then add padding to when sticking headers --- res/css/structures/_LeftPanel2.scss | 8 ++++++++ src/components/structures/LeftPanel2.tsx | 20 +++++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss index a73658d916..57f690346b 100644 --- a/res/css/structures/_LeftPanel2.scss +++ b/res/css/structures/_LeftPanel2.scss @@ -121,6 +121,14 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations } } + .mx_LeftPanel2_roomListWrapper { + display: flex; + flex-grow: 1; + overflow: hidden; + min-height: 0; + + } + .mx_LeftPanel2_actualRoomListContainer { flex-grow: 1; // fill the available space overflow-y: auto; diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index 7fac6cbff1..037c3bd4ff 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -325,15 +325,17 @@ export default class LeftPanel2 extends React.Component {
From 0d94cfa97ad7971d43332709e5023d7d4a6cc894 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 8 Jul 2020 14:49:38 +0200 Subject: [PATCH 17/19] put sticky headers in padding of wrapper this way they don't need a background, as the list is already clipped --- res/css/structures/_LeftPanel2.scss | 7 ++++++ src/components/structures/LeftPanel2.tsx | 29 ++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss index 57f690346b..eaa22a3efa 100644 --- a/res/css/structures/_LeftPanel2.scss +++ b/res/css/structures/_LeftPanel2.scss @@ -127,6 +127,13 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations overflow: hidden; min-height: 0; + &.stickyBottom { + padding-bottom: 32px; + } + + &.stickyTop { + padding-top: 32px; + } } .mx_LeftPanel2_actualRoomListContainer { diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index 037c3bd4ff..51dc4c0c4c 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -153,11 +153,16 @@ export default class LeftPanel2 extends React.Component { header.style.width = `${headerStickyWidth}px`; header.style.removeProperty("top"); gotBottom = true; - } else if ((slRect.top - (headerHeight / 3)) < top) { + } else if (((slRect.top - (headerHeight * 0.6) + headerHeight) < top) || sublist === sublists[0]) { + // the header should become sticky once it is 60% or less out of view at the top. + // We also add headerHeight because the sticky header is put above the scrollable area, + // into the padding of .mx_LeftPanel2_roomListWrapper, + // by subtracting headerHeight from the top below. + // We also always try to make the first sublist header sticky. header.classList.add("mx_RoomSublist2_headerContainer_sticky"); header.classList.add("mx_RoomSublist2_headerContainer_stickyTop"); header.style.width = `${headerStickyWidth}px`; - header.style.top = `${rlRect.top}px`; + header.style.top = `${rlRect.top - headerHeight}px`; if (lastTopHeader) { lastTopHeader.style.display = "none"; } @@ -172,6 +177,26 @@ export default class LeftPanel2 extends React.Component { header.style.removeProperty("top"); } } + + // add appropriate sticky classes to wrapper so it has + // the necessary top/bottom padding to put the sticky header in + const listWrapper = list.parentElement; + if (gotBottom) { + listWrapper.classList.add("stickyBottom"); + } else { + listWrapper.classList.remove("stickyBottom"); + } + if (lastTopHeader) { + listWrapper.classList.add("stickyTop"); + } else { + listWrapper.classList.remove("stickyTop"); + } + + // ensure scroll doesn't go above the gap left by the header of + // the first sublist always being sticky if no other header is sticky + if (list.scrollTop < headerHeight) { + list.scrollTop = headerHeight; + } } // TODO: Improve header reliability: https://github.com/vector-im/riot-web/issues/14232 From a8085f4e3bcd3e0967f3e68b5f2b63db322abac4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 8 Jul 2020 14:50:08 +0200 Subject: [PATCH 18/19] remove background on sticky headers --- res/css/views/rooms/_RoomSublist2.scss | 3 --- 1 file changed, 3 deletions(-) diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss index d08bc09031..194e615099 100644 --- a/res/css/views/rooms/_RoomSublist2.scss +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -54,9 +54,6 @@ limitations under the License. max-width: 100%; z-index: 2; // Prioritize headers in the visible list over sticky ones - // Set the same background color as the room list for sticky headers - background-color: $roomlist2-bg-color; - // Create a flexbox to make ordering easy display: flex; align-items: center; From a361ac3f83b42a4e3cea2608aa4ea91b8760cc99 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 8 Jul 2020 15:11:47 +0200 Subject: [PATCH 19/19] make collapsing/expanding the first header work again --- src/components/structures/LeftPanel2.tsx | 16 ++++++++-------- src/components/views/rooms/RoomSublist2.tsx | 7 ++++++- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index 51dc4c0c4c..6da70ed0ae 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -21,6 +21,7 @@ import classNames from "classnames"; import dis from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; import RoomList2 from "../views/rooms/RoomList2"; +import { HEADER_HEIGHT } from "../views/rooms/RoomSublist2"; import { Action } from "../../dispatcher/actions"; import UserMenu from "./UserMenu"; import RoomSearch from "./RoomSearch"; @@ -135,7 +136,6 @@ export default class LeftPanel2 extends React.Component { const bottom = rlRect.bottom; const top = rlRect.top; const sublists = list.querySelectorAll(".mx_RoomSublist2"); - const headerHeight = 32; // Note: must match the CSS! const headerRightMargin = 24; // calculated from margins and widths to align with non-sticky tiles const headerStickyWidth = rlRect.width - headerRightMargin; @@ -147,22 +147,22 @@ export default class LeftPanel2 extends React.Component { const header = sublist.querySelector(".mx_RoomSublist2_stickable"); - if (slRect.top + headerHeight > bottom && !gotBottom) { + if (slRect.top + HEADER_HEIGHT > bottom && !gotBottom) { header.classList.add("mx_RoomSublist2_headerContainer_sticky"); header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom"); header.style.width = `${headerStickyWidth}px`; header.style.removeProperty("top"); gotBottom = true; - } else if (((slRect.top - (headerHeight * 0.6) + headerHeight) < top) || sublist === sublists[0]) { + } else if (((slRect.top - (HEADER_HEIGHT * 0.6) + HEADER_HEIGHT) < top) || sublist === sublists[0]) { // the header should become sticky once it is 60% or less out of view at the top. - // We also add headerHeight because the sticky header is put above the scrollable area, + // We also add HEADER_HEIGHT because the sticky header is put above the scrollable area, // into the padding of .mx_LeftPanel2_roomListWrapper, - // by subtracting headerHeight from the top below. + // by subtracting HEADER_HEIGHT from the top below. // We also always try to make the first sublist header sticky. header.classList.add("mx_RoomSublist2_headerContainer_sticky"); header.classList.add("mx_RoomSublist2_headerContainer_stickyTop"); header.style.width = `${headerStickyWidth}px`; - header.style.top = `${rlRect.top - headerHeight}px`; + header.style.top = `${rlRect.top - HEADER_HEIGHT}px`; if (lastTopHeader) { lastTopHeader.style.display = "none"; } @@ -194,8 +194,8 @@ export default class LeftPanel2 extends React.Component { // ensure scroll doesn't go above the gap left by the header of // the first sublist always being sticky if no other header is sticky - if (list.scrollTop < headerHeight) { - list.scrollTop = headerHeight; + if (list.scrollTop < HEADER_HEIGHT) { + list.scrollTop = HEADER_HEIGHT; } } diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index eefd29f0b7..9a97aac320 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -55,6 +55,7 @@ import StyledCheckbox from "../elements/StyledCheckbox"; const SHOW_N_BUTTON_HEIGHT = 32; // As defined by CSS const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS +export const HEADER_HEIGHT = 32; // As defined by CSS const MAX_PADDING_HEIGHT = SHOW_N_BUTTON_HEIGHT + RESIZE_HANDLE_HEIGHT; @@ -233,7 +234,11 @@ export default class RoomSublist2 extends React.Component { const possibleSticky = target.parentElement; const sublist = possibleSticky.parentElement.parentElement; - if (possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky')) { + const list = sublist.parentElement.parentElement; + // the scrollTop is capped at the height of the header in LeftPanel2 + const isAtTop = list.scrollTop <= HEADER_HEIGHT; + const isSticky = possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky'); + if (isSticky && !isAtTop) { // is sticky - jump to list sublist.scrollIntoView({behavior: 'smooth'}); } else {