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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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 da1d1ffa09ac7a8105da9b3d0312a93db0e73802 Mon Sep 17 00:00:00 2001 From: Jorik Schellekens Date: Tue, 7 Jul 2020 15:42:54 +0100 Subject: [PATCH 11/42] Change colour to orange and do some lints --- res/themes/light/css/_light.scss | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index c4b4262642..b21fe34fba 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -36,7 +36,7 @@ $focus-bg-color: #dddddd; $accent-fg-color: #ffffff; $accent-color-50pct: rgba(3, 179, 129, 0.5); //#03b381 in rgb $accent-color-darker: #92caad; -$accent-color-alt: #238CF5; +$accent-color-alt: #238cf5; $selection-fg-color: $primary-bg-color; @@ -46,8 +46,8 @@ $focus-brightness: 105%; $warning-color: $notice-primary-color; // red $orange-warning-color: #ff8d13; // used for true warnings // background colour for warnings -$warning-bg-color: #DF2A8B; -$info-bg-color: #2A9EDF; +$warning-bg-color: #df2a8b; +$info-bg-color: #2a9edf; $mention-user-pill-bg-color: $warning-color; $other-user-pill-bg-color: rgba(0, 0, 0, 0.1); @@ -71,7 +71,7 @@ $tagpanel-bg-color: #27303a; $plinth-bg-color: $secondary-accent-color; // used by RoomDropTarget -$droptarget-bg-color: rgba(255,255,255,0.5); +$droptarget-bg-color: rgba(255, 255, 255, 0.5); // used by AddressSelector $selected-color: $secondary-accent-color; @@ -157,18 +157,18 @@ $rte-group-pill-color: #aaa; $topleftmenu-color: #212121; $roomheader-color: #45474a; -$roomheader-addroom-bg-color: #91A1C0; +$roomheader-addroom-bg-color: #91a1c0; $roomheader-addroom-fg-color: $accent-fg-color; -$tagpanel-button-color: #91A1C0; -$roomheader-button-color: #91A1C0; -$groupheader-button-color: #91A1C0; -$rightpanel-button-color: #91A1C0; -$composer-button-color: #91A1C0; +$tagpanel-button-color: #91a1c0; +$roomheader-button-color: #91a1c0; +$groupheader-button-color: #91a1c0; +$rightpanel-button-color: #91a1c0; +$composer-button-color: #91a1c0; $roomtopic-color: #9e9e9e; $eventtile-meta-color: $roomtopic-color; $composer-e2e-icon-color: #c9ced6; -$header-divider-color: #91A1C0; +$header-divider-color: #91a1c0; // ******************** @@ -184,11 +184,11 @@ $roomsublist2-divider-color: $primary-fg-color; $roomtile2-preview-color: #9e9e9e; $roomtile2-default-badge-bg-color: #61708b; -$roomtile2-selected-bg-color: #FFF; +$roomtile2-selected-bg-color: #fff; $presence-online: $accent-color; -$presence-away: orange; // TODO: Get color -$presence-offline: #E3E8F0; +$presence-away: #d9b072; // TODO: Get color +$presence-offline: #e3e8f0; // ******************** From 6b5eaca0b99d6f2a1f88adc735c05de0e693889d Mon Sep 17 00:00:00 2001 From: Jorik Schellekens Date: Tue, 7 Jul 2020 16:45:43 +0100 Subject: [PATCH 12/42] Remove comment --- res/themes/light/css/_light.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index b21fe34fba..8469a85bfe 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -187,7 +187,7 @@ $roomtile2-default-badge-bg-color: #61708b; $roomtile2-selected-bg-color: #fff; $presence-online: $accent-color; -$presence-away: #d9b072; // TODO: Get color +$presence-away: #d9b072; $presence-offline: #e3e8f0; // ******************** From 92e86af162b9d37f79a0c2b1693730c8b65dd3a3 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 7 Jul 2020 17:07:51 +0100 Subject: [PATCH 13/42] Show more/Show less keep focus in a relevant place Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/views/rooms/RoomSublist2.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index 18f8f4e2f6..9d15e64e7b 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -140,15 +140,25 @@ export default class RoomSublist2 extends React.Component { }; private onShowAllClick = () => { - // TODO a11y keep focus somewhere useful: https://github.com/vector-im/riot-web/issues/14180 + const numVisibleTiles = this.numVisibleTiles; this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT); this.forceUpdate(); // because the layout doesn't trigger a re-render + setImmediate(this.focusRoomTile, numVisibleTiles); // focus the tile after the current bottom one }; private onShowLessClick = () => { - // TODO a11y keep focus somewhere useful: https://github.com/vector-im/riot-web/issues/14180 this.props.layout.visibleTiles = this.props.layout.defaultVisibleTiles; this.forceUpdate(); // because the layout doesn't trigger a re-render + // focus will flow to the show more button here + }; + + private focusRoomTile = (index: number) => { + if (!this.sublistRef.current) return; + const elements = this.sublistRef.current.querySelectorAll(".mx_RoomTile2"); + const element = elements && elements[index]; + if (element) { + element.focus(); + } }; private onOpenMenuClick = (ev: InputEvent) => { @@ -520,6 +530,7 @@ export default class RoomSublist2 extends React.Component { ); if (this.props.isMinimized) showMoreText = null; + // TODO Roving tab index / treeitem?: https://github.com/vector-im/riot-web/issues/14180 showNButton = ( From f18db23cc4e7138c8b5a55598430711c59bf65fe Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 7 Jul 2020 17:18:56 +0100 Subject: [PATCH 14/42] Remove some TODOs Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/structures/LeftPanel2.tsx | 3 --- src/components/views/rooms/RoomSublist2.tsx | 1 - src/components/views/rooms/RoomTile2.tsx | 3 --- 3 files changed, 7 deletions(-) diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index 4b954d7843..b2a2384cb2 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -70,8 +70,6 @@ export default class LeftPanel2 extends React.Component { private tagPanelWatcherRef: string; private focusedElement = null; - // TODO: a11y: https://github.com/vector-im/riot-web/issues/14180 - constructor(props: IProps) { super(props); @@ -264,7 +262,6 @@ export default class LeftPanel2 extends React.Component { onVerticalArrow={this.onKeyDown} /> {
); - // TODO: a11y (see old component): https://github.com/vector-im/riot-web/issues/14180 // Note: the addRoomButton conditionally gets moved around // the DOM depending on whether or not the list is minimized. // If we're minimized, we want it below the header so it diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx index c6cd401803..abb31a6f71 100644 --- a/src/components/views/rooms/RoomTile2.tsx +++ b/src/components/views/rooms/RoomTile2.tsx @@ -118,8 +118,6 @@ const NotifOption: React.FC = ({active, onClick, iconClassNam }; export default class RoomTile2 extends React.Component { - // TODO: a11y: https://github.com/vector-im/riot-web/issues/14180 - constructor(props: IProps) { super(props); @@ -390,7 +388,6 @@ export default class RoomTile2 extends React.Component { public render(): React.ReactElement { // TODO: Invites: https://github.com/vector-im/riot-web/issues/14198 - // TODO: a11y proper: https://github.com/vector-im/riot-web/issues/14180 const classes = classNames({ 'mx_RoomTile2': true, From 4edd3dfc6c9b8bc3185adaa231b82ba65fa4ea10 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 7 Jul 2020 17:46:33 +0100 Subject: [PATCH 15/42] Convert RovingTabIndex to Typescript Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../{RovingTabIndex.js => RovingTabIndex.tsx} | 87 +++++++++++++------ 1 file changed, 62 insertions(+), 25 deletions(-) rename src/accessibility/{RovingTabIndex.js => RovingTabIndex.tsx} (79%) diff --git a/src/accessibility/RovingTabIndex.js b/src/accessibility/RovingTabIndex.tsx similarity index 79% rename from src/accessibility/RovingTabIndex.js rename to src/accessibility/RovingTabIndex.tsx index b481f08fe2..32780629f2 100644 --- a/src/accessibility/RovingTabIndex.js +++ b/src/accessibility/RovingTabIndex.tsx @@ -22,9 +22,13 @@ import React, { useMemo, useRef, useReducer, + Reducer, + RefObject, + Dispatch, } from "react"; -import PropTypes from "prop-types"; + import {Key} from "../Keyboard"; +import AccessibleButton from "../components/views/elements/AccessibleButton"; /** * Module to simplify implementing the Roving TabIndex accessibility technique @@ -41,7 +45,19 @@ import {Key} from "../Keyboard"; const DOCUMENT_POSITION_PRECEDING = 2; -const RovingTabIndexContext = createContext({ +type Ref = RefObject; + +interface IState { + activeRef: Ref; + refs: Ref[]; +} + +interface IContext { + state: IState; + dispatch: Dispatch; +} + +const RovingTabIndexContext = createContext({ state: { activeRef: null, refs: [], // list of refs in DOM order @@ -50,16 +66,22 @@ const RovingTabIndexContext = createContext({ }); RovingTabIndexContext.displayName = "RovingTabIndexContext"; -// TODO use a TypeScript type here -const types = { - REGISTER: "REGISTER", - UNREGISTER: "UNREGISTER", - SET_FOCUS: "SET_FOCUS", -}; +enum Type { + Register = "REGISTER", + Unregister = "UNREGISTER", + SetFocus = "SET_FOCUS", +} -const reducer = (state, action) => { +interface IAction { + type: Type; + payload: { + ref: Ref; + }; +} + +const reducer = (state: IState, action: IAction) => { switch (action.type) { - case types.REGISTER: { + case Type.Register: { if (state.refs.length === 0) { // Our list of refs was empty, set activeRef to this first item return { @@ -92,7 +114,7 @@ const reducer = (state, action) => { ], }; } - case types.UNREGISTER: { + case Type.Unregister: { // filter out the ref which we are removing const refs = state.refs.filter(r => r !== action.payload.ref); @@ -117,7 +139,7 @@ const reducer = (state, action) => { refs, }; } - case types.SET_FOCUS: { + case Type.SetFocus: { // update active ref return { ...state, @@ -129,13 +151,21 @@ const reducer = (state, action) => { } }; -export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) => { - const [state, dispatch] = useReducer(reducer, { +interface IProps { + handleHomeEnd?: boolean; + children(renderProps: { + onKeyDownHandler(ev: React.KeyboardEvent); + }); + onKeyDown?(ev: React.KeyboardEvent); +} + +export const RovingTabIndexProvider: React.FC = ({children, handleHomeEnd, onKeyDown}) => { + const [state, dispatch] = useReducer>(reducer, { activeRef: null, refs: [], }); - const context = useMemo(() => ({state, dispatch}), [state]); + const context = useMemo(() => ({state, dispatch}), [state]); const onKeyDownHandler = useCallback((ev) => { let handled = false; @@ -171,19 +201,17 @@ export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) => { children({onKeyDownHandler}) } ; }; -RovingTabIndexProvider.propTypes = { - handleHomeEnd: PropTypes.bool, - onKeyDown: PropTypes.func, -}; + +type FocusHandler = () => void; // Hook to register a roving tab index // inputRef parameter specifies the ref to use // onFocus should be called when the index gained focus in any manner // isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}` // ref should be passed to a DOM node which will be used for DOM compareDocumentPosition -export const useRovingTabIndex = (inputRef) => { +export const useRovingTabIndex = (inputRef: Ref): [FocusHandler, boolean, Ref] => { const context = useContext(RovingTabIndexContext); - let ref = useRef(null); + let ref = useRef(null); if (inputRef) { // if we are given a ref, use it instead of ours @@ -193,13 +221,13 @@ export const useRovingTabIndex = (inputRef) => { // setup (after refs) useLayoutEffect(() => { context.dispatch({ - type: types.REGISTER, + type: Type.Register, payload: {ref}, }); // teardown return () => { context.dispatch({ - type: types.UNREGISTER, + type: Type.Unregister, payload: {ref}, }); }; @@ -207,7 +235,7 @@ export const useRovingTabIndex = (inputRef) => { const onFocus = useCallback(() => { context.dispatch({ - type: types.SET_FOCUS, + type: Type.SetFocus, payload: {ref}, }); }, [ref, context]); @@ -216,8 +244,17 @@ export const useRovingTabIndex = (inputRef) => { return [onFocus, isActive, ref]; }; +interface IRovingTabIndexWrapperProps { + inputRef?: Ref; + children(renderProps: { + onFocus: FocusHandler; + isActive: boolean; + ref: Ref; + }); +} + // Wrapper to allow use of useRovingTabIndex outside of React Functional Components. -export const RovingTabIndexWrapper = ({children, inputRef}) => { +export const RovingTabIndexWrapper: React.FC = ({children, inputRef}) => { const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); return children({onFocus, isActive, ref}); }; From a33717a475a74f9b4bd773169fb624de450ed509 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 7 Jul 2020 17:47:21 +0100 Subject: [PATCH 16/42] Wire up Room sublist show more/less as roving tabindex button using new helper Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/accessibility/RovingTabIndex.tsx | 10 ++++++++++ src/components/views/rooms/RoomSublist2.tsx | 10 +++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 32780629f2..388d67d9f3 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -259,3 +259,13 @@ export const RovingTabIndexWrapper: React.FC = ({ch return children({onFocus, isActive, ref}); }; +interface IRovingAccessibleButtonProps extends React.ComponentProps { + inputRef?: Ref; +} + +// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components. +export const RovingAccessibleButton: React.FC = ({inputRef, ...props}) => { + const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); + return ; +}; + diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index 3a12e99914..56ce631604 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -20,7 +20,7 @@ import * as React from "react"; import { createRef } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import classNames from 'classnames'; -import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; +import {RovingAccessibleButton, RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex"; import { _t } from "../../../languageHandler"; import AccessibleButton from "../../views/elements/AccessibleButton"; import RoomTile2 from "./RoomTile2"; @@ -531,12 +531,12 @@ export default class RoomSublist2 extends React.Component { if (this.props.isMinimized) showMoreText = null; // TODO Roving tab index / treeitem?: https://github.com/vector-im/riot-web/issues/14180 showNButton = ( - + {/* set by CSS masking */} {showMoreText} - + ); } else if (this.numTiles <= visibleTiles.length && this.numTiles > this.props.layout.defaultVisibleTiles) { // we have all tiles visible - add a button to show less @@ -548,12 +548,12 @@ export default class RoomSublist2 extends React.Component { if (this.props.isMinimized) showLessText = null; // TODO Roving tab index / treeitem?: https://github.com/vector-im/riot-web/issues/14180 showNButton = ( - + {/* set by CSS masking */} {showLessText} - + ); } From 28310cb64848713486bf4ac05ff4c83517cf1db1 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 7 Jul 2020 17:48:39 +0100 Subject: [PATCH 17/42] remove TODOs Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/views/rooms/RoomSublist2.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index 56ce631604..eefd29f0b7 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -529,7 +529,6 @@ export default class RoomSublist2 extends React.Component { ); if (this.props.isMinimized) showMoreText = null; - // TODO Roving tab index / treeitem?: https://github.com/vector-im/riot-web/issues/14180 showNButton = ( @@ -546,7 +545,6 @@ export default class RoomSublist2 extends React.Component { ); if (this.props.isMinimized) showLessText = null; - // TODO Roving tab index / treeitem?: https://github.com/vector-im/riot-web/issues/14180 showNButton = ( 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 18/42] 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 e6b20088c0a1c87067f99444d3f90b693ad26cf4 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 7 Jul 2020 11:33:32 -0600 Subject: [PATCH 19/42] Try using requestAnimationFrame if available for sticky headers This might help performance, or it might not. Let's try it! --- src/components/structures/LeftPanel2.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index b2a2384cb2..36012a1473 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -69,6 +69,7 @@ export default class LeftPanel2 extends React.Component { private listContainerRef: React.RefObject = createRef(); private tagPanelWatcherRef: string; private focusedElement = null; + private isDoingStickyHeaders = false; constructor(props: IProps) { super(props); @@ -113,6 +114,24 @@ export default class LeftPanel2 extends React.Component { }; private handleStickyHeaders(list: HTMLDivElement) { + // TODO: Evaluate if this has any performance benefit or detriment. + // See https://github.com/vector-im/riot-web/issues/14035 + + if (this.isDoingStickyHeaders) return; + this.isDoingStickyHeaders = true; + if (window.requestAnimationFrame) { + window.requestAnimationFrame(() => { + this.doStickyHeaders(list); + this.isDoingStickyHeaders = false; + }); + } else { + this.doStickyHeaders(list); + this.isDoingStickyHeaders = false; + } + } + + private doStickyHeaders(list: HTMLDivElement) { + this.isDoingStickyHeaders = true; const rlRect = list.getBoundingClientRect(); const bottom = rlRect.bottom; const top = rlRect.top; From baccabeae461048e1da8d9d52f4b51c33ab170ed Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 7 Jul 2020 11:34:52 -0600 Subject: [PATCH 20/42] Remove extraneous true --- src/components/structures/LeftPanel2.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index 36012a1473..7fac6cbff1 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -131,7 +131,6 @@ export default class LeftPanel2 extends React.Component { } private doStickyHeaders(list: HTMLDivElement) { - this.isDoingStickyHeaders = true; const rlRect = list.getBoundingClientRect(); const bottom = rlRect.bottom; const top = rlRect.top; From 7963ed6d047536dfa545a0ff11c332eba7476072 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 7 Jul 2020 13:42:15 -0600 Subject: [PATCH 21/42] Mute "Unknown room caused setting update" spam See comment enclosed within. Fixes https://github.com/vector-im/riot-web/issues/14254 --- src/settings/handlers/RoomSettingsHandler.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/settings/handlers/RoomSettingsHandler.js b/src/settings/handlers/RoomSettingsHandler.js index d8e775742c..00dd5b8bec 100644 --- a/src/settings/handlers/RoomSettingsHandler.js +++ b/src/settings/handlers/RoomSettingsHandler.js @@ -43,11 +43,14 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl const roomId = event.getRoomId(); const room = this.client.getRoom(roomId); - // Note: the tests often fire setting updates that don't have rooms in the store, so - // we fail softly here. We shouldn't assume that the state being fired is current - // state, but we also don't need to explode just because we didn't find a room. - if (!room) console.warn(`Unknown room caused setting update: ${roomId}`); - if (room && state !== room.currentState) return; // ignore state updates which are not current + // Note: in tests and during the encryption setup on initial load we might not have + // rooms in the store, so we just quietly ignore the problem. If we log it then we'll + // just end up spamming the logs a few thousand times. It is perfectly fine for us + // to ignore the problem as the app will not have loaded enough to care yet. + if (!room) return; + + // ignore state updates which are not current + if (room && state !== room.currentState) return; if (event.getType() === "org.matrix.room.preview_urls") { let val = event.getContent()['disable']; From be1b2fddafd7a3588bbbd3e7709540b04a550a8c Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 7 Jul 2020 13:46:10 -0600 Subject: [PATCH 22/42] Ensure DMs are not lost in the new room list Fixes https://github.com/vector-im/riot-web/issues/14236 --- src/stores/room-list/algorithms/Algorithm.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index d5f2ed0053..41066fc14b 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -530,9 +530,11 @@ export class Algorithm extends EventEmitter { } if (!inTag) { - // TODO: Determine if DM and push there instead: https://github.com/vector-im/riot-web/issues/14236 - newTags[DefaultTagID.Untagged].push(room); - + if (DMRoomMap.getUserIdForRoomId(room.roomId)) { + newTags[DefaultTagID.DM].push(room); + } else { + newTags[DefaultTagID.Untagged].push(room); + } // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Untagged`); } From 2488520263d0097f6a08fefd98f1540de7bc8839 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 7 Jul 2020 13:46:29 -0600 Subject: [PATCH 23/42] Clean up tag logging in setKnownRooms We don't need this anymore --- src/stores/room-list/algorithms/Algorithm.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 41066fc14b..e70d7d468e 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -501,13 +501,9 @@ export class Algorithm extends EventEmitter { // Split out the easy rooms first (leave and invite) const memberships = splitRoomsByMembership(rooms); for (const room of memberships[EffectiveMembership.Invite]) { - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`[DEBUG] "${room.name}" (${room.roomId}) is an Invite`); newTags[DefaultTagID.Invite].push(room); } for (const room of memberships[EffectiveMembership.Leave]) { - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Historical`); newTags[DefaultTagID.Archived].push(room); } @@ -518,11 +514,7 @@ export class Algorithm extends EventEmitter { let inTag = false; if (tags.length > 0) { for (const tag of tags) { - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged as ${tag}`); if (!isNullOrUndefined(newTags[tag])) { - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged with VALID tag ${tag}`); newTags[tag].push(room); inTag = true; } @@ -535,8 +527,6 @@ export class Algorithm extends EventEmitter { } else { newTags[DefaultTagID.Untagged].push(room); } - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Untagged`); } } From 34ea8342fb3654f5a18ec07df5137a4159f9cc8a Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 7 Jul 2020 13:49:36 -0600 Subject: [PATCH 24/42] Remove comment claiming encrypted rooms are handled incorrectly Fixes https://github.com/vector-im/riot-web/issues/14238 The encrypted rooms are loaded on startup (eventually), so we don't need to worry about the problem described. --- src/stores/room-list/RoomListStore2.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/stores/room-list/RoomListStore2.ts b/src/stores/room-list/RoomListStore2.ts index e5205f6051..60174b63c8 100644 --- a/src/stores/room-list/RoomListStore2.ts +++ b/src/stores/room-list/RoomListStore2.ts @@ -221,9 +221,6 @@ export class RoomListStore2 extends AsyncStore { } // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 console.log(`[RoomListDebug] Decrypted timeline event ${eventPayload.event.getId()} in ${roomId}`); - // TODO: Verify that e2e rooms are handled on init: https://github.com/vector-im/riot-web/issues/14238 - // It seems like when viewing the room the timeline is decrypted, rather than at startup. This could - // cause inaccuracies with the list ordering. We may have to decrypt the last N messages of every room :( await this.handleRoomUpdate(room, RoomUpdateCause.Timeline); } else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') { const eventPayload = (payload); // TODO: Type out the dispatcher types From a4ef5909f93cdb1685efe7673ae601b8b6746433 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 7 Jul 2020 14:45:08 -0600 Subject: [PATCH 25/42] Respect and fix understanding of legacy options Fixes https://github.com/vector-im/riot-web/issues/14372 We read/use the options in multiple places, and those places were not in sync. Now when algorithms change and on initial load, both will come to the same conclusions about how to order & sort the rooms. --- src/settings/Settings.js | 4 +- src/stores/room-list/RoomListStore2.ts | 57 ++++++++++++++++---- src/stores/room-list/algorithms/Algorithm.ts | 2 + 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 58d9ed4f31..0011ecfccd 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -478,13 +478,13 @@ export const SETTINGS = { deny: [], }, }, - // TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14231 + // TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14373 "RoomList.orderAlphabetically": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td("Order rooms by name"), default: false, }, - // TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14231 + // TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14373 "RoomList.orderByImportance": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td("Show rooms with unread notifications first"), diff --git a/src/stores/room-list/RoomListStore2.ts b/src/stores/room-list/RoomListStore2.ts index e5205f6051..ac765b1e05 100644 --- a/src/stores/room-list/RoomListStore2.ts +++ b/src/stores/room-list/RoomListStore2.ts @@ -31,6 +31,7 @@ import RoomViewStore from "../RoomViewStore"; import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm"; import { EffectiveMembership, getEffectiveMembership } from "./membership"; import { ListLayout } from "./ListLayout"; +import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; interface IState { tagsEnabled?: boolean; @@ -321,6 +322,28 @@ export class RoomListStore2 extends AsyncStore { return localStorage.getItem(`mx_tagSort_${tagId}`); } + // logic must match calculateListOrder + private calculateTagSorting(tagId: TagID): SortAlgorithm { + const defaultSort = SortAlgorithm.Alphabetic; + const settingAlphabetical = SettingsStore.getValue("RoomList.orderAlphabetically", null, true); + const definedSort = this.getTagSorting(tagId); + const storedSort = this.getStoredTagSorting(tagId); + + // We use the following order to determine which of the 4 flags to use: + // Stored > Settings > Defined > Default + + let tagSort = defaultSort; + if (storedSort) { + tagSort = storedSort; + } else if (!isNullOrUndefined(settingAlphabetical)) { + tagSort = settingAlphabetical ? SortAlgorithm.Alphabetic : SortAlgorithm.Recent; + } else if (definedSort) { + tagSort = definedSort; + } // else default (already set) + + return tagSort; + } + public async setListOrder(tagId: TagID, order: ListAlgorithm) { await this.algorithm.setListOrdering(tagId, order); // TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114 @@ -337,19 +360,35 @@ export class RoomListStore2 extends AsyncStore { return localStorage.getItem(`mx_listOrder_${tagId}`); } - private async updateAlgorithmInstances() { - const defaultSort = SortAlgorithm.Alphabetic; + // logic must match calculateTagSorting + private calculateListOrder(tagId: TagID): ListAlgorithm { const defaultOrder = ListAlgorithm.Natural; + const settingImportance = SettingsStore.getValue("RoomList.orderByImportance", null, true); + const definedOrder = this.getListOrder(tagId); + const storedOrder = this.getStoredListOrder(tagId); + // We use the following order to determine which of the 4 flags to use: + // Stored > Settings > Defined > Default + + let listOrder = defaultOrder; + if (storedOrder) { + listOrder = storedOrder; + } else if (!isNullOrUndefined(settingImportance)) { + listOrder = settingImportance ? ListAlgorithm.Importance : ListAlgorithm.Natural; + } else if (definedOrder) { + listOrder = definedOrder; + } // else default (already set) + + return listOrder; + } + + private async updateAlgorithmInstances() { for (const tag of Object.keys(this.orderedLists)) { const definedSort = this.getTagSorting(tag); const definedOrder = this.getListOrder(tag); - const storedSort = this.getStoredTagSorting(tag); - const storedOrder = this.getStoredListOrder(tag); - - const tagSort = storedSort ? storedSort : (definedSort ? definedSort : defaultSort); - const listOrder = storedOrder ? storedOrder : (definedOrder ? definedOrder : defaultOrder); + const tagSort = this.calculateTagSorting(tag); + const listOrder = this.calculateListOrder(tag); if (tagSort !== definedSort) { await this.setTagSorting(tag, tagSort); @@ -378,8 +417,8 @@ export class RoomListStore2 extends AsyncStore { const sorts: ITagSortingMap = {}; const orders: IListOrderingMap = {}; for (const tagId of OrderedDefaultTagIDs) { - sorts[tagId] = this.getStoredTagSorting(tagId) || SortAlgorithm.Alphabetic; - orders[tagId] = this.getStoredListOrder(tagId) || ListAlgorithm.Natural; + sorts[tagId] = this.calculateTagSorting(tagId); + orders[tagId] = this.calculateListOrder(tagId); } if (this.state.tagsEnabled) { diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index d5f2ed0053..91d156d0d9 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -109,6 +109,7 @@ export class Algorithm extends EventEmitter { } public getTagSorting(tagId: TagID): SortAlgorithm { + if (!this.sortAlgorithms) return null; return this.sortAlgorithms[tagId]; } @@ -125,6 +126,7 @@ export class Algorithm extends EventEmitter { } public getListOrdering(tagId: TagID): ListAlgorithm { + if (!this.listAlgorithms) return null; return this.listAlgorithms[tagId]; } From 774e32ecf0dd3f36b467b82df4ad377c6aa924c4 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 7 Jul 2020 16:16:46 -0600 Subject: [PATCH 26/42] Fix DM handling in new room list --- src/stores/room-list/algorithms/Algorithm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 361f42eac5..8c7bbc8615 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -524,7 +524,7 @@ export class Algorithm extends EventEmitter { } if (!inTag) { - if (DMRoomMap.getUserIdForRoomId(room.roomId)) { + if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { newTags[DefaultTagID.DM].push(room); } else { newTags[DefaultTagID.Untagged].push(room); From 92dec8ddd81b326558a9032bce253138d3fcfd52 Mon Sep 17 00:00:00 2001 From: Jorik Schellekens Date: Wed, 8 Jul 2020 00:16:24 +0100 Subject: [PATCH 27/42] Fix gaps --- res/css/views/rooms/_RoomSublist2.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss index 0e76152f86..a8de6dd409 100644 --- a/res/css/views/rooms/_RoomSublist2.scss +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -203,15 +203,15 @@ limitations under the License. // Update the render() function for RoomSublist2 if these change // Update the ListLayout class for minVisibleTiles if these change. // - // At 24px high and 8px padding on the top this equates to 0.65 of + // At 28px high and 8px padding on the top this equates to 0.73 of // a tile due to how the padding calculations work. - height: 24px; + height: 28px; padding-top: 8px; // We force this to the bottom so it will overlap rooms as needed. // We account for the space it takes up (24px) in the code through padding. position: absolute; - bottom: 4px; // the height of the resize handle + bottom: 0; // the height of the resize handle left: 0; right: 0; From 0906da01baa1724df28814afc34d45d9e4fbeca9 Mon Sep 17 00:00:00 2001 From: Jorik Schellekens Date: Wed, 8 Jul 2020 00:18:58 +0100 Subject: [PATCH 28/42] Fix gaps --- res/css/views/rooms/_RoomSublist2.scss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss index a8de6dd409..d08bc09031 100644 --- a/res/css/views/rooms/_RoomSublist2.scss +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -203,10 +203,11 @@ limitations under the License. // Update the render() function for RoomSublist2 if these change // Update the ListLayout class for minVisibleTiles if these change. // - // At 28px high and 8px padding on the top this equates to 0.73 of + // At 24px high, 8px padding on the top and 4px padding on the bottom this equates to 0.73 of // a tile due to how the padding calculations work. - height: 28px; + height: 24px; padding-top: 8px; + padding-bottom: 4px; // We force this to the bottom so it will overlap rooms as needed. // We account for the space it takes up (24px) in the code through padding. From 70c1bf3e5c1e305ed177b0b28194e66d51d0ee8b Mon Sep 17 00:00:00 2001 From: Jorik Schellekens Date: Wed, 8 Jul 2020 00:25:43 +0100 Subject: [PATCH 29/42] Focus room filter on openSearch --- src/components/structures/RoomSearch.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 15f3bd5b54..861549bef4 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -81,6 +81,7 @@ export default class RoomSearch extends React.PureComponent { private openSearch = () => { defaultDispatcher.dispatch({action: "show_left_panel"}); + defaultDispatcher.dispatch({action: "focus_room_filter"}) }; private onChange = () => { From 8679d90703b6e3b62f2e3ba14db54f06ce13c80f Mon Sep 17 00:00:00 2001 From: Jorik Schellekens Date: Wed, 8 Jul 2020 00:27:29 +0100 Subject: [PATCH 30/42] lint semi --- src/components/structures/RoomSearch.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 861549bef4..bb82ab8f63 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -81,7 +81,7 @@ export default class RoomSearch extends React.PureComponent { private openSearch = () => { defaultDispatcher.dispatch({action: "show_left_panel"}); - defaultDispatcher.dispatch({action: "focus_room_filter"}) + defaultDispatcher.dispatch({action: "focus_room_filter"}); }; private onChange = () => { From 15b6a273c9fdc1c086ad5db845eaf0bf6e53a905 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 7 Jul 2020 19:36:26 -0600 Subject: [PATCH 31/42] Swap out the resizer lib for something more stable react-resizer appears to be okay at tracking state, but it often desyncs from reality. re-resizer is more maintained and more broadly used (160k downloads vs 110k), and appears to generally do a better job of tracking the cursor. The new library has some oddities though, such as deltas, touch support (hence the polyfill), and calling handles "Enable". For https://github.com/vector-im/riot-web/issues/14022 --- package.json | 2 +- res/css/views/rooms/_RoomSublist2.scss | 16 ++--- src/@types/polyfill.ts | 36 +++++++++++ src/components/views/rooms/RoomSublist2.tsx | 68 +++++++++++++++------ src/stores/room-list/ListLayout.ts | 7 ++- yarn.lock | 32 +++++----- 6 files changed, 114 insertions(+), 47 deletions(-) create mode 100644 src/@types/polyfill.ts diff --git a/package.json b/package.json index 3fd7703afb..608bc42a7f 100644 --- a/package.json +++ b/package.json @@ -89,11 +89,11 @@ "prop-types": "^15.5.8", "qrcode": "^1.4.4", "qs": "^6.6.0", + "re-resizable": "^6.5.2", "react": "^16.9.0", "react-beautiful-dnd": "^4.0.1", "react-dom": "^16.9.0", "react-focus-lock": "^2.2.1", - "react-resizable": "^1.10.1", "react-transition-group": "^4.4.1", "resize-observer-polyfill": "^1.5.0", "sanitize-html": "^1.18.4", diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss index d08bc09031..e9149ee5e6 100644 --- a/res/css/views/rooms/_RoomSublist2.scss +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -254,24 +254,26 @@ limitations under the License. // Class name comes from the ResizableBox component // The hover state needs to use the whole sublist, not just the resizable box, // so that selector is below and one level higher. - .react-resizable-handle { + .mx_RoomSublist2_resizerHandle { cursor: ns-resize; border-radius: 3px; - // Update RESIZE_HANDLE_HEIGHT if this changes - height: 4px; + // Override styles from library + width: unset !important; + height: 4px !important; // Update RESIZE_HANDLE_HEIGHT if this changes // This is positioned directly below the 'show more' button. position: absolute; - bottom: 0; + bottom: 0 !important; // override from library // Together, these make the bar 64px wide - left: calc(50% - 32px); - right: calc(50% - 32px); + // These are also overridden from the library + left: calc(50% - 32px) !important; + right: calc(50% - 32px) !important; } &:hover, &.mx_RoomSublist2_hasMenuOpen { - .react-resizable-handle { + .mx_RoomSublist2_resizerHandle { opacity: 0.8; background-color: $primary-fg-color; } diff --git a/src/@types/polyfill.ts b/src/@types/polyfill.ts new file mode 100644 index 0000000000..816df7946d --- /dev/null +++ b/src/@types/polyfill.ts @@ -0,0 +1,36 @@ +/* +Copyright 2020 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. +*/ + +export function polyfillTouchEvent() { + // Firefox doesn't have touch events, so create a fake one we can rely on lying about. + if (!window.TouchEvent) { + // We have no intention of actually using this, so just lie. + window.TouchEvent = class TouchEvent extends UIEvent { + public get altKey(): boolean { return false; } + public get changedTouches(): any { return []; } + public get ctrlKey(): boolean { return false; } + public get metaKey(): boolean { return false; } + public get shiftKey(): boolean { return false; } + public get targetTouches(): any { return []; } + public get touches(): any { return []; } + public get rotation(): number { return 0.0; } + public get scale(): number { return 0.0; } + constructor(eventType: string, params?: any) { + super(eventType, params); + } + }; + } +} diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index eefd29f0b7..252e5f562b 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -24,7 +24,6 @@ import {RovingAccessibleButton, RovingTabIndexWrapper} from "../../../accessibil import { _t } from "../../../languageHandler"; import AccessibleButton from "../../views/elements/AccessibleButton"; import RoomTile2 from "./RoomTile2"; -import { ResizableBox, ResizeCallbackData } from "react-resizable"; import { ListLayout } from "../../../stores/room-list/ListLayout"; import { ContextMenu, @@ -40,7 +39,9 @@ import NotificationBadge from "./NotificationBadge"; import { ListNotificationState } from "../../../stores/notifications/ListNotificationState"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { Key } from "../../../Keyboard"; -import StyledCheckbox from "../elements/StyledCheckbox"; +import { Enable, Resizable } from "re-resizable"; +import { Direction } from "re-resizable/lib/resizer"; +import { polyfillTouchEvent } from "../../../@types/polyfill"; // 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 @@ -58,6 +59,9 @@ const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS const MAX_PADDING_HEIGHT = SHOW_N_BUTTON_HEIGHT + RESIZE_HANDLE_HEIGHT; +// HACK: We really shouldn't have to do this. +polyfillTouchEvent(); + interface IProps { forRooms: boolean; rooms?: Room[]; @@ -124,10 +128,25 @@ export default class RoomSublist2 extends React.Component { if (this.props.onAddRoom) this.props.onAddRoom(); }; - private onResize = (e: React.MouseEvent, data: ResizeCallbackData) => { - const direction = e.movementY < 0 ? -1 : +1; - const tileDiff = this.props.layout.pixelsToTiles(Math.abs(e.movementY)) * direction; - this.props.layout.setVisibleTilesWithin(tileDiff, this.numTiles); + private onResize = ( + e: MouseEvent | TouchEvent, + travelDirection: Direction, + refToElement: HTMLDivElement, + delta: { width: number, height: number }, // TODO: Use NumberSize from re-resizer when it is exposed + ) => { + // Do some sanity checks, but in reality we shouldn't need these. + if (travelDirection !== "bottom") return; + if (delta.height === 0) return; // something went wrong, so just ignore it. + + // NOTE: the movement in the MouseEvent (not present on a TouchEvent) is inaccurate + // for our purposes. The delta provided by the library is also a change *from when + // resizing started*, meaning it is fairly useless for us. This is why we just use + // the client height and run with it. + + const heightBefore = this.props.layout.visibleTiles; + const heightInTiles = this.props.layout.pixelsToTiles(refToElement.clientHeight); + this.props.layout.setVisibleTilesWithin(heightInTiles, this.numTiles); + if (heightBefore === this.props.layout.visibleTiles) return; // no-op this.forceUpdate(); // because the layout doesn't trigger a re-render }; @@ -556,9 +575,19 @@ export default class RoomSublist2 extends React.Component { } // Figure out if we need a handle - let handles = ['s']; + const handles: Enable = { + bottom: true, // the only one we need, but the others must be explicitly false + bottomLeft: false, + bottomRight: false, + left: false, + right: false, + top: false, + topLeft: false, + topRight: false, + }; if (layout.visibleTiles >= this.numTiles && this.numTiles <= layout.minVisibleTiles) { - handles = []; // no handles, we're at a minimum + // we're at a minimum, don't have a bottom handle + handles.bottom = false; } // We have to account for padding so we can accommodate a 'show more' button and @@ -582,22 +611,25 @@ export default class RoomSublist2 extends React.Component { const tilesWithoutPadding = Math.min(relativeTiles, layout.visibleTiles); const tilesPx = layout.calculateTilesToPixelsMin(relativeTiles, tilesWithoutPadding, padding); + const dimensions = { + height: tilesPx, + }; content = ( - {visibleTiles} {showNButton} - + ); } diff --git a/src/stores/room-list/ListLayout.ts b/src/stores/room-list/ListLayout.ts index f31e92b8ae..1e7d8f0763 100644 --- a/src/stores/room-list/ListLayout.ts +++ b/src/stores/room-list/ListLayout.ts @@ -89,11 +89,12 @@ export class ListLayout { return 5 + RESIZER_BOX_FACTOR; } - public setVisibleTilesWithin(diff: number, maxPossible: number) { + public setVisibleTilesWithin(newVal: number, maxPossible: number) { + maxPossible = maxPossible + RESIZER_BOX_FACTOR; if (this.visibleTiles > maxPossible) { - this.visibleTiles = maxPossible + diff; + this.visibleTiles = maxPossible; } else { - this.visibleTiles += diff; + this.visibleTiles = newVal; } } diff --git a/yarn.lock b/yarn.lock index d8106febab..80158a89e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2499,7 +2499,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@^2.1.2, classnames@^2.2.5: +classnames@^2.1.2: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== @@ -3779,6 +3779,11 @@ fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= +fast-memoize@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/fast-memoize/-/fast-memoize-2.5.2.tgz#79e3bb6a4ec867ea40ba0e7146816f6cdce9b57e" + integrity sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw== + fb-watchman@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.1.tgz#fc84fb39d2709cf3ff6d743706157bb5708a8a85" @@ -6882,7 +6887,7 @@ prop-types-exact@^1.2.0: object.assign "^4.1.0" reflect.ownkeys "^0.2.0" -prop-types@15.x, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -7053,6 +7058,13 @@ rc@1.2.8, rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" +re-resizable@^6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.5.2.tgz#7eb1928c673285d4dcf654211e47acb9a3801c3e" + integrity sha512-Pjo3ydkr/meTr6j3YZqyv+9fRS5UNOj5SaAI06gHFQ35BnpsZKmwNvupCnbo11gjQ1I62Uy+UzlHLO9xPQEuWQ== + dependencies: + fast-memoize "^2.5.1" + react-beautiful-dnd@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-4.0.1.tgz#3b0a49bf6be75af351176c904f012611dd292b81" @@ -7086,14 +7098,6 @@ react-dom@^16.9.0: prop-types "^15.6.2" scheduler "^0.19.1" -react-draggable@^4.0.3: - version "4.4.2" - resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.4.2.tgz#f3cefecee25f467f865144cda0d066e5f05f94a0" - integrity sha512-zLQs4R4bnBCGnCVTZiD8hPsHtkiJxgMpGDlRESM+EHQo8ysXhKJ2GKdJ8UxxLJdRVceX1j19jy+hQS2wHislPQ== - dependencies: - classnames "^2.2.5" - prop-types "^15.6.0" - react-focus-lock@^2.2.1: version "2.3.1" resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.3.1.tgz#9d5d85899773609c7eefa4fc54fff6a0f5f2fc47" @@ -7138,14 +7142,6 @@ react-redux@^5.0.6: react-is "^16.6.0" react-lifecycles-compat "^3.0.0" -react-resizable@^1.10.1: - version "1.10.1" - resolved "https://registry.yarnpkg.com/react-resizable/-/react-resizable-1.10.1.tgz#f0c2cf1d83b3470b87676ce6d6b02bbe3f4d8cd4" - integrity sha512-Jd/bKOKx6+19NwC4/aMLRu/J9/krfxlDnElP41Oc+oLiUWs/zwV1S9yBfBZRnqAwQb6vQ/HRSk3bsSWGSgVbpw== - dependencies: - prop-types "15.x" - react-draggable "^4.0.3" - react-test-renderer@^16.0.0-0, react-test-renderer@^16.9.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.13.1.tgz#de25ea358d9012606de51e012d9742e7f0deabc1" 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 32/42] 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 33/42] 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 34/42] 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 35/42] 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 36/42] 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 37/42] 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 38/42] 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 39/42] 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 { From 9b0eeae2eb74352dc509a83039cb038c3e6047b5 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 8 Jul 2020 07:51:04 -0600 Subject: [PATCH 40/42] Clarify who is meant to use the polyfill --- src/@types/polyfill.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/@types/polyfill.ts b/src/@types/polyfill.ts index 816df7946d..3ce05d9c2f 100644 --- a/src/@types/polyfill.ts +++ b/src/@types/polyfill.ts @@ -14,8 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +// This is intended to fix re-resizer because of its unguarded `instanceof TouchEvent` checks. export function polyfillTouchEvent() { - // Firefox doesn't have touch events, so create a fake one we can rely on lying about. + // Firefox doesn't have touch events without touch devices being present, so create a fake + // one we can rely on lying about. if (!window.TouchEvent) { // We have no intention of actually using this, so just lie. window.TouchEvent = class TouchEvent extends UIEvent { From b2abe61fc5fb56086aa73a6f44936f7caa5dc3c9 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 8 Jul 2020 07:51:48 -0600 Subject: [PATCH 41/42] clarify which NumberSize to use --- 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 252e5f562b..9e4e5de0bb 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -132,7 +132,7 @@ export default class RoomSublist2 extends React.Component { e: MouseEvent | TouchEvent, travelDirection: Direction, refToElement: HTMLDivElement, - delta: { width: number, height: number }, // TODO: Use NumberSize from re-resizer when it is exposed + delta: { width: number, height: number }, // TODO: Use re-resizer's NumberSize when it is exposed as the type ) => { // Do some sanity checks, but in reality we shouldn't need these. if (travelDirection !== "bottom") return; From b4f3b8ab11210f91a3a126985b3b0be06c044cd7 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Wed, 8 Jul 2020 07:53:55 -0600 Subject: [PATCH 42/42] Use the right variables when detecting max height --- src/stores/room-list/ListLayout.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/room-list/ListLayout.ts b/src/stores/room-list/ListLayout.ts index 1e7d8f0763..99674fe74f 100644 --- a/src/stores/room-list/ListLayout.ts +++ b/src/stores/room-list/ListLayout.ts @@ -91,7 +91,7 @@ export class ListLayout { public setVisibleTilesWithin(newVal: number, maxPossible: number) { maxPossible = maxPossible + RESIZER_BOX_FACTOR; - if (this.visibleTiles > maxPossible) { + if (newVal > maxPossible) { this.visibleTiles = maxPossible; } else { this.visibleTiles = newVal;