Merge remote-tracking branch 'origin/develop' into travis/room-list/resizable
This commit is contained in:
commit
3912f2d21c
22 changed files with 696 additions and 351 deletions
|
@ -122,6 +122,7 @@
|
|||
"@types/classnames": "^2.2.10",
|
||||
"@types/counterpart": "^0.18.1",
|
||||
"@types/flux": "^3.1.9",
|
||||
"@types/linkifyjs": "^2.1.3",
|
||||
"@types/lodash": "^4.14.152",
|
||||
"@types/modernizr": "^3.5.3",
|
||||
"@types/node": "^12.12.41",
|
||||
|
@ -129,6 +130,7 @@
|
|||
"@types/react": "^16.9",
|
||||
"@types/react-dom": "^16.9.8",
|
||||
"@types/react-transition-group": "^4.4.0",
|
||||
"@types/sanitize-html": "^1.23.3",
|
||||
"@types/zxcvbn": "^4.4.0",
|
||||
"babel-eslint": "^10.0.3",
|
||||
"babel-jest": "^24.9.0",
|
||||
|
|
|
@ -121,6 +121,21 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
|
|||
}
|
||||
}
|
||||
|
||||
.mx_LeftPanel2_roomListWrapper {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
|
||||
&.stickyBottom {
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
&.stickyTop {
|
||||
padding-top: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_LeftPanel2_actualRoomListContainer {
|
||||
flex-grow: 1; // fill the available space
|
||||
overflow-y: auto;
|
||||
|
|
|
@ -54,9 +54,6 @@ limitations under the License.
|
|||
max-width: 100%;
|
||||
z-index: 2; // Prioritize headers in the visible list over sticky ones
|
||||
|
||||
// Set the same background color as the room list for sticky headers
|
||||
background-color: $roomlist2-bg-color;
|
||||
|
||||
// Create a flexbox to make ordering easy
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
@ -21,6 +21,10 @@ limitations under the License.
|
|||
margin-bottom: 4px;
|
||||
padding: 4px;
|
||||
|
||||
// allow scrollIntoView to ignore the sticky headers, must match combined height of .mx_RoomSublist2_headerContainer
|
||||
scroll-margin-top: 32px;
|
||||
scroll-margin-bottom: 32px;
|
||||
|
||||
// The tile is also a flexbox row itself
|
||||
display: flex;
|
||||
|
||||
|
@ -165,6 +169,11 @@ limitations under the License.
|
|||
}
|
||||
}
|
||||
|
||||
// do not apply scroll-margin-bottom to the sublist which will not have a sticky header below it
|
||||
.mx_RoomSublist2:last-child .mx_RoomTile2 {
|
||||
scroll-margin-bottom: 0;
|
||||
}
|
||||
|
||||
// We use these both in context menus and the room tiles
|
||||
.mx_RoomTile2_iconBell::before {
|
||||
mask-image: url('$(res)/img/feather-customised/bell.svg');
|
||||
|
|
|
@ -17,3 +17,4 @@ limitations under the License.
|
|||
// Based on https://stackoverflow.com/a/53229857/3532235
|
||||
export type Without<T, U> = {[P in Exclude<keyof T, keyof U>] ? : never};
|
||||
export type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
|
||||
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
|
||||
|
|
|
@ -17,10 +17,6 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import ReplyThread from "./components/views/elements/ReplyThread";
|
||||
|
||||
import React from 'react';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import * as linkify from 'linkifyjs';
|
||||
|
@ -28,12 +24,13 @@ import linkifyMatrix from './linkify-matrix';
|
|||
import _linkifyElement from 'linkifyjs/element';
|
||||
import _linkifyString from 'linkifyjs/string';
|
||||
import classNames from 'classnames';
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import EMOJIBASE_REGEX from 'emojibase-regex';
|
||||
import url from 'url';
|
||||
|
||||
import EMOJIBASE_REGEX from 'emojibase-regex';
|
||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
|
||||
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
|
||||
import ReplyThread from "./components/views/elements/ReplyThread";
|
||||
|
||||
linkifyMatrix(linkify);
|
||||
|
||||
|
@ -64,7 +61,7 @@ const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
|
|||
* need emojification.
|
||||
* unicodeToImage uses this function.
|
||||
*/
|
||||
function mightContainEmoji(str) {
|
||||
function mightContainEmoji(str: string) {
|
||||
return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
|
||||
}
|
||||
|
||||
|
@ -74,7 +71,7 @@ function mightContainEmoji(str) {
|
|||
* @param {String} char The emoji character
|
||||
* @return {String} The shortcode (such as :thumbup:)
|
||||
*/
|
||||
export function unicodeToShortcode(char) {
|
||||
export function unicodeToShortcode(char: string) {
|
||||
const data = getEmojiFromUnicode(char);
|
||||
return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : '');
|
||||
}
|
||||
|
@ -85,7 +82,7 @@ export function unicodeToShortcode(char) {
|
|||
* @param {String} shortcode The shortcode (such as :thumbup:)
|
||||
* @return {String} The emoji character; null if none exists
|
||||
*/
|
||||
export function shortcodeToUnicode(shortcode) {
|
||||
export function shortcodeToUnicode(shortcode: string) {
|
||||
shortcode = shortcode.slice(1, shortcode.length - 1);
|
||||
const data = SHORTCODE_TO_EMOJI.get(shortcode);
|
||||
return data ? data.unicode : null;
|
||||
|
@ -100,7 +97,7 @@ export function processHtmlForSending(html: string): string {
|
|||
}
|
||||
|
||||
let contentHTML = "";
|
||||
for (let i=0; i < contentDiv.children.length; i++) {
|
||||
for (let i = 0; i < contentDiv.children.length; i++) {
|
||||
const element = contentDiv.children[i];
|
||||
if (element.tagName.toLowerCase() === 'p') {
|
||||
contentHTML += element.innerHTML;
|
||||
|
@ -122,12 +119,19 @@ export function processHtmlForSending(html: string): string {
|
|||
* Given an untrusted HTML string, return a React node with an sanitized version
|
||||
* of that HTML.
|
||||
*/
|
||||
export function sanitizedHtmlNode(insaneHtml) {
|
||||
export function sanitizedHtmlNode(insaneHtml: string) {
|
||||
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
|
||||
|
||||
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
|
||||
}
|
||||
|
||||
export function sanitizedHtmlNodeInnerText(insaneHtml: string) {
|
||||
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
|
||||
const contentDiv = document.createElement("div");
|
||||
contentDiv.innerHTML = saneHtml;
|
||||
return contentDiv.innerText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests if a URL from an untrusted source may be safely put into the DOM
|
||||
* The biggest threat here is javascript: URIs.
|
||||
|
@ -136,7 +140,7 @@ export function sanitizedHtmlNode(insaneHtml) {
|
|||
* other places we need to sanitise URLs.
|
||||
* @return true if permitted, otherwise false
|
||||
*/
|
||||
export function isUrlPermitted(inputUrl) {
|
||||
export function isUrlPermitted(inputUrl: string) {
|
||||
try {
|
||||
const parsed = url.parse(inputUrl);
|
||||
if (!parsed.protocol) return false;
|
||||
|
@ -147,9 +151,9 @@ export function isUrlPermitted(inputUrl) {
|
|||
}
|
||||
}
|
||||
|
||||
const transformTags = { // custom to matrix
|
||||
const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to matrix
|
||||
// add blank targets to all hyperlinks except vector URLs
|
||||
'a': function(tagName, attribs) {
|
||||
'a': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
||||
if (attribs.href) {
|
||||
attribs.target = '_blank'; // by default
|
||||
|
||||
|
@ -162,7 +166,7 @@ const transformTags = { // custom to matrix
|
|||
attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/
|
||||
return { tagName, attribs };
|
||||
},
|
||||
'img': function(tagName, attribs) {
|
||||
'img': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
||||
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
|
||||
// because transformTags is used _before_ we filter by allowedSchemesByTag and
|
||||
// we don't want to allow images with `https?` `src`s.
|
||||
|
@ -176,7 +180,7 @@ const transformTags = { // custom to matrix
|
|||
);
|
||||
return { tagName, attribs };
|
||||
},
|
||||
'code': function(tagName, attribs) {
|
||||
'code': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
||||
if (typeof attribs.class !== 'undefined') {
|
||||
// Filter out all classes other than ones starting with language- for syntax highlighting.
|
||||
const classes = attribs.class.split(/\s/).filter(function(cl) {
|
||||
|
@ -186,7 +190,7 @@ const transformTags = { // custom to matrix
|
|||
}
|
||||
return { tagName, attribs };
|
||||
},
|
||||
'*': function(tagName, attribs) {
|
||||
'*': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
||||
// Delete any style previously assigned, style is an allowedTag for font and span
|
||||
// because attributes are stripped after transforming
|
||||
delete attribs.style;
|
||||
|
@ -220,7 +224,7 @@ const transformTags = { // custom to matrix
|
|||
},
|
||||
};
|
||||
|
||||
const sanitizeHtmlParams = {
|
||||
const sanitizeHtmlParams: sanitizeHtml.IOptions = {
|
||||
allowedTags: [
|
||||
'font', // custom to matrix for IRC-style font coloring
|
||||
'del', // for markdown
|
||||
|
@ -247,16 +251,16 @@ const sanitizeHtmlParams = {
|
|||
};
|
||||
|
||||
// this is the same as the above except with less rewriting
|
||||
const composerSanitizeHtmlParams = Object.assign({}, sanitizeHtmlParams);
|
||||
composerSanitizeHtmlParams.transformTags = {
|
||||
'code': transformTags['code'],
|
||||
'*': transformTags['*'],
|
||||
const composerSanitizeHtmlParams: sanitizeHtml.IOptions = {
|
||||
...sanitizeHtmlParams,
|
||||
transformTags: {
|
||||
'code': transformTags['code'],
|
||||
'*': transformTags['*'],
|
||||
},
|
||||
};
|
||||
|
||||
class BaseHighlighter {
|
||||
constructor(highlightClass, highlightLink) {
|
||||
this.highlightClass = highlightClass;
|
||||
this.highlightLink = highlightLink;
|
||||
abstract class BaseHighlighter<T extends React.ReactNode> {
|
||||
constructor(public highlightClass: string, public highlightLink: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -270,47 +274,49 @@ class BaseHighlighter {
|
|||
* returns a list of results (strings for HtmlHighligher, react nodes for
|
||||
* TextHighlighter).
|
||||
*/
|
||||
applyHighlights(safeSnippet, safeHighlights) {
|
||||
public applyHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
|
||||
let lastOffset = 0;
|
||||
let offset;
|
||||
let nodes = [];
|
||||
let nodes: T[] = [];
|
||||
|
||||
const safeHighlight = safeHighlights[0];
|
||||
while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) {
|
||||
// handle preamble
|
||||
if (offset > lastOffset) {
|
||||
var subSnippet = safeSnippet.substring(lastOffset, offset);
|
||||
nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights));
|
||||
const subSnippet = safeSnippet.substring(lastOffset, offset);
|
||||
nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
|
||||
}
|
||||
|
||||
// do highlight. use the original string rather than safeHighlight
|
||||
// to preserve the original casing.
|
||||
const endOffset = offset + safeHighlight.length;
|
||||
nodes.push(this._processSnippet(safeSnippet.substring(offset, endOffset), true));
|
||||
nodes.push(this.processSnippet(safeSnippet.substring(offset, endOffset), true));
|
||||
|
||||
lastOffset = endOffset;
|
||||
}
|
||||
|
||||
// handle postamble
|
||||
if (lastOffset !== safeSnippet.length) {
|
||||
subSnippet = safeSnippet.substring(lastOffset, undefined);
|
||||
nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights));
|
||||
const subSnippet = safeSnippet.substring(lastOffset, undefined);
|
||||
nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
_applySubHighlights(safeSnippet, safeHighlights) {
|
||||
private applySubHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
|
||||
if (safeHighlights[1]) {
|
||||
// recurse into this range to check for the next set of highlight matches
|
||||
return this.applyHighlights(safeSnippet, safeHighlights.slice(1));
|
||||
} else {
|
||||
// no more highlights to be found, just return the unhighlighted string
|
||||
return [this._processSnippet(safeSnippet, false)];
|
||||
return [this.processSnippet(safeSnippet, false)];
|
||||
}
|
||||
}
|
||||
|
||||
protected abstract processSnippet(snippet: string, highlight: boolean): T;
|
||||
}
|
||||
|
||||
class HtmlHighlighter extends BaseHighlighter {
|
||||
class HtmlHighlighter extends BaseHighlighter<string> {
|
||||
/* highlight the given snippet if required
|
||||
*
|
||||
* snippet: content of the span; must have been sanitised
|
||||
|
@ -318,28 +324,23 @@ class HtmlHighlighter extends BaseHighlighter {
|
|||
*
|
||||
* returns an HTML string
|
||||
*/
|
||||
_processSnippet(snippet, highlight) {
|
||||
protected processSnippet(snippet: string, highlight: boolean): string {
|
||||
if (!highlight) {
|
||||
// nothing required here
|
||||
return snippet;
|
||||
}
|
||||
|
||||
let span = "<span class=\""+this.highlightClass+"\">"
|
||||
+ snippet + "</span>";
|
||||
let span = `<span class="${this.highlightClass}">${snippet}</span>`;
|
||||
|
||||
if (this.highlightLink) {
|
||||
span = "<a href=\""+encodeURI(this.highlightLink)+"\">"
|
||||
+span+"</a>";
|
||||
span = `<a href="${encodeURI(this.highlightLink)}">${span}</a>`;
|
||||
}
|
||||
return span;
|
||||
}
|
||||
}
|
||||
|
||||
class TextHighlighter extends BaseHighlighter {
|
||||
constructor(highlightClass, highlightLink) {
|
||||
super(highlightClass, highlightLink);
|
||||
this._key = 0;
|
||||
}
|
||||
class TextHighlighter extends BaseHighlighter<React.ReactNode> {
|
||||
private key = 0;
|
||||
|
||||
/* create a <span> node to hold the given content
|
||||
*
|
||||
|
@ -348,13 +349,12 @@ class TextHighlighter extends BaseHighlighter {
|
|||
*
|
||||
* returns a React node
|
||||
*/
|
||||
_processSnippet(snippet, highlight) {
|
||||
const key = this._key++;
|
||||
protected processSnippet(snippet: string, highlight: boolean): React.ReactNode {
|
||||
const key = this.key++;
|
||||
|
||||
let node =
|
||||
<span key={key} className={highlight ? this.highlightClass : null}>
|
||||
{ snippet }
|
||||
</span>;
|
||||
let node = <span key={key} className={highlight ? this.highlightClass : null}>
|
||||
{ snippet }
|
||||
</span>;
|
||||
|
||||
if (highlight && this.highlightLink) {
|
||||
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
|
||||
*
|
||||
|
@ -378,7 +392,7 @@ class TextHighlighter extends BaseHighlighter {
|
|||
* opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer
|
||||
* opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString)
|
||||
*/
|
||||
export function bodyToHtml(content, highlights, opts={}) {
|
||||
export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) {
|
||||
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
|
||||
let bodyHasEmoji = false;
|
||||
|
||||
|
@ -387,9 +401,9 @@ export function bodyToHtml(content, highlights, opts={}) {
|
|||
sanitizeParams = composerSanitizeHtmlParams;
|
||||
}
|
||||
|
||||
let strippedBody;
|
||||
let safeBody;
|
||||
let isDisplayedWithHtml;
|
||||
let strippedBody: string;
|
||||
let safeBody: string;
|
||||
let isDisplayedWithHtml: boolean;
|
||||
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
|
||||
// to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which
|
||||
// are interrupted by HTML tags (not that we did before) - e.g. foo<span/>bar won't get highlighted
|
||||
|
@ -471,7 +485,7 @@ export function bodyToHtml(content, highlights, opts={}) {
|
|||
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
|
||||
* @returns {string} Linkified string
|
||||
*/
|
||||
export function linkifyString(str, options = linkifyMatrix.options) {
|
||||
export function linkifyString(str: string, options = linkifyMatrix.options) {
|
||||
return _linkifyString(str, options);
|
||||
}
|
||||
|
||||
|
@ -482,7 +496,7 @@ export function linkifyString(str, options = linkifyMatrix.options) {
|
|||
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options
|
||||
* @returns {object}
|
||||
*/
|
||||
export function linkifyElement(element, options = linkifyMatrix.options) {
|
||||
export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options) {
|
||||
return _linkifyElement(element, options);
|
||||
}
|
||||
|
||||
|
@ -493,7 +507,7 @@ export function linkifyElement(element, options = linkifyMatrix.options) {
|
|||
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
|
||||
* @returns {string}
|
||||
*/
|
||||
export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.options) {
|
||||
export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) {
|
||||
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
|
||||
}
|
||||
|
||||
|
@ -504,7 +518,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.option
|
|||
* @param {Node} node
|
||||
* @returns {bool}
|
||||
*/
|
||||
export function checkBlockNode(node) {
|
||||
export function checkBlockNode(node: Node) {
|
||||
switch (node.nodeName) {
|
||||
case "H1":
|
||||
case "H2":
|
51
src/accessibility/context_menu/ContextMenuButton.tsx
Normal file
51
src/accessibility/context_menu/ContextMenuButton.tsx
Normal 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>
|
||||
);
|
||||
};
|
30
src/accessibility/context_menu/MenuGroup.tsx
Normal file
30
src/accessibility/context_menu/MenuGroup.tsx
Normal 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>;
|
||||
};
|
35
src/accessibility/context_menu/MenuItem.tsx
Normal file
35
src/accessibility/context_menu/MenuItem.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
43
src/accessibility/context_menu/MenuItemCheckbox.tsx
Normal file
43
src/accessibility/context_menu/MenuItemCheckbox.tsx
Normal 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>
|
||||
);
|
||||
};
|
43
src/accessibility/context_menu/MenuItemRadio.tsx
Normal file
43
src/accessibility/context_menu/MenuItemRadio.tsx
Normal 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>
|
||||
);
|
||||
};
|
64
src/accessibility/context_menu/StyledMenuItemCheckbox.tsx
Normal file
64
src/accessibility/context_menu/StyledMenuItemCheckbox.tsx
Normal 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>
|
||||
);
|
||||
};
|
64
src/accessibility/context_menu/StyledMenuItemRadio.tsx
Normal file
64
src/accessibility/context_menu/StyledMenuItemRadio.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -16,15 +16,12 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, {useRef, useState} from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import React, {CSSProperties, useRef, useState} from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import classNames from "classnames";
|
||||
|
||||
import {Key} from "../../Keyboard";
|
||||
import * as sdk from "../../index";
|
||||
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||
import StyledCheckbox from "../views/elements/StyledCheckbox";
|
||||
import StyledRadioButton from "../views/elements/StyledRadioButton";
|
||||
import {Writeable} from "../../@types/common";
|
||||
|
||||
// Shamelessly ripped off Modal.js. There's probably a better way
|
||||
// of doing reusable widgets like dialog boxes & menus where we go and
|
||||
|
@ -32,8 +29,8 @@ import StyledRadioButton from "../views/elements/StyledRadioButton";
|
|||
|
||||
const ContextualMenuContainerId = "mx_ContextualMenu_Container";
|
||||
|
||||
function getOrCreateContainer() {
|
||||
let container = document.getElementById(ContextualMenuContainerId);
|
||||
function getOrCreateContainer(): HTMLDivElement {
|
||||
let container = document.getElementById(ContextualMenuContainerId) as HTMLDivElement;
|
||||
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
|
@ -45,50 +42,70 @@ function getOrCreateContainer() {
|
|||
}
|
||||
|
||||
const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
|
||||
|
||||
interface IPosition {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
}
|
||||
|
||||
export enum ChevronFace {
|
||||
Top = "top",
|
||||
Bottom = "bottom",
|
||||
Left = "left",
|
||||
Right = "right",
|
||||
None = "none",
|
||||
}
|
||||
|
||||
interface IProps extends IPosition {
|
||||
menuWidth?: number;
|
||||
menuHeight?: number;
|
||||
|
||||
chevronOffset?: number;
|
||||
chevronFace?: ChevronFace;
|
||||
|
||||
menuPaddingTop?: number;
|
||||
menuPaddingBottom?: number;
|
||||
menuPaddingLeft?: number;
|
||||
menuPaddingRight?: number;
|
||||
|
||||
zIndex?: number;
|
||||
|
||||
// If true, insert an invisible screen-sized element behind the menu that when clicked will close it.
|
||||
hasBackground?: boolean;
|
||||
// whether this context menu should be focus managed. If false it must handle itself
|
||||
managed?: boolean;
|
||||
|
||||
// Function to be called on menu close
|
||||
onFinished();
|
||||
// on resize callback
|
||||
windowResize?();
|
||||
}
|
||||
|
||||
interface IState {
|
||||
contextMenuElem: HTMLDivElement;
|
||||
}
|
||||
|
||||
// Generic ContextMenu Portal wrapper
|
||||
// all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1}
|
||||
// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
|
||||
export class ContextMenu extends React.Component {
|
||||
static propTypes = {
|
||||
top: PropTypes.number,
|
||||
bottom: PropTypes.number,
|
||||
left: PropTypes.number,
|
||||
right: PropTypes.number,
|
||||
menuWidth: PropTypes.number,
|
||||
menuHeight: PropTypes.number,
|
||||
chevronOffset: PropTypes.number,
|
||||
chevronFace: PropTypes.string, // top, bottom, left, right or none
|
||||
// Function to be called on menu close
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
menuPaddingTop: PropTypes.number,
|
||||
menuPaddingRight: PropTypes.number,
|
||||
menuPaddingBottom: PropTypes.number,
|
||||
menuPaddingLeft: PropTypes.number,
|
||||
zIndex: PropTypes.number,
|
||||
|
||||
// If true, insert an invisible screen-sized element behind the
|
||||
// menu that when clicked will close it.
|
||||
hasBackground: PropTypes.bool,
|
||||
|
||||
// on resize callback
|
||||
windowResize: PropTypes.func,
|
||||
|
||||
managed: PropTypes.bool, // whether this context menu should be focus managed. If false it must handle itself
|
||||
};
|
||||
export class ContextMenu extends React.PureComponent<IProps, IState> {
|
||||
private initialFocus: HTMLElement;
|
||||
|
||||
static defaultProps = {
|
||||
hasBackground: true,
|
||||
managed: true,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
contextMenuElem: null,
|
||||
};
|
||||
|
||||
// persist what had focus when we got initialized so we can return it after
|
||||
this.initialFocus = document.activeElement;
|
||||
this.initialFocus = document.activeElement as HTMLElement;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
|
@ -96,7 +113,7 @@ export class ContextMenu extends React.Component {
|
|||
this.initialFocus.focus();
|
||||
}
|
||||
|
||||
collectContextMenuRect = (element) => {
|
||||
private collectContextMenuRect = (element) => {
|
||||
// We don't need to clean up when unmounting, so ignore
|
||||
if (!element) return;
|
||||
|
||||
|
@ -113,7 +130,7 @@ export class ContextMenu extends React.Component {
|
|||
});
|
||||
};
|
||||
|
||||
onContextMenu = (e) => {
|
||||
private onContextMenu = (e) => {
|
||||
if (this.props.onFinished) {
|
||||
this.props.onFinished();
|
||||
|
||||
|
@ -136,20 +153,20 @@ export class ContextMenu extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
onContextMenuPreventBubbling = (e) => {
|
||||
private onContextMenuPreventBubbling = (e) => {
|
||||
// stop propagation so that any context menu handlers don't leak out of this context menu
|
||||
// but do not inhibit the default browser menu
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Prevent clicks on the background from going through to the component which opened the menu.
|
||||
_onFinished = (ev: InputEvent) => {
|
||||
private onFinished = (ev: React.MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (this.props.onFinished) this.props.onFinished();
|
||||
};
|
||||
|
||||
_onMoveFocus = (element, up) => {
|
||||
private onMoveFocus = (element: Element, up: boolean) => {
|
||||
let descending = false; // are we currently descending or ascending through the DOM tree?
|
||||
|
||||
do {
|
||||
|
@ -183,25 +200,25 @@ export class ContextMenu extends React.Component {
|
|||
} while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role")));
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
(element as HTMLElement).focus();
|
||||
}
|
||||
};
|
||||
|
||||
_onMoveFocusHomeEnd = (element, up) => {
|
||||
private onMoveFocusHomeEnd = (element: Element, up: boolean) => {
|
||||
let results = element.querySelectorAll('[role^="menuitem"]');
|
||||
if (!results) {
|
||||
results = element.querySelectorAll('[tab-index]');
|
||||
}
|
||||
if (results && results.length) {
|
||||
if (up) {
|
||||
results[0].focus();
|
||||
(results[0] as HTMLElement).focus();
|
||||
} else {
|
||||
results[results.length - 1].focus();
|
||||
(results[results.length - 1] as HTMLElement).focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
_onKeyDown = (ev) => {
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
if (!this.props.managed) {
|
||||
if (ev.key === Key.ESCAPE) {
|
||||
this.props.onFinished();
|
||||
|
@ -219,16 +236,16 @@ export class ContextMenu extends React.Component {
|
|||
this.props.onFinished();
|
||||
break;
|
||||
case Key.ARROW_UP:
|
||||
this._onMoveFocus(ev.target, true);
|
||||
this.onMoveFocus(ev.target as Element, true);
|
||||
break;
|
||||
case Key.ARROW_DOWN:
|
||||
this._onMoveFocus(ev.target, false);
|
||||
this.onMoveFocus(ev.target as Element, false);
|
||||
break;
|
||||
case Key.HOME:
|
||||
this._onMoveFocusHomeEnd(this.state.contextMenuElem, true);
|
||||
this.onMoveFocusHomeEnd(this.state.contextMenuElem, true);
|
||||
break;
|
||||
case Key.END:
|
||||
this._onMoveFocusHomeEnd(this.state.contextMenuElem, false);
|
||||
this.onMoveFocusHomeEnd(this.state.contextMenuElem, false);
|
||||
break;
|
||||
default:
|
||||
handled = false;
|
||||
|
@ -241,9 +258,8 @@ export class ContextMenu extends React.Component {
|
|||
}
|
||||
};
|
||||
|
||||
renderMenu(hasBackground=this.props.hasBackground) {
|
||||
const position = {};
|
||||
let chevronFace = null;
|
||||
protected renderMenu(hasBackground = this.props.hasBackground) {
|
||||
const position: Partial<Writeable<DOMRect>> = {};
|
||||
const props = this.props;
|
||||
|
||||
if (props.top) {
|
||||
|
@ -252,23 +268,24 @@ export class ContextMenu extends React.Component {
|
|||
position.bottom = props.bottom;
|
||||
}
|
||||
|
||||
let chevronFace: ChevronFace;
|
||||
if (props.left) {
|
||||
position.left = props.left;
|
||||
chevronFace = 'left';
|
||||
chevronFace = ChevronFace.Left;
|
||||
} else {
|
||||
position.right = props.right;
|
||||
chevronFace = 'right';
|
||||
chevronFace = ChevronFace.Right;
|
||||
}
|
||||
|
||||
const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null;
|
||||
|
||||
const chevronOffset = {};
|
||||
const chevronOffset: CSSProperties = {};
|
||||
if (props.chevronFace) {
|
||||
chevronFace = props.chevronFace;
|
||||
}
|
||||
const hasChevron = chevronFace && chevronFace !== "none";
|
||||
const hasChevron = chevronFace && chevronFace !== ChevronFace.None;
|
||||
|
||||
if (chevronFace === 'top' || chevronFace === 'bottom') {
|
||||
if (chevronFace === ChevronFace.Top || chevronFace === ChevronFace.Bottom) {
|
||||
chevronOffset.left = props.chevronOffset;
|
||||
} else if (position.top !== undefined) {
|
||||
const target = position.top;
|
||||
|
@ -298,13 +315,13 @@ export class ContextMenu extends React.Component {
|
|||
'mx_ContextualMenu_right': !hasChevron && position.right,
|
||||
'mx_ContextualMenu_top': !hasChevron && position.top,
|
||||
'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
|
||||
'mx_ContextualMenu_withChevron_left': chevronFace === 'left',
|
||||
'mx_ContextualMenu_withChevron_right': chevronFace === 'right',
|
||||
'mx_ContextualMenu_withChevron_top': chevronFace === 'top',
|
||||
'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom',
|
||||
'mx_ContextualMenu_withChevron_left': chevronFace === ChevronFace.Left,
|
||||
'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
|
||||
'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
|
||||
'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom,
|
||||
});
|
||||
|
||||
const menuStyle = {};
|
||||
const menuStyle: CSSProperties = {};
|
||||
if (props.menuWidth) {
|
||||
menuStyle.width = props.menuWidth;
|
||||
}
|
||||
|
@ -335,13 +352,28 @@ export class ContextMenu extends React.Component {
|
|||
let background;
|
||||
if (hasBackground) {
|
||||
background = (
|
||||
<div className="mx_ContextualMenu_background" style={wrapperStyle} onClick={this._onFinished} onContextMenu={this.onContextMenu} />
|
||||
<div
|
||||
className="mx_ContextualMenu_background"
|
||||
style={wrapperStyle}
|
||||
onClick={this.onFinished}
|
||||
onContextMenu={this.onContextMenu}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_ContextualMenu_wrapper" style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown} onContextMenu={this.onContextMenuPreventBubbling}>
|
||||
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} role={this.props.managed ? "menu" : undefined}>
|
||||
<div
|
||||
className="mx_ContextualMenu_wrapper"
|
||||
style={{...position, ...wrapperStyle}}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onContextMenu={this.onContextMenuPreventBubbling}
|
||||
>
|
||||
<div
|
||||
className={menuClasses}
|
||||
style={menuStyle}
|
||||
ref={this.collectContextMenuRect}
|
||||
role={this.props.managed ? "menu" : undefined}
|
||||
>
|
||||
{ chevron }
|
||||
{ props.children }
|
||||
</div>
|
||||
|
@ -350,195 +382,13 @@ export class ContextMenu extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
render(): React.ReactChild {
|
||||
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
|
||||
}
|
||||
}
|
||||
|
||||
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
|
||||
export const ContextMenuButton = ({ label, isExpanded, children, onClick, onContextMenu, ...props }) => {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
return (
|
||||
<AccessibleButton
|
||||
{...props}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu || onClick}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-haspopup={true}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
{ children }
|
||||
</AccessibleButton>
|
||||
);
|
||||
};
|
||||
ContextMenuButton.propTypes = {
|
||||
...AccessibleButton.propTypes,
|
||||
label: PropTypes.string,
|
||||
isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open
|
||||
};
|
||||
|
||||
// Semantic component for representing a role=menuitem
|
||||
export const MenuItem = ({children, label, ...props}) => {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
return (
|
||||
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={label}>
|
||||
{ children }
|
||||
</AccessibleButton>
|
||||
);
|
||||
};
|
||||
MenuItem.propTypes = {
|
||||
...AccessibleButton.propTypes,
|
||||
label: PropTypes.string, // optional
|
||||
className: PropTypes.string, // optional
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
// Semantic component for representing a role=group for grouping menu radios/checkboxes
|
||||
export const MenuGroup = ({children, label, ...props}) => {
|
||||
return <div {...props} role="group" aria-label={label}>
|
||||
{ children }
|
||||
</div>;
|
||||
};
|
||||
MenuGroup.propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
className: PropTypes.string, // optional
|
||||
};
|
||||
|
||||
// Semantic component for representing a role=menuitemcheckbox
|
||||
export const MenuItemCheckbox = ({children, label, active=false, disabled=false, ...props}) => {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
return (
|
||||
<AccessibleButton {...props} role="menuitemcheckbox" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
|
||||
{ children }
|
||||
</AccessibleButton>
|
||||
);
|
||||
};
|
||||
MenuItemCheckbox.propTypes = {
|
||||
...AccessibleButton.propTypes,
|
||||
label: PropTypes.string, // optional
|
||||
active: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.bool, // optional
|
||||
className: PropTypes.string, // optional
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
// Semantic component for representing a styled role=menuitemcheckbox
|
||||
export const StyledMenuItemCheckbox = ({children, label, onChange, onClose, checked, disabled=false, ...props}) => {
|
||||
const onKeyDown = (e) => {
|
||||
if (e.key === Key.ENTER || e.key === Key.SPACE) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onChange();
|
||||
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
|
||||
if (e.key === Key.ENTER) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
const onKeyUp = (e) => {
|
||||
// prevent the input default handler as we handle it on keydown to match
|
||||
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
|
||||
if (e.key === Key.SPACE || e.key === Key.ENTER) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<StyledCheckbox
|
||||
{...props}
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={checked}
|
||||
checked={checked}
|
||||
aria-disabled={disabled}
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
>
|
||||
{ children }
|
||||
</StyledCheckbox>
|
||||
);
|
||||
};
|
||||
StyledMenuItemCheckbox.propTypes = {
|
||||
...StyledCheckbox.propTypes,
|
||||
label: PropTypes.string, // optional
|
||||
checked: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.bool, // optional
|
||||
className: PropTypes.string, // optional
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired, // gets called after onChange on Key.ENTER
|
||||
};
|
||||
|
||||
// Semantic component for representing a role=menuitemradio
|
||||
export const MenuItemRadio = ({children, label, active=false, disabled=false, ...props}) => {
|
||||
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
|
||||
return (
|
||||
<AccessibleButton {...props} role="menuitemradio" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
|
||||
{ children }
|
||||
</AccessibleButton>
|
||||
);
|
||||
};
|
||||
MenuItemRadio.propTypes = {
|
||||
...AccessibleButton.propTypes,
|
||||
label: PropTypes.string, // optional
|
||||
active: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.bool, // optional
|
||||
className: PropTypes.string, // optional
|
||||
onClick: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
// Semantic component for representing a styled role=menuitemradio
|
||||
export const StyledMenuItemRadio = ({children, label, onChange, onClose, checked=false, disabled=false, ...props}) => {
|
||||
const onKeyDown = (e) => {
|
||||
if (e.key === Key.ENTER || e.key === Key.SPACE) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onChange();
|
||||
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
|
||||
if (e.key === Key.ENTER) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
const onKeyUp = (e) => {
|
||||
// prevent the input default handler as we handle it on keydown to match
|
||||
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
|
||||
if (e.key === Key.SPACE || e.key === Key.ENTER) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
return (
|
||||
<StyledRadioButton
|
||||
{...props}
|
||||
role="menuitemradio"
|
||||
aria-checked={checked}
|
||||
checked={checked}
|
||||
aria-disabled={disabled}
|
||||
tabIndex={-1}
|
||||
aria-label={label}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
>
|
||||
{ children }
|
||||
</StyledRadioButton>
|
||||
);
|
||||
};
|
||||
StyledMenuItemRadio.propTypes = {
|
||||
...StyledMenuItemRadio.propTypes,
|
||||
label: PropTypes.string, // optional
|
||||
checked: PropTypes.bool.isRequired,
|
||||
disabled: PropTypes.bool, // optional
|
||||
className: PropTypes.string, // optional
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired, // gets called after onChange on Key.ENTER
|
||||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
|
||||
export const toRightOf = (elementRect, chevronOffset=12) => {
|
||||
export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => {
|
||||
const left = elementRect.right + window.pageXOffset + 3;
|
||||
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
|
||||
top -= chevronOffset + 8; // where 8 is half the height of the chevron
|
||||
|
@ -546,8 +396,8 @@ export const toRightOf = (elementRect, chevronOffset=12) => {
|
|||
};
|
||||
|
||||
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
|
||||
export const aboveLeftOf = (elementRect, chevronFace="none") => {
|
||||
const menuOptions = { chevronFace };
|
||||
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None) => {
|
||||
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
|
||||
|
||||
const buttonRight = elementRect.right + window.pageXOffset;
|
||||
const buttonBottom = elementRect.bottom + window.pageYOffset;
|
||||
|
@ -605,3 +455,12 @@ export function createMenu(ElementClass, props) {
|
|||
|
||||
return {close: onFinished};
|
||||
}
|
||||
|
||||
// re-export the semantic helper components for simplicity
|
||||
export {ContextMenuButton} from "../../accessibility/context_menu/ContextMenuButton";
|
||||
export {MenuGroup} from "../../accessibility/context_menu/MenuGroup";
|
||||
export {MenuItem} from "../../accessibility/context_menu/MenuItem";
|
||||
export {MenuItemCheckbox} from "../../accessibility/context_menu/MenuItemCheckbox";
|
||||
export {MenuItemRadio} from "../../accessibility/context_menu/MenuItemRadio";
|
||||
export {StyledMenuItemCheckbox} from "../../accessibility/context_menu/StyledMenuItemCheckbox";
|
||||
export {StyledMenuItemRadio} from "../../accessibility/context_menu/StyledMenuItemRadio";
|
|
@ -21,6 +21,7 @@ import classNames from "classnames";
|
|||
import dis from "../../dispatcher/dispatcher";
|
||||
import { _t } from "../../languageHandler";
|
||||
import RoomList2 from "../views/rooms/RoomList2";
|
||||
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist2";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import UserMenu from "./UserMenu";
|
||||
import RoomSearch from "./RoomSearch";
|
||||
|
@ -135,7 +136,6 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
const bottom = rlRect.bottom;
|
||||
const top = rlRect.top;
|
||||
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist2");
|
||||
const headerHeight = 32; // Note: must match the CSS!
|
||||
const headerRightMargin = 24; // calculated from margins and widths to align with non-sticky tiles
|
||||
|
||||
const headerStickyWidth = rlRect.width - headerRightMargin;
|
||||
|
@ -146,23 +146,27 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
const slRect = sublist.getBoundingClientRect();
|
||||
|
||||
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist2_stickable");
|
||||
header.style.removeProperty("display"); // always clear display:none first
|
||||
|
||||
if (slRect.top + headerHeight > bottom && !gotBottom) {
|
||||
if (slRect.top + HEADER_HEIGHT > bottom && !gotBottom) {
|
||||
header.classList.add("mx_RoomSublist2_headerContainer_sticky");
|
||||
header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom");
|
||||
header.style.width = `${headerStickyWidth}px`;
|
||||
header.style.removeProperty("top");
|
||||
gotBottom = true;
|
||||
} else if ((slRect.top - (headerHeight / 3)) < top) {
|
||||
} else if (((slRect.top - (HEADER_HEIGHT * 0.6) + HEADER_HEIGHT) < top) || sublist === sublists[0]) {
|
||||
// the header should become sticky once it is 60% or less out of view at the top.
|
||||
// We also add HEADER_HEIGHT because the sticky header is put above the scrollable area,
|
||||
// into the padding of .mx_LeftPanel2_roomListWrapper,
|
||||
// by subtracting HEADER_HEIGHT from the top below.
|
||||
// We also always try to make the first sublist header sticky.
|
||||
header.classList.add("mx_RoomSublist2_headerContainer_sticky");
|
||||
header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
|
||||
header.style.width = `${headerStickyWidth}px`;
|
||||
header.style.top = `${rlRect.top}px`;
|
||||
header.style.top = `${rlRect.top - HEADER_HEIGHT}px`;
|
||||
if (lastTopHeader) {
|
||||
lastTopHeader.style.display = "none";
|
||||
}
|
||||
// first unset it, if set in last iteration
|
||||
header.style.removeProperty("display");
|
||||
lastTopHeader = header;
|
||||
} else {
|
||||
header.classList.remove("mx_RoomSublist2_headerContainer_sticky");
|
||||
|
@ -172,6 +176,26 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
header.style.removeProperty("top");
|
||||
}
|
||||
}
|
||||
|
||||
// add appropriate sticky classes to wrapper so it has
|
||||
// the necessary top/bottom padding to put the sticky header in
|
||||
const listWrapper = list.parentElement;
|
||||
if (gotBottom) {
|
||||
listWrapper.classList.add("stickyBottom");
|
||||
} else {
|
||||
listWrapper.classList.remove("stickyBottom");
|
||||
}
|
||||
if (lastTopHeader) {
|
||||
listWrapper.classList.add("stickyTop");
|
||||
} else {
|
||||
listWrapper.classList.remove("stickyTop");
|
||||
}
|
||||
|
||||
// ensure scroll doesn't go above the gap left by the header of
|
||||
// the first sublist always being sticky if no other header is sticky
|
||||
if (list.scrollTop < HEADER_HEIGHT) {
|
||||
list.scrollTop = HEADER_HEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Improve header reliability: https://github.com/vector-im/riot-web/issues/14232
|
||||
|
@ -325,15 +349,17 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
|||
<aside className="mx_LeftPanel2_roomListContainer">
|
||||
{this.renderHeader()}
|
||||
{this.renderSearchExplore()}
|
||||
<div
|
||||
className={roomListClasses}
|
||||
onScroll={this.onScroll}
|
||||
ref={this.listContainerRef}
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
// overflow:scroll;, so force it out of tab order.
|
||||
tabIndex={-1}
|
||||
>
|
||||
{roomList}
|
||||
<div className="mx_LeftPanel2_roomListWrapper">
|
||||
<div
|
||||
className={roomListClasses}
|
||||
onScroll={this.onScroll}
|
||||
ref={this.listContainerRef}
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
// overflow:scroll;, so force it out of tab order.
|
||||
tabIndex={-1}
|
||||
>
|
||||
{roomList}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
|
|
@ -14,14 +14,13 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import React, { createRef } from "react";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import { createRef } from "react";
|
||||
import { _t } from "../../languageHandler";
|
||||
import {ContextMenu, ContextMenuButton, MenuItem} from "./ContextMenu";
|
||||
import { ChevronFace, ContextMenu, ContextMenuButton, MenuItem } from "./ContextMenu";
|
||||
import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
|
||||
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
|
||||
import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog";
|
||||
|
@ -122,7 +121,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onOpenMenuClick = (ev: InputEvent) => {
|
||||
private onOpenMenuClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const target = ev.target as HTMLButtonElement;
|
||||
|
@ -235,7 +234,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
|
|||
|
||||
return (
|
||||
<ContextMenu
|
||||
chevronFace="none"
|
||||
chevronFace={ChevronFace.None}
|
||||
// -20 to overlap the context menu by just over the width of the `...` icon and make it look connected
|
||||
left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 20}
|
||||
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
|
||||
|
|
|
@ -64,7 +64,6 @@ export default function AccessibleButton({
|
|||
className,
|
||||
...restProps
|
||||
}: IProps) {
|
||||
|
||||
const newProps: IAccessibleButtonProps = restProps;
|
||||
if (!disabled) {
|
||||
newProps.onClick = onClick;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
Copyright 2016 OpenMarket Ltd
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
@ -23,6 +23,7 @@ import { _t } from '../../../languageHandler';
|
|||
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
|
||||
import * as sdk from "../../../index";
|
||||
import {MatrixEvent} from "matrix-js-sdk";
|
||||
import {isValid3pidInvite} from "../../../RoomInvite";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'MemberEventListSummary',
|
||||
|
@ -284,6 +285,9 @@ export default createReactClass({
|
|||
_getTransition: function(e) {
|
||||
if (e.mxEvent.getType() === 'm.room.third_party_invite') {
|
||||
// Handle 3pid invites the same as invites so they get bundled together
|
||||
if (!isValid3pidInvite(e.mxEvent)) {
|
||||
return 'invite_withdrawal';
|
||||
}
|
||||
return 'invited';
|
||||
}
|
||||
|
||||
|
|
|
@ -26,6 +26,7 @@ import AccessibleButton from "../../views/elements/AccessibleButton";
|
|||
import RoomTile2 from "./RoomTile2";
|
||||
import { ListLayout } from "../../../stores/room-list/ListLayout";
|
||||
import {
|
||||
ChevronFace,
|
||||
ContextMenu,
|
||||
ContextMenuButton,
|
||||
StyledMenuItemCheckbox,
|
||||
|
@ -39,6 +40,8 @@ import NotificationBadge from "./NotificationBadge";
|
|||
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import { Key } from "../../../Keyboard";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import {ActionPayload} from "../../../dispatcher/payloads";
|
||||
import { Enable, Resizable } from "re-resizable";
|
||||
import { Direction } from "re-resizable/lib/resizer";
|
||||
import { polyfillTouchEvent } from "../../../@types/polyfill";
|
||||
|
@ -56,6 +59,7 @@ import { polyfillTouchEvent } from "../../../@types/polyfill";
|
|||
|
||||
const SHOW_N_BUTTON_HEIGHT = 32; // As defined by CSS
|
||||
const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS
|
||||
export const HEADER_HEIGHT = 32; // As defined by CSS
|
||||
|
||||
const MAX_PADDING_HEIGHT = SHOW_N_BUTTON_HEIGHT + RESIZE_HANDLE_HEIGHT;
|
||||
|
||||
|
@ -93,6 +97,7 @@ interface IState {
|
|||
export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||
private headerButton = createRef<HTMLDivElement>();
|
||||
private sublistRef = createRef<HTMLDivElement>();
|
||||
private dispatcherRef: string;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
@ -103,6 +108,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
isResizing: false,
|
||||
};
|
||||
this.state.notificationState.setRooms(this.props.rooms);
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
}
|
||||
|
||||
private get numTiles(): number {
|
||||
|
@ -121,8 +127,29 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
|
||||
public componentWillUnmount() {
|
||||
this.state.notificationState.destroy();
|
||||
defaultDispatcher.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
if (payload.action === "view_room" && payload.show_room_tile && this.props.rooms) {
|
||||
// XXX: we have to do this a tick later because we have incorrect intermediate props during a room change
|
||||
// where we lose the room we are changing from temporarily and then it comes back in an update right after.
|
||||
setImmediate(() => {
|
||||
const isCollapsed = this.props.layout.isCollapsed;
|
||||
const roomIndex = this.props.rooms.findIndex((r) => r.roomId === payload.room_id);
|
||||
|
||||
if (isCollapsed && roomIndex > -1) {
|
||||
this.toggleCollapsed();
|
||||
}
|
||||
// extend the visible section to include the room if it is entirely invisible
|
||||
if (roomIndex >= this.numVisibleTiles) {
|
||||
this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(roomIndex + 1, MAX_PADDING_HEIGHT);
|
||||
this.forceUpdate(); // because the layout doesn't trigger a re-render
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private onAddRoom = (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.props.onAddRoom) this.props.onAddRoom();
|
||||
|
@ -180,7 +207,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onOpenMenuClick = (ev: InputEvent) => {
|
||||
private onOpenMenuClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const target = ev.target as HTMLButtonElement;
|
||||
|
@ -252,7 +279,11 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
|
||||
const possibleSticky = target.parentElement;
|
||||
const sublist = possibleSticky.parentElement.parentElement;
|
||||
if (possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky')) {
|
||||
const list = sublist.parentElement.parentElement;
|
||||
// the scrollTop is capped at the height of the header in LeftPanel2
|
||||
const isAtTop = list.scrollTop <= HEADER_HEIGHT;
|
||||
const isSticky = possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky');
|
||||
if (isSticky && !isAtTop) {
|
||||
// is sticky - jump to list
|
||||
sublist.scrollIntoView({behavior: 'smooth'});
|
||||
} else {
|
||||
|
@ -384,7 +415,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
|
||||
contextMenu = (
|
||||
<ContextMenu
|
||||
chevronFace="none"
|
||||
chevronFace={ChevronFace.None}
|
||||
left={this.state.contextMenuPosition.left}
|
||||
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
|
||||
onFinished={this.onCloseMenu}
|
||||
|
|
|
@ -17,7 +17,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import React, {createRef} from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import classNames from "classnames";
|
||||
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||
|
@ -27,6 +27,7 @@ import { Key } from "../../../Keyboard";
|
|||
import ActiveRoomObserver from "../../../ActiveRoomObserver";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import {
|
||||
ChevronFace,
|
||||
ContextMenu,
|
||||
ContextMenuButton,
|
||||
MenuItemRadio,
|
||||
|
@ -50,6 +51,8 @@ import { INotificationState } from "../../../stores/notifications/INotificationS
|
|||
import NotificationBadge from "./NotificationBadge";
|
||||
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
|
||||
import { Volume } from "../../../RoomNotifsTypes";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import {ActionPayload} from "../../../dispatcher/payloads";
|
||||
|
||||
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
|
||||
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
|
||||
|
@ -87,7 +90,7 @@ const contextMenuBelow = (elementRect: PartialDOMRect) => {
|
|||
// align the context menu's icons with the icon which opened the context menu
|
||||
const left = elementRect.left + window.pageXOffset - 9;
|
||||
const top = elementRect.bottom + window.pageYOffset + 17;
|
||||
const chevronFace = "none";
|
||||
const chevronFace = ChevronFace.None;
|
||||
return {left, top, chevronFace};
|
||||
};
|
||||
|
||||
|
@ -118,6 +121,10 @@ const NotifOption: React.FC<INotifOptionProps> = ({active, onClick, iconClassNam
|
|||
};
|
||||
|
||||
export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||
private dispatcherRef: string;
|
||||
private roomTileRef = createRef<HTMLDivElement>();
|
||||
// TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
|
@ -130,6 +137,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
|
||||
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||
}
|
||||
|
||||
private get showContextMenu(): boolean {
|
||||
|
@ -140,12 +148,36 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
return !this.props.isMinimized && this.props.showMessagePreview;
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
// when we're first rendered (or our sublist is expanded) make sure we are visible if we're active
|
||||
if (this.state.selected) {
|
||||
this.scrollIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.props.room) {
|
||||
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
|
||||
}
|
||||
defaultDispatcher.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
private onAction = (payload: ActionPayload) => {
|
||||
if (payload.action === "view_room" && payload.room_id === this.props.room.roomId && payload.show_room_tile) {
|
||||
setImmediate(() => {
|
||||
this.scrollIntoView();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private scrollIntoView = () => {
|
||||
if (!this.roomTileRef.current) return;
|
||||
this.roomTileRef.current.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "auto",
|
||||
});
|
||||
};
|
||||
|
||||
private onTileMouseEnter = () => {
|
||||
this.setState({hover: true});
|
||||
};
|
||||
|
@ -159,7 +191,6 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
ev.stopPropagation();
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
// TODO: Support show_room_tile in new room list: https://github.com/vector-im/riot-web/issues/14233
|
||||
show_room_tile: true, // make sure the room is visible in the list
|
||||
room_id: this.props.room.roomId,
|
||||
clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)),
|
||||
|
@ -170,7 +201,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
this.setState({selected: isActive});
|
||||
};
|
||||
|
||||
private onNotificationsMenuOpenClick = (ev: InputEvent) => {
|
||||
private onNotificationsMenuOpenClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const target = ev.target as HTMLButtonElement;
|
||||
|
@ -181,7 +212,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
this.setState({notificationsMenuPosition: null});
|
||||
};
|
||||
|
||||
private onGeneralMenuOpenClick = (ev: InputEvent) => {
|
||||
private onGeneralMenuOpenClick = (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const target = ev.target as HTMLButtonElement;
|
||||
|
@ -477,7 +508,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<RovingTabIndexWrapper>
|
||||
<RovingTabIndexWrapper inputRef={this.roomTileRef}>
|
||||
{({onFocus, isActive, ref}) =>
|
||||
<AccessibleButton
|
||||
onFocus={onFocus}
|
||||
|
|
|
@ -20,6 +20,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
|||
import { _t } from "../../../languageHandler";
|
||||
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
|
||||
import ReplyThread from "../../../components/views/elements/ReplyThread";
|
||||
import { sanitizedHtmlNodeInnerText } from "../../../HtmlUtils";
|
||||
|
||||
export class MessageEventPreview implements IPreview {
|
||||
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
|
||||
|
@ -36,14 +37,27 @@ export class MessageEventPreview implements IPreview {
|
|||
const msgtype = eventContent['msgtype'];
|
||||
if (!body || !msgtype) return null; // invalid event, no preview
|
||||
|
||||
const hasHtml = eventContent.format === "org.matrix.custom.html" && eventContent.formatted_body;
|
||||
if (hasHtml) {
|
||||
body = eventContent.formatted_body;
|
||||
}
|
||||
|
||||
// XXX: Newer relations have a getRelation() function which is not compatible with replies.
|
||||
const mRelatesTo = event.getWireContent()['m.relates_to'];
|
||||
if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
|
||||
// If this is a reply, get the real reply and use that
|
||||
body = (ReplyThread.stripPlainReply(body) || '').trim();
|
||||
if (hasHtml) {
|
||||
body = (ReplyThread.stripHTMLReply(body) || '').trim();
|
||||
} else {
|
||||
body = (ReplyThread.stripPlainReply(body) || '').trim();
|
||||
}
|
||||
if (!body) return null; // invalid event, no preview
|
||||
}
|
||||
|
||||
if (hasHtml) {
|
||||
body = sanitizedHtmlNodeInnerText(body);
|
||||
}
|
||||
|
||||
if (msgtype === 'm.emote') {
|
||||
return _t("%(senderName)s %(emote)s", {senderName: getSenderName(event), emote: body});
|
||||
}
|
||||
|
|
14
yarn.lock
14
yarn.lock
|
@ -1308,6 +1308,13 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339"
|
||||
integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==
|
||||
|
||||
"@types/linkifyjs@^2.1.3":
|
||||
version "2.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/linkifyjs/-/linkifyjs-2.1.3.tgz#80195c3c88c5e75d9f660e3046ce4a42be2c2fa4"
|
||||
integrity sha512-V3Xt9wgaOvDPXcpOy3dC8qXCxy3cs0Lr/Hqgd9Bi6m3sf/vpbpTtfmVR0LJklrqYEjaAmc7e3Xh/INT2rCAKjQ==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
"@types/lodash@^4.14.152":
|
||||
version "4.14.155"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.155.tgz#e2b4514f46a261fd11542e47519c20ebce7bc23a"
|
||||
|
@ -1372,6 +1379,13 @@
|
|||
"@types/prop-types" "*"
|
||||
csstype "^2.2.0"
|
||||
|
||||
"@types/sanitize-html@^1.23.3":
|
||||
version "1.23.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-1.23.3.tgz#26527783aba3bf195ad8a3c3e51bd3713526fc0d"
|
||||
integrity sha512-Isg8N0ifKdDq6/kaNlIcWfapDXxxquMSk2XC5THsOICRyOIhQGds95XH75/PL/g9mExi4bL8otIqJM/Wo96WxA==
|
||||
dependencies:
|
||||
htmlparser2 "^4.1.0"
|
||||
|
||||
"@types/stack-utils@^1.0.1":
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
|
||||
|
|
Loading…
Reference in a new issue