Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into

joriks/room-list-priorities
This commit is contained in:
Jorik Schellekens 2020-07-08 16:08:55 +01:00
commit 6ca7150533
31 changed files with 1001 additions and 485 deletions

View file

@ -89,11 +89,11 @@
"prop-types": "^15.5.8", "prop-types": "^15.5.8",
"qrcode": "^1.4.4", "qrcode": "^1.4.4",
"qs": "^6.6.0", "qs": "^6.6.0",
"re-resizable": "^6.5.2",
"react": "^16.9.0", "react": "^16.9.0",
"react-beautiful-dnd": "^4.0.1", "react-beautiful-dnd": "^4.0.1",
"react-dom": "^16.9.0", "react-dom": "^16.9.0",
"react-focus-lock": "^2.2.1", "react-focus-lock": "^2.2.1",
"react-resizable": "^1.10.1",
"react-transition-group": "^4.4.1", "react-transition-group": "^4.4.1",
"resize-observer-polyfill": "^1.5.0", "resize-observer-polyfill": "^1.5.0",
"sanitize-html": "^1.18.4", "sanitize-html": "^1.18.4",
@ -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;
@ -203,15 +200,16 @@ limitations under the License.
// Update the render() function for RoomSublist2 if these change // Update the render() function for RoomSublist2 if these change
// Update the ListLayout class for minVisibleTiles if these change. // Update the ListLayout class for minVisibleTiles if these change.
// //
// At 24px high and 8px padding on the top this equates to 0.65 of // At 24px high, 8px padding on the top and 4px padding on the bottom this equates to 0.73 of
// a tile due to how the padding calculations work. // a tile due to how the padding calculations work.
height: 24px; height: 24px;
padding-top: 8px; padding-top: 8px;
padding-bottom: 4px;
// We force this to the bottom so it will overlap rooms as needed. // We force this to the bottom so it will overlap rooms as needed.
// We account for the space it takes up (24px) in the code through padding. // We account for the space it takes up (24px) in the code through padding.
position: absolute; position: absolute;
bottom: 4px; // the height of the resize handle bottom: 0; // the height of the resize handle
left: 0; left: 0;
right: 0; right: 0;
@ -253,24 +251,26 @@ limitations under the License.
// Class name comes from the ResizableBox component // Class name comes from the ResizableBox component
// The hover state needs to use the whole sublist, not just the resizable box, // The hover state needs to use the whole sublist, not just the resizable box,
// so that selector is below and one level higher. // so that selector is below and one level higher.
.react-resizable-handle { .mx_RoomSublist2_resizerHandle {
cursor: ns-resize; cursor: ns-resize;
border-radius: 3px; border-radius: 3px;
// Update RESIZE_HANDLE_HEIGHT if this changes // Override styles from library
height: 4px; width: unset !important;
height: 4px !important; // Update RESIZE_HANDLE_HEIGHT if this changes
// This is positioned directly below the 'show more' button. // This is positioned directly below the 'show more' button.
position: absolute; position: absolute;
bottom: 0; bottom: 0 !important; // override from library
// Together, these make the bar 64px wide // Together, these make the bar 64px wide
left: calc(50% - 32px); // These are also overridden from the library
right: calc(50% - 32px); left: calc(50% - 32px) !important;
right: calc(50% - 32px) !important;
} }
&:hover, &.mx_RoomSublist2_hasMenuOpen { &:hover, &.mx_RoomSublist2_hasMenuOpen {
.react-resizable-handle { .mx_RoomSublist2_resizerHandle {
opacity: 0.8; opacity: 0.8;
background-color: $primary-fg-color; background-color: $primary-fg-color;
} }

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

@ -36,7 +36,7 @@ $focus-bg-color: #dddddd;
$accent-fg-color: #ffffff; $accent-fg-color: #ffffff;
$accent-color-50pct: rgba(3, 179, 129, 0.5); //#03b381 in rgb $accent-color-50pct: rgba(3, 179, 129, 0.5); //#03b381 in rgb
$accent-color-darker: #92caad; $accent-color-darker: #92caad;
$accent-color-alt: #238CF5; $accent-color-alt: #238cf5;
$selection-fg-color: $primary-bg-color; $selection-fg-color: $primary-bg-color;
@ -46,8 +46,8 @@ $focus-brightness: 105%;
$warning-color: $notice-primary-color; // red $warning-color: $notice-primary-color; // red
$orange-warning-color: #ff8d13; // used for true warnings $orange-warning-color: #ff8d13; // used for true warnings
// background colour for warnings // background colour for warnings
$warning-bg-color: #DF2A8B; $warning-bg-color: #df2a8b;
$info-bg-color: #2A9EDF; $info-bg-color: #2a9edf;
$mention-user-pill-bg-color: $warning-color; $mention-user-pill-bg-color: $warning-color;
$other-user-pill-bg-color: rgba(0, 0, 0, 0.1); $other-user-pill-bg-color: rgba(0, 0, 0, 0.1);
@ -71,7 +71,7 @@ $tagpanel-bg-color: #27303a;
$plinth-bg-color: $secondary-accent-color; $plinth-bg-color: $secondary-accent-color;
// used by RoomDropTarget // used by RoomDropTarget
$droptarget-bg-color: rgba(255,255,255,0.5); $droptarget-bg-color: rgba(255, 255, 255, 0.5);
// used by AddressSelector // used by AddressSelector
$selected-color: $secondary-accent-color; $selected-color: $secondary-accent-color;
@ -157,18 +157,18 @@ $rte-group-pill-color: #aaa;
$topleftmenu-color: #212121; $topleftmenu-color: #212121;
$roomheader-color: #45474a; $roomheader-color: #45474a;
$roomheader-addroom-bg-color: #91A1C0; $roomheader-addroom-bg-color: #91a1c0;
$roomheader-addroom-fg-color: $accent-fg-color; $roomheader-addroom-fg-color: $accent-fg-color;
$tagpanel-button-color: #91A1C0; $tagpanel-button-color: #91a1c0;
$roomheader-button-color: #91A1C0; $roomheader-button-color: #91a1c0;
$groupheader-button-color: #91A1C0; $groupheader-button-color: #91a1c0;
$rightpanel-button-color: #91A1C0; $rightpanel-button-color: #91a1c0;
$composer-button-color: #91A1C0; $composer-button-color: #91a1c0;
$roomtopic-color: #9e9e9e; $roomtopic-color: #9e9e9e;
$eventtile-meta-color: $roomtopic-color; $eventtile-meta-color: $roomtopic-color;
$composer-e2e-icon-color: #c9ced6; $composer-e2e-icon-color: #c9ced6;
$header-divider-color: #91A1C0; $header-divider-color: #91a1c0;
// ******************** // ********************
@ -184,11 +184,11 @@ $roomsublist2-divider-color: $primary-fg-color;
$roomtile2-preview-color: #9e9e9e; $roomtile2-preview-color: #9e9e9e;
$roomtile2-default-badge-bg-color: #61708b; $roomtile2-default-badge-bg-color: #61708b;
$roomtile2-selected-bg-color: #FFF; $roomtile2-selected-bg-color: #fff;
$presence-online: $accent-color; $presence-online: $accent-color;
$presence-away: orange; // TODO: Get color $presence-away: #d9b072;
$presence-offline: #E3E8F0; $presence-offline: #e3e8f0;
// ******************** // ********************

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] };

38
src/@types/polyfill.ts Normal file
View file

@ -0,0 +1,38 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// This is intended to fix re-resizer because of its unguarded `instanceof TouchEvent` checks.
export function polyfillTouchEvent() {
// Firefox doesn't have touch events without touch devices being present, so create a fake
// one we can rely on lying about.
if (!window.TouchEvent) {
// We have no intention of actually using this, so just lie.
window.TouchEvent = class TouchEvent extends UIEvent {
public get altKey(): boolean { return false; }
public get changedTouches(): any { return []; }
public get ctrlKey(): boolean { return false; }
public get metaKey(): boolean { return false; }
public get shiftKey(): boolean { return false; }
public get targetTouches(): any { return []; }
public get touches(): any { return []; }
public get rotation(): number { return 0.0; }
public get scale(): number { return 0.0; }
constructor(eventType: string, params?: any) {
super(eventType, params);
}
};
}
}

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;
@ -100,7 +97,7 @@ export function processHtmlForSending(html: string): string {
} }
let contentHTML = ""; let contentHTML = "";
for (let i=0; i < contentDiv.children.length; i++) { for (let i = 0; i < contentDiv.children.length; i++) {
const element = contentDiv.children[i]; const element = contentDiv.children[i];
if (element.tagName.toLowerCase() === 'p') { if (element.tagName.toLowerCase() === 'p') {
contentHTML += element.innerHTML; contentHTML += element.innerHTML;
@ -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,
'code': transformTags['code'], transformTags: {
'*': transformTags['*'], 'code': transformTags['code'],
'*': 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)];
} }
} }
protected abstract processSnippet(snippet: string, highlight: boolean): T;
} }
class HtmlHighlighter extends BaseHighlighter { 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,13 +349,12 @@ 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>;
if (highlight && this.highlightLink) { if (highlight && this.highlightLink) {
node = <a key={key} href={this.highlightLink}>{ node }</a>; node = <a key={key} href={this.highlightLink}>{ node }</a>;
@ -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

@ -22,9 +22,13 @@ import React, {
useMemo, useMemo,
useRef, useRef,
useReducer, useReducer,
Reducer,
RefObject,
Dispatch,
} from "react"; } from "react";
import PropTypes from "prop-types";
import {Key} from "../Keyboard"; import {Key} from "../Keyboard";
import AccessibleButton from "../components/views/elements/AccessibleButton";
/** /**
* Module to simplify implementing the Roving TabIndex accessibility technique * Module to simplify implementing the Roving TabIndex accessibility technique
@ -41,7 +45,19 @@ import {Key} from "../Keyboard";
const DOCUMENT_POSITION_PRECEDING = 2; const DOCUMENT_POSITION_PRECEDING = 2;
const RovingTabIndexContext = createContext({ type Ref = RefObject<HTMLElement>;
interface IState {
activeRef: Ref;
refs: Ref[];
}
interface IContext {
state: IState;
dispatch: Dispatch<IAction>;
}
const RovingTabIndexContext = createContext<IContext>({
state: { state: {
activeRef: null, activeRef: null,
refs: [], // list of refs in DOM order refs: [], // list of refs in DOM order
@ -50,16 +66,22 @@ const RovingTabIndexContext = createContext({
}); });
RovingTabIndexContext.displayName = "RovingTabIndexContext"; RovingTabIndexContext.displayName = "RovingTabIndexContext";
// TODO use a TypeScript type here enum Type {
const types = { Register = "REGISTER",
REGISTER: "REGISTER", Unregister = "UNREGISTER",
UNREGISTER: "UNREGISTER", SetFocus = "SET_FOCUS",
SET_FOCUS: "SET_FOCUS", }
};
const reducer = (state, action) => { interface IAction {
type: Type;
payload: {
ref: Ref;
};
}
const reducer = (state: IState, action: IAction) => {
switch (action.type) { switch (action.type) {
case types.REGISTER: { case Type.Register: {
if (state.refs.length === 0) { if (state.refs.length === 0) {
// Our list of refs was empty, set activeRef to this first item // Our list of refs was empty, set activeRef to this first item
return { return {
@ -92,7 +114,7 @@ const reducer = (state, action) => {
], ],
}; };
} }
case types.UNREGISTER: { case Type.Unregister: {
// filter out the ref which we are removing // filter out the ref which we are removing
const refs = state.refs.filter(r => r !== action.payload.ref); const refs = state.refs.filter(r => r !== action.payload.ref);
@ -117,7 +139,7 @@ const reducer = (state, action) => {
refs, refs,
}; };
} }
case types.SET_FOCUS: { case Type.SetFocus: {
// update active ref // update active ref
return { return {
...state, ...state,
@ -129,13 +151,21 @@ const reducer = (state, action) => {
} }
}; };
export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) => { interface IProps {
const [state, dispatch] = useReducer(reducer, { handleHomeEnd?: boolean;
children(renderProps: {
onKeyDownHandler(ev: React.KeyboardEvent);
});
onKeyDown?(ev: React.KeyboardEvent);
}
export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEnd, onKeyDown}) => {
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
activeRef: null, activeRef: null,
refs: [], refs: [],
}); });
const context = useMemo(() => ({state, dispatch}), [state]); const context = useMemo<IContext>(() => ({state, dispatch}), [state]);
const onKeyDownHandler = useCallback((ev) => { const onKeyDownHandler = useCallback((ev) => {
let handled = false; let handled = false;
@ -171,19 +201,17 @@ export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) =>
{ children({onKeyDownHandler}) } { children({onKeyDownHandler}) }
</RovingTabIndexContext.Provider>; </RovingTabIndexContext.Provider>;
}; };
RovingTabIndexProvider.propTypes = {
handleHomeEnd: PropTypes.bool, type FocusHandler = () => void;
onKeyDown: PropTypes.func,
};
// Hook to register a roving tab index // Hook to register a roving tab index
// inputRef parameter specifies the ref to use // inputRef parameter specifies the ref to use
// onFocus should be called when the index gained focus in any manner // onFocus should be called when the index gained focus in any manner
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}` // isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition // ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
export const useRovingTabIndex = (inputRef) => { export const useRovingTabIndex = (inputRef: Ref): [FocusHandler, boolean, Ref] => {
const context = useContext(RovingTabIndexContext); const context = useContext(RovingTabIndexContext);
let ref = useRef(null); let ref = useRef<HTMLElement>(null);
if (inputRef) { if (inputRef) {
// if we are given a ref, use it instead of ours // if we are given a ref, use it instead of ours
@ -193,13 +221,13 @@ export const useRovingTabIndex = (inputRef) => {
// setup (after refs) // setup (after refs)
useLayoutEffect(() => { useLayoutEffect(() => {
context.dispatch({ context.dispatch({
type: types.REGISTER, type: Type.Register,
payload: {ref}, payload: {ref},
}); });
// teardown // teardown
return () => { return () => {
context.dispatch({ context.dispatch({
type: types.UNREGISTER, type: Type.Unregister,
payload: {ref}, payload: {ref},
}); });
}; };
@ -207,7 +235,7 @@ export const useRovingTabIndex = (inputRef) => {
const onFocus = useCallback(() => { const onFocus = useCallback(() => {
context.dispatch({ context.dispatch({
type: types.SET_FOCUS, type: Type.SetFocus,
payload: {ref}, payload: {ref},
}); });
}, [ref, context]); }, [ref, context]);
@ -216,9 +244,28 @@ export const useRovingTabIndex = (inputRef) => {
return [onFocus, isActive, ref]; return [onFocus, isActive, ref];
}; };
interface IRovingTabIndexWrapperProps {
inputRef?: Ref;
children(renderProps: {
onFocus: FocusHandler;
isActive: boolean;
ref: Ref;
});
}
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components. // Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
export const RovingTabIndexWrapper = ({children, inputRef}) => { export const RovingTabIndexWrapper: React.FC<IRovingTabIndexWrapperProps> = ({children, inputRef}) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return children({onFocus, isActive, ref}); return children({onFocus, isActive, ref});
}; };
interface IRovingAccessibleButtonProps extends React.ComponentProps<typeof AccessibleButton> {
inputRef?: Ref;
}
// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
export const RovingAccessibleButton: React.FC<IRovingAccessibleButtonProps> = ({inputRef, ...props}) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return <AccessibleButton {...props} onFocus={onFocus} inputRef={ref} tabIndex={isActive ? 0 : -1} />;
};

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";
@ -69,8 +70,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
private listContainerRef: React.RefObject<HTMLDivElement> = createRef(); private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
private tagPanelWatcherRef: string; private tagPanelWatcherRef: string;
private focusedElement = null; private focusedElement = null;
private isDoingStickyHeaders = false;
// TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
@ -115,11 +115,27 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
}; };
private handleStickyHeaders(list: HTMLDivElement) { private handleStickyHeaders(list: HTMLDivElement) {
// TODO: Evaluate if this has any performance benefit or detriment.
// See https://github.com/vector-im/riot-web/issues/14035
if (this.isDoingStickyHeaders) return;
this.isDoingStickyHeaders = true;
if (window.requestAnimationFrame) {
window.requestAnimationFrame(() => {
this.doStickyHeaders(list);
this.isDoingStickyHeaders = false;
});
} else {
this.doStickyHeaders(list);
this.isDoingStickyHeaders = false;
}
}
private doStickyHeaders(list: HTMLDivElement) {
const rlRect = list.getBoundingClientRect(); const rlRect = list.getBoundingClientRect();
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;
@ -130,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");
@ -156,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
@ -264,7 +304,6 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
onVerticalArrow={this.onKeyDown} onVerticalArrow={this.onKeyDown}
/> />
<AccessibleButton <AccessibleButton
// TODO fix the accessibility of this: https://github.com/vector-im/riot-web/issues/14180
className="mx_LeftPanel2_exploreButton" className="mx_LeftPanel2_exploreButton"
onClick={this.onExplore} onClick={this.onExplore}
title={_t("Explore rooms")} title={_t("Explore rooms")}
@ -310,15 +349,17 @@ 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 <div className="mx_LeftPanel2_roomListWrapper">
className={roomListClasses} <div
onScroll={this.onScroll} className={roomListClasses}
ref={this.listContainerRef} onScroll={this.onScroll}
// Firefox sometimes makes this element focusable due to ref={this.listContainerRef}
// overflow:scroll;, so force it out of tab order. // Firefox sometimes makes this element focusable due to
tabIndex={-1} // overflow:scroll;, so force it out of tab order.
> tabIndex={-1}
{roomList} >
{roomList}
</div>
</div> </div>
</aside> </aside>
</div> </div>

View file

@ -81,6 +81,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
private openSearch = () => { private openSearch = () => {
defaultDispatcher.dispatch({action: "show_left_panel"}); defaultDispatcher.dispatch({action: "show_left_panel"});
defaultDispatcher.dispatch({action: "focus_room_filter"});
}; };
private onChange = () => { private onChange = () => {

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

@ -20,13 +20,13 @@ import * as React from "react";
import { createRef } from "react"; import { 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 {RovingAccessibleButton, RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import AccessibleButton from "../../views/elements/AccessibleButton"; import AccessibleButton from "../../views/elements/AccessibleButton";
import RoomTile2 from "./RoomTile2"; import RoomTile2 from "./RoomTile2";
import { ResizableBox, ResizeCallbackData } from "react-resizable";
import { ListLayout } from "../../../stores/room-list/ListLayout"; import { ListLayout } from "../../../stores/room-list/ListLayout";
import { import {
ChevronFace,
ContextMenu, ContextMenu,
ContextMenuButton, ContextMenuButton,
StyledMenuItemCheckbox, StyledMenuItemCheckbox,
@ -40,7 +40,11 @@ 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 StyledCheckbox from "../elements/StyledCheckbox"; 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";
// 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
@ -55,9 +59,13 @@ import StyledCheckbox from "../elements/StyledCheckbox";
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;
// HACK: We really shouldn't have to do this.
polyfillTouchEvent();
interface IProps { interface IProps {
forRooms: boolean; forRooms: boolean;
rooms?: Room[]; rooms?: Room[];
@ -89,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);
@ -99,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 {
@ -117,17 +127,53 @@ 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();
}; };
private onResize = (e: React.MouseEvent, data: ResizeCallbackData) => { private onResize = (
const direction = e.movementY < 0 ? -1 : +1; e: MouseEvent | TouchEvent,
const tileDiff = this.props.layout.pixelsToTiles(Math.abs(e.movementY)) * direction; travelDirection: Direction,
this.props.layout.setVisibleTilesWithin(tileDiff, this.numTiles); refToElement: HTMLDivElement,
delta: { width: number, height: number }, // TODO: Use re-resizer's NumberSize when it is exposed as the type
) => {
// Do some sanity checks, but in reality we shouldn't need these.
if (travelDirection !== "bottom") return;
if (delta.height === 0) return; // something went wrong, so just ignore it.
// NOTE: the movement in the MouseEvent (not present on a TouchEvent) is inaccurate
// for our purposes. The delta provided by the library is also a change *from when
// resizing started*, meaning it is fairly useless for us. This is why we just use
// the client height and run with it.
const heightBefore = this.props.layout.visibleTiles;
const heightInTiles = this.props.layout.pixelsToTiles(refToElement.clientHeight);
this.props.layout.setVisibleTilesWithin(heightInTiles, this.numTiles);
if (heightBefore === this.props.layout.visibleTiles) return; // no-op
this.forceUpdate(); // because the layout doesn't trigger a re-render this.forceUpdate(); // because the layout doesn't trigger a re-render
}; };
@ -140,18 +186,28 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
}; };
private onShowAllClick = () => { private onShowAllClick = () => {
// TODO a11y keep focus somewhere useful: https://github.com/vector-im/riot-web/issues/14180 const numVisibleTiles = this.numVisibleTiles;
this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT); this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT);
this.forceUpdate(); // because the layout doesn't trigger a re-render this.forceUpdate(); // because the layout doesn't trigger a re-render
setImmediate(this.focusRoomTile, numVisibleTiles); // focus the tile after the current bottom one
}; };
private onShowLessClick = () => { private onShowLessClick = () => {
// TODO a11y keep focus somewhere useful: https://github.com/vector-im/riot-web/issues/14180
this.props.layout.visibleTiles = this.props.layout.defaultVisibleTiles; this.props.layout.visibleTiles = this.props.layout.defaultVisibleTiles;
this.forceUpdate(); // because the layout doesn't trigger a re-render this.forceUpdate(); // because the layout doesn't trigger a re-render
// focus will flow to the show more button here
}; };
private onOpenMenuClick = (ev: InputEvent) => { private focusRoomTile = (index: number) => {
if (!this.sublistRef.current) return;
const elements = this.sublistRef.current.querySelectorAll<HTMLDivElement>(".mx_RoomTile2");
const element = elements && elements[index];
if (element) {
element.focus();
}
};
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;
@ -223,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 {
@ -355,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}
@ -450,7 +510,6 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
</div> </div>
); );
// TODO: a11y (see old component): https://github.com/vector-im/riot-web/issues/14180
// Note: the addRoomButton conditionally gets moved around // Note: the addRoomButton conditionally gets moved around
// the DOM depending on whether or not the list is minimized. // the DOM depending on whether or not the list is minimized.
// If we're minimized, we want it below the header so it // If we're minimized, we want it below the header so it
@ -521,12 +580,12 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
); );
if (this.props.isMinimized) showMoreText = null; if (this.props.isMinimized) showMoreText = null;
showNButton = ( showNButton = (
<AccessibleButton onClick={this.onShowAllClick} className={showMoreBtnClasses} tabIndex={-1}> <RovingAccessibleButton onClick={this.onShowAllClick} className={showMoreBtnClasses}>
<span className='mx_RoomSublist2_showMoreButtonChevron mx_RoomSublist2_showNButtonChevron'> <span className='mx_RoomSublist2_showMoreButtonChevron mx_RoomSublist2_showNButtonChevron'>
{/* set by CSS masking */} {/* set by CSS masking */}
</span> </span>
{showMoreText} {showMoreText}
</AccessibleButton> </RovingAccessibleButton>
); );
} else if (this.numTiles <= visibleTiles.length && this.numTiles > this.props.layout.defaultVisibleTiles) { } else if (this.numTiles <= visibleTiles.length && this.numTiles > this.props.layout.defaultVisibleTiles) {
// we have all tiles visible - add a button to show less // we have all tiles visible - add a button to show less
@ -536,21 +595,30 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
</span> </span>
); );
if (this.props.isMinimized) showLessText = null; if (this.props.isMinimized) showLessText = null;
// TODO Roving tab index / treeitem?: https://github.com/vector-im/riot-web/issues/14180
showNButton = ( showNButton = (
<AccessibleButton onClick={this.onShowLessClick} className={showMoreBtnClasses} tabIndex={-1}> <RovingAccessibleButton onClick={this.onShowLessClick} className={showMoreBtnClasses}>
<span className='mx_RoomSublist2_showLessButtonChevron mx_RoomSublist2_showNButtonChevron'> <span className='mx_RoomSublist2_showLessButtonChevron mx_RoomSublist2_showNButtonChevron'>
{/* set by CSS masking */} {/* set by CSS masking */}
</span> </span>
{showLessText} {showLessText}
</AccessibleButton> </RovingAccessibleButton>
); );
} }
// Figure out if we need a handle // Figure out if we need a handle
let handles = ['s']; const handles: Enable = {
bottom: true, // the only one we need, but the others must be explicitly false
bottomLeft: false,
bottomRight: false,
left: false,
right: false,
top: false,
topLeft: false,
topRight: false,
};
if (layout.visibleTiles >= this.numTiles && this.numTiles <= layout.minVisibleTiles) { if (layout.visibleTiles >= this.numTiles && this.numTiles <= layout.minVisibleTiles) {
handles = []; // no handles, we're at a minimum // we're at a minimum, don't have a bottom handle
handles.bottom = false;
} }
// We have to account for padding so we can accommodate a 'show more' button and // We have to account for padding so we can accommodate a 'show more' button and
@ -574,22 +642,25 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const tilesWithoutPadding = Math.min(relativeTiles, layout.visibleTiles); const tilesWithoutPadding = Math.min(relativeTiles, layout.visibleTiles);
const tilesPx = layout.calculateTilesToPixelsMin(relativeTiles, tilesWithoutPadding, padding); const tilesPx = layout.calculateTilesToPixelsMin(relativeTiles, tilesWithoutPadding, padding);
const dimensions = {
height: tilesPx,
};
content = ( content = (
<ResizableBox <Resizable
width={-1} size={dimensions as any}
height={tilesPx} minHeight={minTilesPx}
axis="y" maxHeight={maxTilesPx}
minConstraints={[-1, minTilesPx]}
maxConstraints={[-1, maxTilesPx]}
resizeHandles={handles}
onResize={this.onResize}
className="mx_RoomSublist2_resizeBox"
onResizeStart={this.onResizeStart} onResizeStart={this.onResizeStart}
onResizeStop={this.onResizeStop} onResizeStop={this.onResizeStop}
onResize={this.onResize}
handleWrapperClass="mx_RoomSublist2_resizerHandles"
handleClasses={{bottom: "mx_RoomSublist2_resizerHandle"}}
className="mx_RoomSublist2_resizeBox"
enable={handles}
> >
{visibleTiles} {visibleTiles}
{showNButton} {showNButton}
</ResizableBox> </Resizable>
); );
} }

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,
@ -52,6 +53,8 @@ import { NotificationColor } from "../../../stores/notifications/NotificationCol
import { Volume } from "../../../RoomNotifsTypes"; import { Volume } from "../../../RoomNotifsTypes";
import RoomListStore from "../../../stores/room-list/RoomListStore2"; import RoomListStore from "../../../stores/room-list/RoomListStore2";
import RoomListActions from "../../../actions/RoomListActions"; import RoomListActions from "../../../actions/RoomListActions";
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
@ -89,7 +92,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};
}; };
@ -120,6 +123,8 @@ 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 // TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
constructor(props: IProps) { constructor(props: IProps) {
@ -134,6 +139,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 {
@ -144,12 +150,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});
}; };
@ -163,7 +193,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)),
@ -174,7 +203,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;
@ -185,7 +214,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;
@ -414,7 +443,6 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
public render(): React.ReactElement { public render(): React.ReactElement {
// TODO: Invites: https://github.com/vector-im/riot-web/issues/14198 // TODO: Invites: https://github.com/vector-im/riot-web/issues/14198
// TODO: a11y proper: https://github.com/vector-im/riot-web/issues/14180
const classes = classNames({ const classes = classNames({
'mx_RoomTile2': true, 'mx_RoomTile2': true,
@ -504,7 +532,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

@ -478,13 +478,13 @@ export const SETTINGS = {
deny: [], deny: [],
}, },
}, },
// TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14373
"RoomList.orderAlphabetically": { "RoomList.orderAlphabetically": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("Order rooms by name"), displayName: _td("Order rooms by name"),
default: false, default: false,
}, },
// TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14373
"RoomList.orderByImportance": { "RoomList.orderByImportance": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("Show rooms with unread notifications first"), displayName: _td("Show rooms with unread notifications first"),

View file

@ -43,11 +43,14 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl
const roomId = event.getRoomId(); const roomId = event.getRoomId();
const room = this.client.getRoom(roomId); const room = this.client.getRoom(roomId);
// Note: the tests often fire setting updates that don't have rooms in the store, so // Note: in tests and during the encryption setup on initial load we might not have
// we fail softly here. We shouldn't assume that the state being fired is current // rooms in the store, so we just quietly ignore the problem. If we log it then we'll
// state, but we also don't need to explode just because we didn't find a room. // just end up spamming the logs a few thousand times. It is perfectly fine for us
if (!room) console.warn(`Unknown room caused setting update: ${roomId}`); // to ignore the problem as the app will not have loaded enough to care yet.
if (room && state !== room.currentState) return; // ignore state updates which are not current if (!room) return;
// ignore state updates which are not current
if (room && state !== room.currentState) return;
if (event.getType() === "org.matrix.room.preview_urls") { if (event.getType() === "org.matrix.room.preview_urls") {
let val = event.getContent()['disable']; let val = event.getContent()['disable'];

View file

@ -89,11 +89,12 @@ export class ListLayout {
return 5 + RESIZER_BOX_FACTOR; return 5 + RESIZER_BOX_FACTOR;
} }
public setVisibleTilesWithin(diff: number, maxPossible: number) { public setVisibleTilesWithin(newVal: number, maxPossible: number) {
if (this.visibleTiles > maxPossible) { maxPossible = maxPossible + RESIZER_BOX_FACTOR;
this.visibleTiles = maxPossible + diff; if (newVal > maxPossible) {
this.visibleTiles = maxPossible;
} else { } else {
this.visibleTiles += diff; this.visibleTiles = newVal;
} }
} }

View file

@ -31,6 +31,7 @@ import RoomViewStore from "../RoomViewStore";
import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm"; import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm";
import { EffectiveMembership, getEffectiveMembership } from "./membership"; import { EffectiveMembership, getEffectiveMembership } from "./membership";
import { ListLayout } from "./ListLayout"; import { ListLayout } from "./ListLayout";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
interface IState { interface IState {
tagsEnabled?: boolean; tagsEnabled?: boolean;
@ -221,9 +222,6 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
} }
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Decrypted timeline event ${eventPayload.event.getId()} in ${roomId}`); console.log(`[RoomListDebug] Decrypted timeline event ${eventPayload.event.getId()} in ${roomId}`);
// TODO: Verify that e2e rooms are handled on init: https://github.com/vector-im/riot-web/issues/14238
// It seems like when viewing the room the timeline is decrypted, rather than at startup. This could
// cause inaccuracies with the list ordering. We may have to decrypt the last N messages of every room :(
await this.handleRoomUpdate(room, RoomUpdateCause.Timeline); await this.handleRoomUpdate(room, RoomUpdateCause.Timeline);
} else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') { } else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') {
const eventPayload = (<any>payload); // TODO: Type out the dispatcher types const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
@ -321,6 +319,28 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
return <SortAlgorithm>localStorage.getItem(`mx_tagSort_${tagId}`); return <SortAlgorithm>localStorage.getItem(`mx_tagSort_${tagId}`);
} }
// logic must match calculateListOrder
private calculateTagSorting(tagId: TagID): SortAlgorithm {
const defaultSort = SortAlgorithm.Alphabetic;
const settingAlphabetical = SettingsStore.getValue("RoomList.orderAlphabetically", null, true);
const definedSort = this.getTagSorting(tagId);
const storedSort = this.getStoredTagSorting(tagId);
// We use the following order to determine which of the 4 flags to use:
// Stored > Settings > Defined > Default
let tagSort = defaultSort;
if (storedSort) {
tagSort = storedSort;
} else if (!isNullOrUndefined(settingAlphabetical)) {
tagSort = settingAlphabetical ? SortAlgorithm.Alphabetic : SortAlgorithm.Recent;
} else if (definedSort) {
tagSort = definedSort;
} // else default (already set)
return tagSort;
}
public async setListOrder(tagId: TagID, order: ListAlgorithm) { public async setListOrder(tagId: TagID, order: ListAlgorithm) {
await this.algorithm.setListOrdering(tagId, order); await this.algorithm.setListOrdering(tagId, order);
// TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114 // TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114
@ -337,19 +357,35 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
return <ListAlgorithm>localStorage.getItem(`mx_listOrder_${tagId}`); return <ListAlgorithm>localStorage.getItem(`mx_listOrder_${tagId}`);
} }
private async updateAlgorithmInstances() { // logic must match calculateTagSorting
const defaultSort = SortAlgorithm.Alphabetic; private calculateListOrder(tagId: TagID): ListAlgorithm {
const defaultOrder = ListAlgorithm.Natural; const defaultOrder = ListAlgorithm.Natural;
const settingImportance = SettingsStore.getValue("RoomList.orderByImportance", null, true);
const definedOrder = this.getListOrder(tagId);
const storedOrder = this.getStoredListOrder(tagId);
// We use the following order to determine which of the 4 flags to use:
// Stored > Settings > Defined > Default
let listOrder = defaultOrder;
if (storedOrder) {
listOrder = storedOrder;
} else if (!isNullOrUndefined(settingImportance)) {
listOrder = settingImportance ? ListAlgorithm.Importance : ListAlgorithm.Natural;
} else if (definedOrder) {
listOrder = definedOrder;
} // else default (already set)
return listOrder;
}
private async updateAlgorithmInstances() {
for (const tag of Object.keys(this.orderedLists)) { for (const tag of Object.keys(this.orderedLists)) {
const definedSort = this.getTagSorting(tag); const definedSort = this.getTagSorting(tag);
const definedOrder = this.getListOrder(tag); const definedOrder = this.getListOrder(tag);
const storedSort = this.getStoredTagSorting(tag); const tagSort = this.calculateTagSorting(tag);
const storedOrder = this.getStoredListOrder(tag); const listOrder = this.calculateListOrder(tag);
const tagSort = storedSort ? storedSort : (definedSort ? definedSort : defaultSort);
const listOrder = storedOrder ? storedOrder : (definedOrder ? definedOrder : defaultOrder);
if (tagSort !== definedSort) { if (tagSort !== definedSort) {
await this.setTagSorting(tag, tagSort); await this.setTagSorting(tag, tagSort);
@ -378,8 +414,8 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
const sorts: ITagSortingMap = {}; const sorts: ITagSortingMap = {};
const orders: IListOrderingMap = {}; const orders: IListOrderingMap = {};
for (const tagId of OrderedDefaultTagIDs) { for (const tagId of OrderedDefaultTagIDs) {
sorts[tagId] = this.getStoredTagSorting(tagId) || SortAlgorithm.Alphabetic; sorts[tagId] = this.calculateTagSorting(tagId);
orders[tagId] = this.getStoredListOrder(tagId) || ListAlgorithm.Natural; orders[tagId] = this.calculateListOrder(tagId);
} }
if (this.state.tagsEnabled) { if (this.state.tagsEnabled) {

View file

@ -109,6 +109,7 @@ export class Algorithm extends EventEmitter {
} }
public getTagSorting(tagId: TagID): SortAlgorithm { public getTagSorting(tagId: TagID): SortAlgorithm {
if (!this.sortAlgorithms) return null;
return this.sortAlgorithms[tagId]; return this.sortAlgorithms[tagId];
} }
@ -125,6 +126,7 @@ export class Algorithm extends EventEmitter {
} }
public getListOrdering(tagId: TagID): ListAlgorithm { public getListOrdering(tagId: TagID): ListAlgorithm {
if (!this.listAlgorithms) return null;
return this.listAlgorithms[tagId]; return this.listAlgorithms[tagId];
} }
@ -501,13 +503,9 @@ export class Algorithm extends EventEmitter {
// Split out the easy rooms first (leave and invite) // Split out the easy rooms first (leave and invite)
const memberships = splitRoomsByMembership(rooms); const memberships = splitRoomsByMembership(rooms);
for (const room of memberships[EffectiveMembership.Invite]) { for (const room of memberships[EffectiveMembership.Invite]) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is an Invite`);
newTags[DefaultTagID.Invite].push(room); newTags[DefaultTagID.Invite].push(room);
} }
for (const room of memberships[EffectiveMembership.Leave]) { for (const room of memberships[EffectiveMembership.Leave]) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Historical`);
newTags[DefaultTagID.Archived].push(room); newTags[DefaultTagID.Archived].push(room);
} }
@ -518,11 +516,7 @@ export class Algorithm extends EventEmitter {
let inTag = false; let inTag = false;
if (tags.length > 0) { if (tags.length > 0) {
for (const tag of tags) { for (const tag of tags) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged as ${tag}`);
if (!isNullOrUndefined(newTags[tag])) { if (!isNullOrUndefined(newTags[tag])) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged with VALID tag ${tag}`);
newTags[tag].push(room); newTags[tag].push(room);
inTag = true; inTag = true;
} }
@ -530,11 +524,11 @@ export class Algorithm extends EventEmitter {
} }
if (!inTag) { if (!inTag) {
// TODO: Determine if DM and push there instead: https://github.com/vector-im/riot-web/issues/14236 if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
newTags[DefaultTagID.Untagged].push(room); newTags[DefaultTagID.DM].push(room);
} else {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 newTags[DefaultTagID.Untagged].push(room);
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Untagged`); }
} }
} }

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
body = (ReplyThread.stripPlainReply(body) || '').trim(); if (hasHtml) {
body = (ReplyThread.stripHTMLReply(body) || '').trim();
} else {
body = (ReplyThread.stripPlainReply(body) || '').trim();
}
if (!body) return null; // invalid event, no preview if (!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"
@ -2499,7 +2513,7 @@ class-utils@^0.3.5:
isobject "^3.0.0" isobject "^3.0.0"
static-extend "^0.1.1" static-extend "^0.1.1"
classnames@^2.1.2, classnames@^2.2.5: classnames@^2.1.2:
version "2.2.6" version "2.2.6"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==
@ -3779,6 +3793,11 @@ fast-levenshtein@~2.0.6:
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
fast-memoize@^2.5.1:
version "2.5.2"
resolved "https://registry.yarnpkg.com/fast-memoize/-/fast-memoize-2.5.2.tgz#79e3bb6a4ec867ea40ba0e7146816f6cdce9b57e"
integrity sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==
fb-watchman@^2.0.0: fb-watchman@^2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.1.tgz#fc84fb39d2709cf3ff6d743706157bb5708a8a85" resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.1.tgz#fc84fb39d2709cf3ff6d743706157bb5708a8a85"
@ -6882,7 +6901,7 @@ prop-types-exact@^1.2.0:
object.assign "^4.1.0" object.assign "^4.1.0"
reflect.ownkeys "^0.2.0" reflect.ownkeys "^0.2.0"
prop-types@15.x, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
version "15.7.2" version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
@ -7053,6 +7072,13 @@ rc@1.2.8, rc@^1.2.8:
minimist "^1.2.0" minimist "^1.2.0"
strip-json-comments "~2.0.1" strip-json-comments "~2.0.1"
re-resizable@^6.5.2:
version "6.5.2"
resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.5.2.tgz#7eb1928c673285d4dcf654211e47acb9a3801c3e"
integrity sha512-Pjo3ydkr/meTr6j3YZqyv+9fRS5UNOj5SaAI06gHFQ35BnpsZKmwNvupCnbo11gjQ1I62Uy+UzlHLO9xPQEuWQ==
dependencies:
fast-memoize "^2.5.1"
react-beautiful-dnd@^4.0.1: react-beautiful-dnd@^4.0.1:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-4.0.1.tgz#3b0a49bf6be75af351176c904f012611dd292b81" resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-4.0.1.tgz#3b0a49bf6be75af351176c904f012611dd292b81"
@ -7086,14 +7112,6 @@ react-dom@^16.9.0:
prop-types "^15.6.2" prop-types "^15.6.2"
scheduler "^0.19.1" scheduler "^0.19.1"
react-draggable@^4.0.3:
version "4.4.2"
resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.4.2.tgz#f3cefecee25f467f865144cda0d066e5f05f94a0"
integrity sha512-zLQs4R4bnBCGnCVTZiD8hPsHtkiJxgMpGDlRESM+EHQo8ysXhKJ2GKdJ8UxxLJdRVceX1j19jy+hQS2wHislPQ==
dependencies:
classnames "^2.2.5"
prop-types "^15.6.0"
react-focus-lock@^2.2.1: react-focus-lock@^2.2.1:
version "2.3.1" version "2.3.1"
resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.3.1.tgz#9d5d85899773609c7eefa4fc54fff6a0f5f2fc47" resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.3.1.tgz#9d5d85899773609c7eefa4fc54fff6a0f5f2fc47"
@ -7138,14 +7156,6 @@ react-redux@^5.0.6:
react-is "^16.6.0" react-is "^16.6.0"
react-lifecycles-compat "^3.0.0" react-lifecycles-compat "^3.0.0"
react-resizable@^1.10.1:
version "1.10.1"
resolved "https://registry.yarnpkg.com/react-resizable/-/react-resizable-1.10.1.tgz#f0c2cf1d83b3470b87676ce6d6b02bbe3f4d8cd4"
integrity sha512-Jd/bKOKx6+19NwC4/aMLRu/J9/krfxlDnElP41Oc+oLiUWs/zwV1S9yBfBZRnqAwQb6vQ/HRSk3bsSWGSgVbpw==
dependencies:
prop-types "15.x"
react-draggable "^4.0.3"
react-test-renderer@^16.0.0-0, react-test-renderer@^16.9.0: react-test-renderer@^16.0.0-0, react-test-renderer@^16.9.0:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.13.1.tgz#de25ea358d9012606de51e012d9742e7f0deabc1" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.13.1.tgz#de25ea358d9012606de51e012d9742e7f0deabc1"