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/classnames": "^2.2.10",
"@types/counterpart": "^0.18.1", "@types/counterpart": "^0.18.1",
"@types/flux": "^3.1.9", "@types/flux": "^3.1.9",
"@types/linkifyjs": "^2.1.3",
"@types/lodash": "^4.14.152", "@types/lodash": "^4.14.152",
"@types/modernizr": "^3.5.3", "@types/modernizr": "^3.5.3",
"@types/node": "^12.12.41", "@types/node": "^12.12.41",
@ -129,6 +130,7 @@
"@types/react": "^16.9", "@types/react": "^16.9",
"@types/react-dom": "^16.9.8", "@types/react-dom": "^16.9.8",
"@types/react-transition-group": "^4.4.0", "@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "^1.23.3",
"@types/zxcvbn": "^4.4.0", "@types/zxcvbn": "^4.4.0",
"babel-eslint": "^10.0.3", "babel-eslint": "^10.0.3",
"babel-jest": "^24.9.0", "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 { .mx_LeftPanel2_actualRoomListContainer {
flex-grow: 1; // fill the available space flex-grow: 1; // fill the available space
overflow-y: auto; overflow-y: auto;

View file

@ -54,9 +54,6 @@ limitations under the License.
max-width: 100%; max-width: 100%;
z-index: 2; // Prioritize headers in the visible list over sticky ones 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 // Create a flexbox to make ordering easy
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -21,6 +21,10 @@ limitations under the License.
margin-bottom: 4px; margin-bottom: 4px;
padding: 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 // The tile is also a flexbox row itself
display: flex; 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 // We use these both in context menus and the room tiles
.mx_RoomTile2_iconBell::before { .mx_RoomTile2_iconBell::before {
mask-image: url('$(res)/img/feather-customised/bell.svg'); 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 // Based on https://stackoverflow.com/a/53229857/3532235
export type Without<T, U> = {[P in Exclude<keyof T, keyof U>] ? : never}; 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 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. limitations under the License.
*/ */
'use strict';
import ReplyThread from "./components/views/elements/ReplyThread";
import React from 'react'; import React from 'react';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
import * as linkify from 'linkifyjs'; import * as linkify from 'linkifyjs';
@ -28,12 +24,13 @@ import linkifyMatrix from './linkify-matrix';
import _linkifyElement from 'linkifyjs/element'; import _linkifyElement from 'linkifyjs/element';
import _linkifyString from 'linkifyjs/string'; import _linkifyString from 'linkifyjs/string';
import classNames from 'classnames'; import classNames from 'classnames';
import {MatrixClientPeg} from './MatrixClientPeg'; import EMOJIBASE_REGEX from 'emojibase-regex';
import url from 'url'; import url from 'url';
import EMOJIBASE_REGEX from 'emojibase-regex'; import {MatrixClientPeg} from './MatrixClientPeg';
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji"; import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
import ReplyThread from "./components/views/elements/ReplyThread";
linkifyMatrix(linkify); linkifyMatrix(linkify);
@ -64,7 +61,7 @@ const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
* need emojification. * need emojification.
* unicodeToImage uses this function. * unicodeToImage uses this function.
*/ */
function mightContainEmoji(str) { function mightContainEmoji(str: string) {
return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str); return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
} }
@ -74,7 +71,7 @@ function mightContainEmoji(str) {
* @param {String} char The emoji character * @param {String} char The emoji character
* @return {String} The shortcode (such as :thumbup:) * @return {String} The shortcode (such as :thumbup:)
*/ */
export function unicodeToShortcode(char) { export function unicodeToShortcode(char: string) {
const data = getEmojiFromUnicode(char); const data = getEmojiFromUnicode(char);
return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : ''); return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : '');
} }
@ -85,7 +82,7 @@ export function unicodeToShortcode(char) {
* @param {String} shortcode The shortcode (such as :thumbup:) * @param {String} shortcode The shortcode (such as :thumbup:)
* @return {String} The emoji character; null if none exists * @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); shortcode = shortcode.slice(1, shortcode.length - 1);
const data = SHORTCODE_TO_EMOJI.get(shortcode); const data = SHORTCODE_TO_EMOJI.get(shortcode);
return data ? data.unicode : null; 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 * Given an untrusted HTML string, return a React node with an sanitized version
* of that HTML. * of that HTML.
*/ */
export function sanitizedHtmlNode(insaneHtml) { export function sanitizedHtmlNode(insaneHtml: string) {
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />; 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 * Tests if a URL from an untrusted source may be safely put into the DOM
* The biggest threat here is javascript: URIs. * The biggest threat here is javascript: URIs.
@ -136,7 +140,7 @@ export function sanitizedHtmlNode(insaneHtml) {
* other places we need to sanitise URLs. * other places we need to sanitise URLs.
* @return true if permitted, otherwise false * @return true if permitted, otherwise false
*/ */
export function isUrlPermitted(inputUrl) { export function isUrlPermitted(inputUrl: string) {
try { try {
const parsed = url.parse(inputUrl); const parsed = url.parse(inputUrl);
if (!parsed.protocol) return false; 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 // add blank targets to all hyperlinks except vector URLs
'a': function(tagName, attribs) { 'a': function(tagName: string, attribs: sanitizeHtml.Attributes) {
if (attribs.href) { if (attribs.href) {
attribs.target = '_blank'; // by default attribs.target = '_blank'; // by default
@ -162,7 +166,7 @@ const transformTags = { // custom to matrix
attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/ attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/
return { tagName, attribs }; 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 // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and // because transformTags is used _before_ we filter by allowedSchemesByTag and
// we don't want to allow images with `https?` `src`s. // we don't want to allow images with `https?` `src`s.
@ -176,7 +180,7 @@ const transformTags = { // custom to matrix
); );
return { tagName, attribs }; return { tagName, attribs };
}, },
'code': function(tagName, attribs) { 'code': function(tagName: string, attribs: sanitizeHtml.Attributes) {
if (typeof attribs.class !== 'undefined') { if (typeof attribs.class !== 'undefined') {
// Filter out all classes other than ones starting with language- for syntax highlighting. // Filter out all classes other than ones starting with language- for syntax highlighting.
const classes = attribs.class.split(/\s/).filter(function(cl) { const classes = attribs.class.split(/\s/).filter(function(cl) {
@ -186,7 +190,7 @@ const transformTags = { // custom to matrix
} }
return { tagName, attribs }; 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 // Delete any style previously assigned, style is an allowedTag for font and span
// because attributes are stripped after transforming // because attributes are stripped after transforming
delete attribs.style; delete attribs.style;
@ -220,7 +224,7 @@ const transformTags = { // custom to matrix
}, },
}; };
const sanitizeHtmlParams = { const sanitizeHtmlParams: sanitizeHtml.IOptions = {
allowedTags: [ allowedTags: [
'font', // custom to matrix for IRC-style font coloring 'font', // custom to matrix for IRC-style font coloring
'del', // for markdown 'del', // for markdown
@ -247,16 +251,16 @@ const sanitizeHtmlParams = {
}; };
// this is the same as the above except with less rewriting // this is the same as the above except with less rewriting
const composerSanitizeHtmlParams = Object.assign({}, sanitizeHtmlParams); const composerSanitizeHtmlParams: sanitizeHtml.IOptions = {
composerSanitizeHtmlParams.transformTags = { ...sanitizeHtmlParams,
transformTags: {
'code': transformTags['code'], 'code': transformTags['code'],
'*': transformTags['*'], '*': transformTags['*'],
},
}; };
class BaseHighlighter { abstract class BaseHighlighter<T extends React.ReactNode> {
constructor(highlightClass, highlightLink) { constructor(public highlightClass: string, public highlightLink: string) {
this.highlightClass = highlightClass;
this.highlightLink = highlightLink;
} }
/** /**
@ -270,47 +274,49 @@ class BaseHighlighter {
* returns a list of results (strings for HtmlHighligher, react nodes for * returns a list of results (strings for HtmlHighligher, react nodes for
* TextHighlighter). * TextHighlighter).
*/ */
applyHighlights(safeSnippet, safeHighlights) { public applyHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
let lastOffset = 0; let lastOffset = 0;
let offset; let offset;
let nodes = []; let nodes: T[] = [];
const safeHighlight = safeHighlights[0]; const safeHighlight = safeHighlights[0];
while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) { while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) {
// handle preamble // handle preamble
if (offset > lastOffset) { if (offset > lastOffset) {
var subSnippet = safeSnippet.substring(lastOffset, offset); const subSnippet = safeSnippet.substring(lastOffset, offset);
nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights)); nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
} }
// do highlight. use the original string rather than safeHighlight // do highlight. use the original string rather than safeHighlight
// to preserve the original casing. // to preserve the original casing.
const endOffset = offset + safeHighlight.length; 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; lastOffset = endOffset;
} }
// handle postamble // handle postamble
if (lastOffset !== safeSnippet.length) { if (lastOffset !== safeSnippet.length) {
subSnippet = safeSnippet.substring(lastOffset, undefined); const subSnippet = safeSnippet.substring(lastOffset, undefined);
nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights)); nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
} }
return nodes; return nodes;
} }
_applySubHighlights(safeSnippet, safeHighlights) { private applySubHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
if (safeHighlights[1]) { if (safeHighlights[1]) {
// recurse into this range to check for the next set of highlight matches // recurse into this range to check for the next set of highlight matches
return this.applyHighlights(safeSnippet, safeHighlights.slice(1)); return this.applyHighlights(safeSnippet, safeHighlights.slice(1));
} else { } else {
// no more highlights to be found, just return the unhighlighted string // 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 /* highlight the given snippet if required
* *
* snippet: content of the span; must have been sanitised * snippet: content of the span; must have been sanitised
@ -318,28 +324,23 @@ class HtmlHighlighter extends BaseHighlighter {
* *
* returns an HTML string * returns an HTML string
*/ */
_processSnippet(snippet, highlight) { protected processSnippet(snippet: string, highlight: boolean): string {
if (!highlight) { if (!highlight) {
// nothing required here // nothing required here
return snippet; return snippet;
} }
let span = "<span class=\""+this.highlightClass+"\">" let span = `<span class="${this.highlightClass}">${snippet}</span>`;
+ snippet + "</span>";
if (this.highlightLink) { if (this.highlightLink) {
span = "<a href=\""+encodeURI(this.highlightLink)+"\">" span = `<a href="${encodeURI(this.highlightLink)}">${span}</a>`;
+span+"</a>";
} }
return span; return span;
} }
} }
class TextHighlighter extends BaseHighlighter { class TextHighlighter extends BaseHighlighter<React.ReactNode> {
constructor(highlightClass, highlightLink) { private key = 0;
super(highlightClass, highlightLink);
this._key = 0;
}
/* create a <span> node to hold the given content /* create a <span> node to hold the given content
* *
@ -348,11 +349,10 @@ class TextHighlighter extends BaseHighlighter {
* *
* returns a React node * returns a React node
*/ */
_processSnippet(snippet, highlight) { protected processSnippet(snippet: string, highlight: boolean): React.ReactNode {
const key = this._key++; const key = this.key++;
let node = let node = <span key={key} className={highlight ? this.highlightClass : null}>
<span key={key} className={highlight ? this.highlightClass : null}>
{ snippet } { snippet }
</span>; </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 /* 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.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) * 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; const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
let bodyHasEmoji = false; let bodyHasEmoji = false;
@ -387,9 +401,9 @@ export function bodyToHtml(content, highlights, opts={}) {
sanitizeParams = composerSanitizeHtmlParams; sanitizeParams = composerSanitizeHtmlParams;
} }
let strippedBody; let strippedBody: string;
let safeBody; let safeBody: string;
let isDisplayedWithHtml; let isDisplayedWithHtml: boolean;
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying // 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 // 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 // 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 * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string} Linkified string * @returns {string} Linkified string
*/ */
export function linkifyString(str, options = linkifyMatrix.options) { export function linkifyString(str: string, options = linkifyMatrix.options) {
return _linkifyString(str, 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 * @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options
* @returns {object} * @returns {object}
*/ */
export function linkifyElement(element, options = linkifyMatrix.options) { export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options) {
return _linkifyElement(element, 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 * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string} * @returns {string}
*/ */
export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.options) { export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) {
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams); return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
} }
@ -504,7 +518,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.option
* @param {Node} node * @param {Node} node
* @returns {bool} * @returns {bool}
*/ */
export function checkBlockNode(node) { export function checkBlockNode(node: Node) {
switch (node.nodeName) { switch (node.nodeName) {
case "H1": case "H1":
case "H2": 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. limitations under the License.
*/ */
import React, {useRef, useState} from 'react'; import React, {CSSProperties, useRef, useState} from "react";
import ReactDOM from 'react-dom'; import ReactDOM from "react-dom";
import PropTypes from 'prop-types'; import classNames from "classnames";
import classNames from 'classnames';
import {Key} from "../../Keyboard"; import {Key} from "../../Keyboard";
import * as sdk from "../../index"; import {Writeable} from "../../@types/common";
import AccessibleButton from "../views/elements/AccessibleButton";
import StyledCheckbox from "../views/elements/StyledCheckbox";
import StyledRadioButton from "../views/elements/StyledRadioButton";
// Shamelessly ripped off Modal.js. There's probably a better way // Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and // 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"; const ContextualMenuContainerId = "mx_ContextualMenu_Container";
function getOrCreateContainer() { function getOrCreateContainer(): HTMLDivElement {
let container = document.getElementById(ContextualMenuContainerId); let container = document.getElementById(ContextualMenuContainerId) as HTMLDivElement;
if (!container) { if (!container) {
container = document.createElement("div"); container = document.createElement("div");
@ -45,50 +42,70 @@ function getOrCreateContainer() {
} }
const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]); 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 // Generic ContextMenu Portal wrapper
// all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1} // 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. // this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
export class ContextMenu extends React.Component { export class ContextMenu extends React.PureComponent<IProps, IState> {
static propTypes = { private initialFocus: HTMLElement;
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
};
static defaultProps = { static defaultProps = {
hasBackground: true, hasBackground: true,
managed: true, managed: true,
}; };
constructor() { constructor(props, context) {
super(); super(props, context);
this.state = { this.state = {
contextMenuElem: null, contextMenuElem: null,
}; };
// persist what had focus when we got initialized so we can return it after // 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() { componentWillUnmount() {
@ -96,7 +113,7 @@ export class ContextMenu extends React.Component {
this.initialFocus.focus(); this.initialFocus.focus();
} }
collectContextMenuRect = (element) => { private collectContextMenuRect = (element) => {
// We don't need to clean up when unmounting, so ignore // We don't need to clean up when unmounting, so ignore
if (!element) return; if (!element) return;
@ -113,7 +130,7 @@ export class ContextMenu extends React.Component {
}); });
}; };
onContextMenu = (e) => { private onContextMenu = (e) => {
if (this.props.onFinished) { if (this.props.onFinished) {
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 // stop propagation so that any context menu handlers don't leak out of this context menu
// but do not inhibit the default browser menu // but do not inhibit the default browser menu
e.stopPropagation(); e.stopPropagation();
}; };
// Prevent clicks on the background from going through to the component which opened the menu. // 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.stopPropagation();
ev.preventDefault(); ev.preventDefault();
if (this.props.onFinished) this.props.onFinished(); 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? let descending = false; // are we currently descending or ascending through the DOM tree?
do { do {
@ -183,25 +200,25 @@ export class ContextMenu extends React.Component {
} while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role"))); } while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role")));
if (element) { if (element) {
element.focus(); (element as HTMLElement).focus();
} }
}; };
_onMoveFocusHomeEnd = (element, up) => { private onMoveFocusHomeEnd = (element: Element, up: boolean) => {
let results = element.querySelectorAll('[role^="menuitem"]'); let results = element.querySelectorAll('[role^="menuitem"]');
if (!results) { if (!results) {
results = element.querySelectorAll('[tab-index]'); results = element.querySelectorAll('[tab-index]');
} }
if (results && results.length) { if (results && results.length) {
if (up) { if (up) {
results[0].focus(); (results[0] as HTMLElement).focus();
} else { } 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 (!this.props.managed) {
if (ev.key === Key.ESCAPE) { if (ev.key === Key.ESCAPE) {
this.props.onFinished(); this.props.onFinished();
@ -219,16 +236,16 @@ export class ContextMenu extends React.Component {
this.props.onFinished(); this.props.onFinished();
break; break;
case Key.ARROW_UP: case Key.ARROW_UP:
this._onMoveFocus(ev.target, true); this.onMoveFocus(ev.target as Element, true);
break; break;
case Key.ARROW_DOWN: case Key.ARROW_DOWN:
this._onMoveFocus(ev.target, false); this.onMoveFocus(ev.target as Element, false);
break; break;
case Key.HOME: case Key.HOME:
this._onMoveFocusHomeEnd(this.state.contextMenuElem, true); this.onMoveFocusHomeEnd(this.state.contextMenuElem, true);
break; break;
case Key.END: case Key.END:
this._onMoveFocusHomeEnd(this.state.contextMenuElem, false); this.onMoveFocusHomeEnd(this.state.contextMenuElem, false);
break; break;
default: default:
handled = false; handled = false;
@ -241,9 +258,8 @@ export class ContextMenu extends React.Component {
} }
}; };
renderMenu(hasBackground=this.props.hasBackground) { protected renderMenu(hasBackground = this.props.hasBackground) {
const position = {}; const position: Partial<Writeable<DOMRect>> = {};
let chevronFace = null;
const props = this.props; const props = this.props;
if (props.top) { if (props.top) {
@ -252,23 +268,24 @@ export class ContextMenu extends React.Component {
position.bottom = props.bottom; position.bottom = props.bottom;
} }
let chevronFace: ChevronFace;
if (props.left) { if (props.left) {
position.left = props.left; position.left = props.left;
chevronFace = 'left'; chevronFace = ChevronFace.Left;
} else { } else {
position.right = props.right; position.right = props.right;
chevronFace = 'right'; chevronFace = ChevronFace.Right;
} }
const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null; const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null;
const chevronOffset = {}; const chevronOffset: CSSProperties = {};
if (props.chevronFace) { if (props.chevronFace) {
chevronFace = 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; chevronOffset.left = props.chevronOffset;
} else if (position.top !== undefined) { } else if (position.top !== undefined) {
const target = position.top; const target = position.top;
@ -298,13 +315,13 @@ export class ContextMenu extends React.Component {
'mx_ContextualMenu_right': !hasChevron && position.right, 'mx_ContextualMenu_right': !hasChevron && position.right,
'mx_ContextualMenu_top': !hasChevron && position.top, 'mx_ContextualMenu_top': !hasChevron && position.top,
'mx_ContextualMenu_bottom': !hasChevron && position.bottom, 'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
'mx_ContextualMenu_withChevron_left': chevronFace === 'left', 'mx_ContextualMenu_withChevron_left': chevronFace === ChevronFace.Left,
'mx_ContextualMenu_withChevron_right': chevronFace === 'right', 'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
'mx_ContextualMenu_withChevron_top': chevronFace === 'top', 'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom', 'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom,
}); });
const menuStyle = {}; const menuStyle: CSSProperties = {};
if (props.menuWidth) { if (props.menuWidth) {
menuStyle.width = props.menuWidth; menuStyle.width = props.menuWidth;
} }
@ -335,13 +352,28 @@ export class ContextMenu extends React.Component {
let background; let background;
if (hasBackground) { if (hasBackground) {
background = ( 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 ( return (
<div className="mx_ContextualMenu_wrapper" style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown} onContextMenu={this.onContextMenuPreventBubbling}> <div
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} role={this.props.managed ? "menu" : undefined}> 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 } { chevron }
{ props.children } { props.children }
</div> </div>
@ -350,195 +382,13 @@ export class ContextMenu extends React.Component {
); );
} }
render() { render(): React.ReactChild {
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer()); 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 // 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; const left = elementRect.right + window.pageXOffset + 3;
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset; let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
top -= chevronOffset + 8; // where 8 is half the height of the chevron 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 // Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
export const aboveLeftOf = (elementRect, chevronFace="none") => { export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None) => {
const menuOptions = { chevronFace }; const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.pageXOffset; const buttonRight = elementRect.right + window.pageXOffset;
const buttonBottom = elementRect.bottom + window.pageYOffset; const buttonBottom = elementRect.bottom + window.pageYOffset;
@ -605,3 +455,12 @@ export function createMenu(ElementClass, props) {
return {close: onFinished}; 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 dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler"; import { _t } from "../../languageHandler";
import RoomList2 from "../views/rooms/RoomList2"; import RoomList2 from "../views/rooms/RoomList2";
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist2";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import UserMenu from "./UserMenu"; import UserMenu from "./UserMenu";
import RoomSearch from "./RoomSearch"; import RoomSearch from "./RoomSearch";
@ -135,7 +136,6 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
const bottom = rlRect.bottom; const bottom = rlRect.bottom;
const top = rlRect.top; const top = rlRect.top;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist2"); 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 headerRightMargin = 24; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = rlRect.width - headerRightMargin; const headerStickyWidth = rlRect.width - headerRightMargin;
@ -146,23 +146,27 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
const slRect = sublist.getBoundingClientRect(); const slRect = sublist.getBoundingClientRect();
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist2_stickable"); 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_sticky");
header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom"); header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom");
header.style.width = `${headerStickyWidth}px`; header.style.width = `${headerStickyWidth}px`;
header.style.removeProperty("top"); header.style.removeProperty("top");
gotBottom = true; 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_sticky");
header.classList.add("mx_RoomSublist2_headerContainer_stickyTop"); header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
header.style.width = `${headerStickyWidth}px`; header.style.width = `${headerStickyWidth}px`;
header.style.top = `${rlRect.top}px`; header.style.top = `${rlRect.top - HEADER_HEIGHT}px`;
if (lastTopHeader) { if (lastTopHeader) {
lastTopHeader.style.display = "none"; lastTopHeader.style.display = "none";
} }
// first unset it, if set in last iteration
header.style.removeProperty("display");
lastTopHeader = header; lastTopHeader = header;
} else { } else {
header.classList.remove("mx_RoomSublist2_headerContainer_sticky"); header.classList.remove("mx_RoomSublist2_headerContainer_sticky");
@ -172,6 +176,26 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
header.style.removeProperty("top"); 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 // 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"> <aside className="mx_LeftPanel2_roomListContainer">
{this.renderHeader()} {this.renderHeader()}
{this.renderSearchExplore()} {this.renderSearchExplore()}
<div className="mx_LeftPanel2_roomListWrapper">
<div <div
className={roomListClasses} className={roomListClasses}
onScroll={this.onScroll} onScroll={this.onScroll}
@ -335,6 +360,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
> >
{roomList} {roomList}
</div> </div>
</div>
</aside> </aside>
</div> </div>
); );

View file

@ -14,14 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import * as React from "react"; import React, { createRef } from "react";
import { MatrixClientPeg } from "../../MatrixClientPeg"; import { MatrixClientPeg } from "../../MatrixClientPeg";
import defaultDispatcher from "../../dispatcher/dispatcher"; import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads"; import { ActionPayload } from "../../dispatcher/payloads";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import { createRef } from "react";
import { _t } from "../../languageHandler"; 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 {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 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.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
const target = ev.target as HTMLButtonElement; const target = ev.target as HTMLButtonElement;
@ -235,7 +234,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
return ( return (
<ContextMenu <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 // -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} left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 20}
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height} top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}

View file

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

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd 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> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
@ -23,6 +23,7 @@ import { _t } from '../../../languageHandler';
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
import * as sdk from "../../../index"; import * as sdk from "../../../index";
import {MatrixEvent} from "matrix-js-sdk"; import {MatrixEvent} from "matrix-js-sdk";
import {isValid3pidInvite} from "../../../RoomInvite";
export default createReactClass({ export default createReactClass({
displayName: 'MemberEventListSummary', displayName: 'MemberEventListSummary',
@ -284,6 +285,9 @@ export default createReactClass({
_getTransition: function(e) { _getTransition: function(e) {
if (e.mxEvent.getType() === 'm.room.third_party_invite') { if (e.mxEvent.getType() === 'm.room.third_party_invite') {
// Handle 3pid invites the same as invites so they get bundled together // Handle 3pid invites the same as invites so they get bundled together
if (!isValid3pidInvite(e.mxEvent)) {
return 'invite_withdrawal';
}
return 'invited'; return 'invited';
} }

View file

@ -26,6 +26,7 @@ import AccessibleButton from "../../views/elements/AccessibleButton";
import RoomTile2 from "./RoomTile2"; import RoomTile2 from "./RoomTile2";
import { ListLayout } from "../../../stores/room-list/ListLayout"; import { ListLayout } from "../../../stores/room-list/ListLayout";
import { import {
ChevronFace,
ContextMenu, ContextMenu,
ContextMenuButton, ContextMenuButton,
StyledMenuItemCheckbox, StyledMenuItemCheckbox,
@ -39,6 +40,8 @@ import NotificationBadge from "./NotificationBadge";
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState"; import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import {ActionPayload} from "../../../dispatcher/payloads";
import { Enable, Resizable } from "re-resizable"; import { Enable, Resizable } from "re-resizable";
import { Direction } from "re-resizable/lib/resizer"; import { Direction } from "re-resizable/lib/resizer";
import { polyfillTouchEvent } from "../../../@types/polyfill"; 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 SHOW_N_BUTTON_HEIGHT = 32; // As defined by CSS
const RESIZE_HANDLE_HEIGHT = 4; // 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; 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> { export default class RoomSublist2 extends React.Component<IProps, IState> {
private headerButton = createRef<HTMLDivElement>(); private headerButton = createRef<HTMLDivElement>();
private sublistRef = createRef<HTMLDivElement>(); private sublistRef = createRef<HTMLDivElement>();
private dispatcherRef: string;
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
@ -103,6 +108,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
isResizing: false, isResizing: false,
}; };
this.state.notificationState.setRooms(this.props.rooms); this.state.notificationState.setRooms(this.props.rooms);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
} }
private get numTiles(): number { private get numTiles(): number {
@ -121,8 +127,29 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
public componentWillUnmount() { public componentWillUnmount() {
this.state.notificationState.destroy(); 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) => { private onAddRoom = (e) => {
e.stopPropagation(); e.stopPropagation();
if (this.props.onAddRoom) this.props.onAddRoom(); 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.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
const target = ev.target as HTMLButtonElement; const target = ev.target as HTMLButtonElement;
@ -252,7 +279,11 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const possibleSticky = target.parentElement; const possibleSticky = target.parentElement;
const sublist = possibleSticky.parentElement.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 // is sticky - jump to list
sublist.scrollIntoView({behavior: 'smooth'}); sublist.scrollIntoView({behavior: 'smooth'});
} else { } else {
@ -384,7 +415,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
contextMenu = ( contextMenu = (
<ContextMenu <ContextMenu
chevronFace="none" chevronFace={ChevronFace.None}
left={this.state.contextMenuPosition.left} left={this.state.contextMenuPosition.left}
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height} top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
onFinished={this.onCloseMenu} onFinished={this.onCloseMenu}

View file

@ -17,7 +17,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, {createRef} from "react";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import classNames from "classnames"; import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
@ -27,6 +27,7 @@ import { Key } from "../../../Keyboard";
import ActiveRoomObserver from "../../../ActiveRoomObserver"; import ActiveRoomObserver from "../../../ActiveRoomObserver";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { import {
ChevronFace,
ContextMenu, ContextMenu,
ContextMenuButton, ContextMenuButton,
MenuItemRadio, MenuItemRadio,
@ -50,6 +51,8 @@ import { INotificationState } from "../../../stores/notifications/INotificationS
import NotificationBadge from "./NotificationBadge"; import NotificationBadge from "./NotificationBadge";
import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { Volume } from "../../../RoomNotifsTypes"; 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: 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 // 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 // align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.pageXOffset - 9; const left = elementRect.left + window.pageXOffset - 9;
const top = elementRect.bottom + window.pageYOffset + 17; const top = elementRect.bottom + window.pageYOffset + 17;
const chevronFace = "none"; const chevronFace = ChevronFace.None;
return {left, top, chevronFace}; 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> { 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) { constructor(props: IProps) {
super(props); super(props);
@ -130,6 +137,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
}; };
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate); ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
} }
private get showContextMenu(): boolean { private get showContextMenu(): boolean {
@ -140,12 +148,36 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
return !this.props.isMinimized && this.props.showMessagePreview; 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() { public componentWillUnmount() {
if (this.props.room) { if (this.props.room) {
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate); 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 = () => { private onTileMouseEnter = () => {
this.setState({hover: true}); this.setState({hover: true});
}; };
@ -159,7 +191,6 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
ev.stopPropagation(); ev.stopPropagation();
dis.dispatch({ dis.dispatch({
action: 'view_room', 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 show_room_tile: true, // make sure the room is visible in the list
room_id: this.props.room.roomId, room_id: this.props.room.roomId,
clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)), 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}); this.setState({selected: isActive});
}; };
private onNotificationsMenuOpenClick = (ev: InputEvent) => { private onNotificationsMenuOpenClick = (ev: React.MouseEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
const target = ev.target as HTMLButtonElement; const target = ev.target as HTMLButtonElement;
@ -181,7 +212,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
this.setState({notificationsMenuPosition: null}); this.setState({notificationsMenuPosition: null});
}; };
private onGeneralMenuOpenClick = (ev: InputEvent) => { private onGeneralMenuOpenClick = (ev: React.MouseEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
const target = ev.target as HTMLButtonElement; const target = ev.target as HTMLButtonElement;
@ -477,7 +508,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
return ( return (
<React.Fragment> <React.Fragment>
<RovingTabIndexWrapper> <RovingTabIndexWrapper inputRef={this.roomTileRef}>
{({onFocus, isActive, ref}) => {({onFocus, isActive, ref}) =>
<AccessibleButton <AccessibleButton
onFocus={onFocus} onFocus={onFocus}

View file

@ -20,6 +20,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import ReplyThread from "../../../components/views/elements/ReplyThread"; import ReplyThread from "../../../components/views/elements/ReplyThread";
import { sanitizedHtmlNodeInnerText } from "../../../HtmlUtils";
export class MessageEventPreview implements IPreview { export class MessageEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string { public getTextFor(event: MatrixEvent, tagId?: TagID): string {
@ -36,14 +37,27 @@ export class MessageEventPreview implements IPreview {
const msgtype = eventContent['msgtype']; const msgtype = eventContent['msgtype'];
if (!body || !msgtype) return null; // invalid event, no preview 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. // XXX: Newer relations have a getRelation() function which is not compatible with replies.
const mRelatesTo = event.getWireContent()['m.relates_to']; const mRelatesTo = event.getWireContent()['m.relates_to'];
if (mRelatesTo && mRelatesTo['m.in_reply_to']) { if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
// If this is a reply, get the real reply and use that // 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(); body = (ReplyThread.stripPlainReply(body) || '').trim();
}
if (!body) return null; // invalid event, no preview if (!body) return null; // invalid event, no preview
} }
if (hasHtml) {
body = sanitizedHtmlNodeInnerText(body);
}
if (msgtype === 'm.emote') { if (msgtype === 'm.emote') {
return _t("%(senderName)s %(emote)s", {senderName: getSenderName(event), emote: body}); 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" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339"
integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA== 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": "@types/lodash@^4.14.152":
version "4.14.155" version "4.14.155"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.155.tgz#e2b4514f46a261fd11542e47519c20ebce7bc23a" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.155.tgz#e2b4514f46a261fd11542e47519c20ebce7bc23a"
@ -1372,6 +1379,13 @@
"@types/prop-types" "*" "@types/prop-types" "*"
csstype "^2.2.0" 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": "@types/stack-utils@^1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"