Merge remote-tracking branch 'origin/develop' into travis/room-list/resizable

This commit is contained in:
Travis Ralston 2020-07-08 07:55:10 -06:00
commit 3912f2d21c
22 changed files with 696 additions and 351 deletions

View file

@ -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",

View file

@ -121,6 +121,21 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
}
}
.mx_LeftPanel2_roomListWrapper {
display: flex;
flex-grow: 1;
overflow: hidden;
min-height: 0;
&.stickyBottom {
padding-bottom: 32px;
}
&.stickyTop {
padding-top: 32px;
}
}
.mx_LeftPanel2_actualRoomListContainer {
flex-grow: 1; // fill the available space
overflow-y: auto;

View file

@ -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;

View file

@ -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;
@ -165,6 +169,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');

View file

@ -17,3 +17,4 @@ limitations under the License.
// Based on https://stackoverflow.com/a/53229857/3532235
export type Without<T, U> = {[P in Exclude<keyof T, keyof U>] ? : never};
export type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };

View file

@ -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;
@ -122,12 +119,19 @@ 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 <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
}
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.
@ -136,7 +140,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 +151,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 +166,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 +180,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 +190,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 +224,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 +251,16 @@ const sanitizeHtmlParams = {
};
// this is the same as the above except with less rewriting
const composerSanitizeHtmlParams = Object.assign({}, sanitizeHtmlParams);
composerSanitizeHtmlParams.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<T extends React.ReactNode> {
constructor(public highlightClass: string, public highlightLink: string) {
}
/**
@ -270,47 +274,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)];
}
}
class HtmlHighlighter extends BaseHighlighter {
protected abstract processSnippet(snippet: string, highlight: boolean): T;
}
class HtmlHighlighter extends BaseHighlighter<string> {
/* highlight the given snippet if required
*
* snippet: content of the span; must have been sanitised
@ -318,28 +324,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 = "<span class=\""+this.highlightClass+"\">"
+ snippet + "</span>";
let span = `<span class="${this.highlightClass}">${snippet}</span>`;
if (this.highlightLink) {
span = "<a href=\""+encodeURI(this.highlightLink)+"\">"
+span+"</a>";
span = `<a href="${encodeURI(this.highlightLink)}">${span}</a>`;
}
return span;
}
}
class TextHighlighter extends BaseHighlighter {
constructor(highlightClass, highlightLink) {
super(highlightClass, highlightLink);
this._key = 0;
}
class TextHighlighter extends BaseHighlighter<React.ReactNode> {
private key = 0;
/* create a <span> node to hold the given content
*
@ -348,11 +349,10 @@ 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 =
<span key={key} className={highlight ? this.highlightClass : null}>
let node = <span key={key} className={highlight ? this.highlightClass : null}>
{ snippet }
</span>;
@ -364,6 +364,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<any>;
}
/* turn a matrix event body into html
*
@ -378,7 +392,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 +401,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. foo<span/>bar won't get highlighted
@ -471,7 +485,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 +496,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 +507,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 +518,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":

View file

@ -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 <ContextMenu />
export const ContextMenuButton: React.FC<IProps> = ({
label,
isExpanded,
children,
onClick,
onContextMenu,
...props
}) => {
return (
<AccessibleButton
{...props}
onClick={onClick}
onContextMenu={onContextMenu || onClick}
title={label}
aria-label={label}
aria-haspopup={true}
aria-expanded={isExpanded}
>
{ children }
</AccessibleButton>
);
};

View file

@ -0,0 +1,30 @@
/*
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<HTMLDivElement> {
label: string;
}
// Semantic component for representing a role=group for grouping menu radios/checkboxes
export const MenuGroup: React.FC<IProps> = ({children, label, ...props}) => {
return <div {...props} role="group" aria-label={label}>
{ children }
</div>;
};

View file

@ -0,0 +1,35 @@
/*
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 from "../../components/views/elements/AccessibleButton";
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
label?: string;
}
// Semantic component for representing a role=menuitem
export const MenuItem: React.FC<IProps> = ({children, label, ...props}) => {
return (
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={label}>
{ children }
</AccessibleButton>
);
};

View file

@ -0,0 +1,43 @@
/*
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 from "../../components/views/elements/AccessibleButton";
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
label?: string;
active: boolean;
}
// Semantic component for representing a role=menuitemcheckbox
export const MenuItemCheckbox: React.FC<IProps> = ({children, label, active, disabled, ...props}) => {
return (
<AccessibleButton
{...props}
role="menuitemcheckbox"
aria-checked={active}
aria-disabled={disabled}
disabled={disabled}
tabIndex={-1}
aria-label={label}
>
{ children }
</AccessibleButton>
);
};

View file

@ -0,0 +1,43 @@
/*
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 from "../../components/views/elements/AccessibleButton";
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
label?: string;
active: boolean;
}
// Semantic component for representing a role=menuitemradio
export const MenuItemRadio: React.FC<IProps> = ({children, label, active, disabled, ...props}) => {
return (
<AccessibleButton
{...props}
role="menuitemradio"
aria-checked={active}
aria-disabled={disabled}
disabled={disabled}
tabIndex={-1}
aria-label={label}
>
{ children }
</AccessibleButton>
);
};

View file

@ -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<typeof StyledCheckbox> {
label?: string;
onChange(); // we handle keyup/down ourselves so lose the ChangeEvent
onClose(): void; // gets called after onChange on Key.ENTER
}
// Semantic component for representing a styled role=menuitemcheckbox
export const StyledMenuItemCheckbox: React.FC<IProps> = ({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 (
<StyledCheckbox
{...props}
role="menuitemcheckbox"
tabIndex={-1}
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
{ children }
</StyledCheckbox>
);
};

View file

@ -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 StyledRadioButton from "../../components/views/elements/StyledRadioButton";
interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
label?: string;
onChange(); // we handle keyup/down ourselves so lose the ChangeEvent
onClose(): void; // gets called after onChange on Key.ENTER
}
// Semantic component for representing a styled role=menuitemradio
export const StyledMenuItemRadio: React.FC<IProps> = ({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 (
<StyledRadioButton
{...props}
role="menuitemradio"
tabIndex={-1}
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
{ children }
</StyledRadioButton>
);
};

View file

@ -16,15 +16,12 @@ 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 React, {CSSProperties, useRef, useState} from "react";
import ReactDOM from "react-dom";
import classNames from "classnames";
import {Key} from "../../Keyboard";
import * as sdk from "../../index";
import AccessibleButton from "../views/elements/AccessibleButton";
import StyledCheckbox from "../views/elements/StyledCheckbox";
import StyledRadioButton from "../views/elements/StyledRadioButton";
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
@ -32,8 +29,8 @@ import StyledRadioButton from "../views/elements/StyledRadioButton";
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");
@ -45,50 +42,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<IProps, IState> {
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() {
@ -96,7 +113,7 @@ export class ContextMenu extends React.Component {
this.initialFocus.focus();
}
collectContextMenuRect = (element) => {
private collectContextMenuRect = (element) => {
// We don't need to clean up when unmounting, so ignore
if (!element) return;
@ -113,7 +130,7 @@ export class ContextMenu extends React.Component {
});
};
onContextMenu = (e) => {
private onContextMenu = (e) => {
if (this.props.onFinished) {
this.props.onFinished();
@ -136,20 +153,20 @@ export class ContextMenu extends React.Component {
}
};
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 {
@ -183,25 +200,25 @@ export class ContextMenu extends React.Component {
} 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();
@ -219,16 +236,16 @@ export class ContextMenu extends React.Component {
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;
@ -241,9 +258,8 @@ export class ContextMenu extends React.Component {
}
};
renderMenu(hasBackground=this.props.hasBackground) {
const position = {};
let chevronFace = null;
protected renderMenu(hasBackground = this.props.hasBackground) {
const position: Partial<Writeable<DOMRect>> = {};
const props = this.props;
if (props.top) {
@ -252,23 +268,24 @@ export class ContextMenu extends React.Component {
position.bottom = props.bottom;
}
let chevronFace: 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;
@ -298,13 +315,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;
}
@ -335,13 +352,28 @@ export class ContextMenu extends React.Component {
let background;
if (hasBackground) {
background = (
<div className="mx_ContextualMenu_background" style={wrapperStyle} onClick={this._onFinished} onContextMenu={this.onContextMenu} />
<div
className="mx_ContextualMenu_background"
style={wrapperStyle}
onClick={this.onFinished}
onContextMenu={this.onContextMenu}
/>
);
}
return (
<div className="mx_ContextualMenu_wrapper" style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown} onContextMenu={this.onContextMenuPreventBubbling}>
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} role={this.props.managed ? "menu" : undefined}>
<div
className="mx_ContextualMenu_wrapper"
style={{...position, ...wrapperStyle}}
onKeyDown={this.onKeyDown}
onContextMenu={this.onContextMenuPreventBubbling}
>
<div
className={menuClasses}
style={menuStyle}
ref={this.collectContextMenuRect}
role={this.props.managed ? "menu" : undefined}
>
{ chevron }
{ props.children }
</div>
@ -350,195 +382,13 @@ export class ContextMenu extends React.Component {
);
}
render() {
render(): React.ReactChild {
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
}
}
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuButton = ({ label, isExpanded, children, onClick, onContextMenu, ...props }) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton
{...props}
onClick={onClick}
onContextMenu={onContextMenu || onClick}
title={label}
aria-label={label}
aria-haspopup={true}
aria-expanded={isExpanded}
>
{ children }
</AccessibleButton>
);
};
ContextMenuButton.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string,
isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open
};
// Semantic component for representing a role=menuitem
export const MenuItem = ({children, label, ...props}) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={label}>
{ children }
</AccessibleButton>
);
};
MenuItem.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string, // optional
className: PropTypes.string, // optional
onClick: PropTypes.func.isRequired,
};
// Semantic component for representing a role=group for grouping menu radios/checkboxes
export const MenuGroup = ({children, label, ...props}) => {
return <div {...props} role="group" aria-label={label}>
{ children }
</div>;
};
MenuGroup.propTypes = {
label: PropTypes.string.isRequired,
className: PropTypes.string, // optional
};
// Semantic component for representing a role=menuitemcheckbox
export const MenuItemCheckbox = ({children, label, active=false, disabled=false, ...props}) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton {...props} role="menuitemcheckbox" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
{ children }
</AccessibleButton>
);
};
MenuItemCheckbox.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string, // optional
active: PropTypes.bool.isRequired,
disabled: PropTypes.bool, // optional
className: PropTypes.string, // optional
onClick: PropTypes.func.isRequired,
};
// Semantic component for representing a styled role=menuitemcheckbox
export const StyledMenuItemCheckbox = ({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 (
<StyledCheckbox
{...props}
role="menuitemcheckbox"
aria-checked={checked}
checked={checked}
aria-disabled={disabled}
tabIndex={-1}
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
{ children }
</StyledCheckbox>
);
};
StyledMenuItemCheckbox.propTypes = {
...StyledCheckbox.propTypes,
label: PropTypes.string, // optional
checked: PropTypes.bool.isRequired,
disabled: PropTypes.bool, // optional
className: PropTypes.string, // optional
onChange: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, // gets called after onChange on Key.ENTER
};
// Semantic component for representing a role=menuitemradio
export const MenuItemRadio = ({children, label, active=false, disabled=false, ...props}) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton {...props} role="menuitemradio" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
{ children }
</AccessibleButton>
);
};
MenuItemRadio.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string, // optional
active: PropTypes.bool.isRequired,
disabled: PropTypes.bool, // optional
className: PropTypes.string, // optional
onClick: PropTypes.func.isRequired,
};
// Semantic component for representing a styled role=menuitemradio
export const StyledMenuItemRadio = ({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 (
<StyledRadioButton
{...props}
role="menuitemradio"
aria-checked={checked}
checked={checked}
aria-disabled={disabled}
tabIndex={-1}
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
{ children }
</StyledRadioButton>
);
};
StyledMenuItemRadio.propTypes = {
...StyledMenuItemRadio.propTypes,
label: PropTypes.string, // optional
checked: PropTypes.bool.isRequired,
disabled: PropTypes.bool, // optional
className: PropTypes.string, // optional
onChange: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, // gets called after onChange on Key.ENTER
};
// Placement method for <ContextMenu /> 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
@ -546,8 +396,8 @@ export const toRightOf = (elementRect, chevronOffset=12) => {
};
// Placement method for <ContextMenu /> 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;
@ -605,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";

View file

@ -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<IProps, IState> {
const bottom = rlRect.bottom;
const top = rlRect.top;
const sublists = list.querySelectorAll<HTMLDivElement>(".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;
@ -146,23 +146,27 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
const slRect = sublist.getBoundingClientRect();
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist2_stickable");
header.style.removeProperty("display"); // always clear display:none first
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 / 3)) < top) {
} 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 HEADER_HEIGHT because the sticky header is put above the scrollable area,
// into the padding of .mx_LeftPanel2_roomListWrapper,
// 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}px`;
header.style.top = `${rlRect.top - HEADER_HEIGHT}px`;
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");
@ -172,6 +176,26 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
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 < HEADER_HEIGHT) {
list.scrollTop = HEADER_HEIGHT;
}
}
// TODO: Improve header reliability: https://github.com/vector-im/riot-web/issues/14232
@ -325,6 +349,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
<aside className="mx_LeftPanel2_roomListContainer">
{this.renderHeader()}
{this.renderSearchExplore()}
<div className="mx_LeftPanel2_roomListWrapper">
<div
className={roomListClasses}
onScroll={this.onScroll}
@ -335,6 +360,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
>
{roomList}
</div>
</div>
</aside>
</div>
);

View file

@ -14,14 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import React, { 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, MenuItem} from "./ContextMenu";
import { ChevronFace, ContextMenu, ContextMenuButton, MenuItem } from "./ContextMenu";
import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog";
@ -122,7 +121,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
}
};
private onOpenMenuClick = (ev: InputEvent) => {
private onOpenMenuClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
@ -235,7 +234,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
return (
<ContextMenu
chevronFace="none"
chevronFace={ChevronFace.None}
// -20 to overlap the context menu by just over the width of the `...` icon and make it look connected
left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 20}
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}

View file

@ -64,7 +64,6 @@ export default function AccessibleButton({
className,
...restProps
}: IProps) {
const newProps: IAccessibleButtonProps = restProps;
if (!disabled) {
newProps.onClick = onClick;

View file

@ -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';
}

View file

@ -26,6 +26,7 @@ import AccessibleButton from "../../views/elements/AccessibleButton";
import RoomTile2 from "./RoomTile2";
import { ListLayout } from "../../../stores/room-list/ListLayout";
import {
ChevronFace,
ContextMenu,
ContextMenuButton,
StyledMenuItemCheckbox,
@ -39,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";
import { Enable, Resizable } from "re-resizable";
import { Direction } from "re-resizable/lib/resizer";
import { polyfillTouchEvent } from "../../../@types/polyfill";
@ -56,6 +59,7 @@ import { polyfillTouchEvent } from "../../../@types/polyfill";
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;
@ -93,6 +97,7 @@ interface IState {
export default class RoomSublist2 extends React.Component<IProps, IState> {
private headerButton = createRef<HTMLDivElement>();
private sublistRef = createRef<HTMLDivElement>();
private dispatcherRef: string;
constructor(props: IProps) {
super(props);
@ -103,6 +108,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
isResizing: false,
};
this.state.notificationState.setRooms(this.props.rooms);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
}
private get numTiles(): number {
@ -121,8 +127,29 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
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 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
}
});
}
};
private onAddRoom = (e) => {
e.stopPropagation();
if (this.props.onAddRoom) this.props.onAddRoom();
@ -180,7 +207,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
}
};
private onOpenMenuClick = (ev: InputEvent) => {
private onOpenMenuClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
@ -252,7 +279,11 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
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 {
@ -384,7 +415,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
contextMenu = (
<ContextMenu
chevronFace="none"
chevronFace={ChevronFace.None}
left={this.state.contextMenuPosition.left}
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
onFinished={this.onCloseMenu}

View file

@ -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";
@ -27,6 +27,7 @@ import { Key } from "../../../Keyboard";
import ActiveRoomObserver from "../../../ActiveRoomObserver";
import { _t } from "../../../languageHandler";
import {
ChevronFace,
ContextMenu,
ContextMenuButton,
MenuItemRadio,
@ -50,6 +51,8 @@ import { INotificationState } from "../../../stores/notifications/INotificationS
import NotificationBadge from "./NotificationBadge";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { Volume } from "../../../RoomNotifsTypes";
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
@ -87,7 +90,7 @@ const contextMenuBelow = (elementRect: PartialDOMRect) => {
// 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};
};
@ -118,6 +121,10 @@ const NotifOption: React.FC<INotifOptionProps> = ({active, onClick, iconClassNam
};
export default class RoomTile2 extends React.Component<IProps, IState> {
private dispatcherRef: string;
private roomTileRef = createRef<HTMLDivElement>();
// TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
constructor(props: IProps) {
super(props);
@ -130,6 +137,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
};
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
}
private get showContextMenu(): boolean {
@ -140,12 +148,36 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
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 = () => {
if (!this.roomTileRef.current) return;
this.roomTileRef.current.scrollIntoView({
block: "nearest",
behavior: "auto",
});
};
private onTileMouseEnter = () => {
this.setState({hover: true});
};
@ -159,7 +191,6 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
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)),
@ -170,7 +201,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
this.setState({selected: isActive});
};
private onNotificationsMenuOpenClick = (ev: InputEvent) => {
private onNotificationsMenuOpenClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
@ -181,7 +212,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
this.setState({notificationsMenuPosition: null});
};
private onGeneralMenuOpenClick = (ev: InputEvent) => {
private onGeneralMenuOpenClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
@ -477,7 +508,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
return (
<React.Fragment>
<RovingTabIndexWrapper>
<RovingTabIndexWrapper inputRef={this.roomTileRef}>
{({onFocus, isActive, ref}) =>
<AccessibleButton
onFocus={onFocus}

View file

@ -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
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});
}

View file

@ -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"