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/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",
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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');
|
||||||
|
|
|
@ -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] };
|
||||||
|
|
|
@ -17,10 +17,6 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
import ReplyThread from "./components/views/elements/ReplyThread";
|
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import sanitizeHtml from 'sanitize-html';
|
import sanitizeHtml from 'sanitize-html';
|
||||||
import * as linkify from 'linkifyjs';
|
import * as linkify from 'linkifyjs';
|
||||||
|
@ -28,12 +24,13 @@ import linkifyMatrix from './linkify-matrix';
|
||||||
import _linkifyElement from 'linkifyjs/element';
|
import _linkifyElement from 'linkifyjs/element';
|
||||||
import _linkifyString from 'linkifyjs/string';
|
import _linkifyString from 'linkifyjs/string';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import {MatrixClientPeg} from './MatrixClientPeg';
|
import EMOJIBASE_REGEX from 'emojibase-regex';
|
||||||
import url from 'url';
|
import url from 'url';
|
||||||
|
|
||||||
import EMOJIBASE_REGEX from 'emojibase-regex';
|
import {MatrixClientPeg} from './MatrixClientPeg';
|
||||||
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
|
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
|
||||||
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
|
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
|
||||||
|
import ReplyThread from "./components/views/elements/ReplyThread";
|
||||||
|
|
||||||
linkifyMatrix(linkify);
|
linkifyMatrix(linkify);
|
||||||
|
|
||||||
|
@ -64,7 +61,7 @@ const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
|
||||||
* need emojification.
|
* need emojification.
|
||||||
* unicodeToImage uses this function.
|
* unicodeToImage uses this function.
|
||||||
*/
|
*/
|
||||||
function mightContainEmoji(str) {
|
function mightContainEmoji(str: string) {
|
||||||
return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
|
return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,7 +71,7 @@ function mightContainEmoji(str) {
|
||||||
* @param {String} char The emoji character
|
* @param {String} char The emoji character
|
||||||
* @return {String} The shortcode (such as :thumbup:)
|
* @return {String} The shortcode (such as :thumbup:)
|
||||||
*/
|
*/
|
||||||
export function unicodeToShortcode(char) {
|
export function unicodeToShortcode(char: string) {
|
||||||
const data = getEmojiFromUnicode(char);
|
const data = getEmojiFromUnicode(char);
|
||||||
return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : '');
|
return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : '');
|
||||||
}
|
}
|
||||||
|
@ -85,7 +82,7 @@ export function unicodeToShortcode(char) {
|
||||||
* @param {String} shortcode The shortcode (such as :thumbup:)
|
* @param {String} shortcode The shortcode (such as :thumbup:)
|
||||||
* @return {String} The emoji character; null if none exists
|
* @return {String} The emoji character; null if none exists
|
||||||
*/
|
*/
|
||||||
export function shortcodeToUnicode(shortcode) {
|
export function shortcodeToUnicode(shortcode: string) {
|
||||||
shortcode = shortcode.slice(1, shortcode.length - 1);
|
shortcode = shortcode.slice(1, shortcode.length - 1);
|
||||||
const data = SHORTCODE_TO_EMOJI.get(shortcode);
|
const data = SHORTCODE_TO_EMOJI.get(shortcode);
|
||||||
return data ? data.unicode : null;
|
return data ? data.unicode : null;
|
||||||
|
@ -122,12 +119,19 @@ export function processHtmlForSending(html: string): string {
|
||||||
* Given an untrusted HTML string, return a React node with an sanitized version
|
* Given an untrusted HTML string, return a React node with an sanitized version
|
||||||
* of that HTML.
|
* of that HTML.
|
||||||
*/
|
*/
|
||||||
export function sanitizedHtmlNode(insaneHtml) {
|
export function sanitizedHtmlNode(insaneHtml: string) {
|
||||||
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
|
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
|
||||||
|
|
||||||
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
|
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function sanitizedHtmlNodeInnerText(insaneHtml: string) {
|
||||||
|
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
|
||||||
|
const contentDiv = document.createElement("div");
|
||||||
|
contentDiv.innerHTML = saneHtml;
|
||||||
|
return contentDiv.innerText;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests if a URL from an untrusted source may be safely put into the DOM
|
* Tests if a URL from an untrusted source may be safely put into the DOM
|
||||||
* The biggest threat here is javascript: URIs.
|
* The biggest threat here is javascript: URIs.
|
||||||
|
@ -136,7 +140,7 @@ export function sanitizedHtmlNode(insaneHtml) {
|
||||||
* other places we need to sanitise URLs.
|
* other places we need to sanitise URLs.
|
||||||
* @return true if permitted, otherwise false
|
* @return true if permitted, otherwise false
|
||||||
*/
|
*/
|
||||||
export function isUrlPermitted(inputUrl) {
|
export function isUrlPermitted(inputUrl: string) {
|
||||||
try {
|
try {
|
||||||
const parsed = url.parse(inputUrl);
|
const parsed = url.parse(inputUrl);
|
||||||
if (!parsed.protocol) return false;
|
if (!parsed.protocol) return false;
|
||||||
|
@ -147,9 +151,9 @@ export function isUrlPermitted(inputUrl) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const transformTags = { // custom to matrix
|
const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to matrix
|
||||||
// add blank targets to all hyperlinks except vector URLs
|
// add blank targets to all hyperlinks except vector URLs
|
||||||
'a': function(tagName, attribs) {
|
'a': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
||||||
if (attribs.href) {
|
if (attribs.href) {
|
||||||
attribs.target = '_blank'; // by default
|
attribs.target = '_blank'; // by default
|
||||||
|
|
||||||
|
@ -162,7 +166,7 @@ const transformTags = { // custom to matrix
|
||||||
attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/
|
attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/
|
||||||
return { tagName, attribs };
|
return { tagName, attribs };
|
||||||
},
|
},
|
||||||
'img': function(tagName, attribs) {
|
'img': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
||||||
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
|
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
|
||||||
// because transformTags is used _before_ we filter by allowedSchemesByTag and
|
// because transformTags is used _before_ we filter by allowedSchemesByTag and
|
||||||
// we don't want to allow images with `https?` `src`s.
|
// we don't want to allow images with `https?` `src`s.
|
||||||
|
@ -176,7 +180,7 @@ const transformTags = { // custom to matrix
|
||||||
);
|
);
|
||||||
return { tagName, attribs };
|
return { tagName, attribs };
|
||||||
},
|
},
|
||||||
'code': function(tagName, attribs) {
|
'code': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
||||||
if (typeof attribs.class !== 'undefined') {
|
if (typeof attribs.class !== 'undefined') {
|
||||||
// Filter out all classes other than ones starting with language- for syntax highlighting.
|
// Filter out all classes other than ones starting with language- for syntax highlighting.
|
||||||
const classes = attribs.class.split(/\s/).filter(function(cl) {
|
const classes = attribs.class.split(/\s/).filter(function(cl) {
|
||||||
|
@ -186,7 +190,7 @@ const transformTags = { // custom to matrix
|
||||||
}
|
}
|
||||||
return { tagName, attribs };
|
return { tagName, attribs };
|
||||||
},
|
},
|
||||||
'*': function(tagName, attribs) {
|
'*': function(tagName: string, attribs: sanitizeHtml.Attributes) {
|
||||||
// Delete any style previously assigned, style is an allowedTag for font and span
|
// Delete any style previously assigned, style is an allowedTag for font and span
|
||||||
// because attributes are stripped after transforming
|
// because attributes are stripped after transforming
|
||||||
delete attribs.style;
|
delete attribs.style;
|
||||||
|
@ -220,7 +224,7 @@ const transformTags = { // custom to matrix
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const sanitizeHtmlParams = {
|
const sanitizeHtmlParams: sanitizeHtml.IOptions = {
|
||||||
allowedTags: [
|
allowedTags: [
|
||||||
'font', // custom to matrix for IRC-style font coloring
|
'font', // custom to matrix for IRC-style font coloring
|
||||||
'del', // for markdown
|
'del', // for markdown
|
||||||
|
@ -247,16 +251,16 @@ const sanitizeHtmlParams = {
|
||||||
};
|
};
|
||||||
|
|
||||||
// this is the same as the above except with less rewriting
|
// this is the same as the above except with less rewriting
|
||||||
const composerSanitizeHtmlParams = Object.assign({}, sanitizeHtmlParams);
|
const composerSanitizeHtmlParams: sanitizeHtml.IOptions = {
|
||||||
composerSanitizeHtmlParams.transformTags = {
|
...sanitizeHtmlParams,
|
||||||
|
transformTags: {
|
||||||
'code': transformTags['code'],
|
'code': transformTags['code'],
|
||||||
'*': transformTags['*'],
|
'*': transformTags['*'],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
class BaseHighlighter {
|
abstract class BaseHighlighter<T extends React.ReactNode> {
|
||||||
constructor(highlightClass, highlightLink) {
|
constructor(public highlightClass: string, public highlightLink: string) {
|
||||||
this.highlightClass = highlightClass;
|
|
||||||
this.highlightLink = highlightLink;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -270,47 +274,49 @@ class BaseHighlighter {
|
||||||
* returns a list of results (strings for HtmlHighligher, react nodes for
|
* returns a list of results (strings for HtmlHighligher, react nodes for
|
||||||
* TextHighlighter).
|
* TextHighlighter).
|
||||||
*/
|
*/
|
||||||
applyHighlights(safeSnippet, safeHighlights) {
|
public applyHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
|
||||||
let lastOffset = 0;
|
let lastOffset = 0;
|
||||||
let offset;
|
let offset;
|
||||||
let nodes = [];
|
let nodes: T[] = [];
|
||||||
|
|
||||||
const safeHighlight = safeHighlights[0];
|
const safeHighlight = safeHighlights[0];
|
||||||
while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) {
|
while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) {
|
||||||
// handle preamble
|
// handle preamble
|
||||||
if (offset > lastOffset) {
|
if (offset > lastOffset) {
|
||||||
var subSnippet = safeSnippet.substring(lastOffset, offset);
|
const subSnippet = safeSnippet.substring(lastOffset, offset);
|
||||||
nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights));
|
nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
|
||||||
}
|
}
|
||||||
|
|
||||||
// do highlight. use the original string rather than safeHighlight
|
// do highlight. use the original string rather than safeHighlight
|
||||||
// to preserve the original casing.
|
// to preserve the original casing.
|
||||||
const endOffset = offset + safeHighlight.length;
|
const endOffset = offset + safeHighlight.length;
|
||||||
nodes.push(this._processSnippet(safeSnippet.substring(offset, endOffset), true));
|
nodes.push(this.processSnippet(safeSnippet.substring(offset, endOffset), true));
|
||||||
|
|
||||||
lastOffset = endOffset;
|
lastOffset = endOffset;
|
||||||
}
|
}
|
||||||
|
|
||||||
// handle postamble
|
// handle postamble
|
||||||
if (lastOffset !== safeSnippet.length) {
|
if (lastOffset !== safeSnippet.length) {
|
||||||
subSnippet = safeSnippet.substring(lastOffset, undefined);
|
const subSnippet = safeSnippet.substring(lastOffset, undefined);
|
||||||
nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights));
|
nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
|
||||||
}
|
}
|
||||||
return nodes;
|
return nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
_applySubHighlights(safeSnippet, safeHighlights) {
|
private applySubHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
|
||||||
if (safeHighlights[1]) {
|
if (safeHighlights[1]) {
|
||||||
// recurse into this range to check for the next set of highlight matches
|
// recurse into this range to check for the next set of highlight matches
|
||||||
return this.applyHighlights(safeSnippet, safeHighlights.slice(1));
|
return this.applyHighlights(safeSnippet, safeHighlights.slice(1));
|
||||||
} else {
|
} else {
|
||||||
// no more highlights to be found, just return the unhighlighted string
|
// no more highlights to be found, just return the unhighlighted string
|
||||||
return [this._processSnippet(safeSnippet, false)];
|
return [this.processSnippet(safeSnippet, false)];
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class HtmlHighlighter extends BaseHighlighter {
|
protected abstract processSnippet(snippet: string, highlight: boolean): T;
|
||||||
|
}
|
||||||
|
|
||||||
|
class HtmlHighlighter extends BaseHighlighter<string> {
|
||||||
/* highlight the given snippet if required
|
/* highlight the given snippet if required
|
||||||
*
|
*
|
||||||
* snippet: content of the span; must have been sanitised
|
* snippet: content of the span; must have been sanitised
|
||||||
|
@ -318,28 +324,23 @@ class HtmlHighlighter extends BaseHighlighter {
|
||||||
*
|
*
|
||||||
* returns an HTML string
|
* returns an HTML string
|
||||||
*/
|
*/
|
||||||
_processSnippet(snippet, highlight) {
|
protected processSnippet(snippet: string, highlight: boolean): string {
|
||||||
if (!highlight) {
|
if (!highlight) {
|
||||||
// nothing required here
|
// nothing required here
|
||||||
return snippet;
|
return snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let span = "<span class=\""+this.highlightClass+"\">"
|
let span = `<span class="${this.highlightClass}">${snippet}</span>`;
|
||||||
+ snippet + "</span>";
|
|
||||||
|
|
||||||
if (this.highlightLink) {
|
if (this.highlightLink) {
|
||||||
span = "<a href=\""+encodeURI(this.highlightLink)+"\">"
|
span = `<a href="${encodeURI(this.highlightLink)}">${span}</a>`;
|
||||||
+span+"</a>";
|
|
||||||
}
|
}
|
||||||
return span;
|
return span;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class TextHighlighter extends BaseHighlighter {
|
class TextHighlighter extends BaseHighlighter<React.ReactNode> {
|
||||||
constructor(highlightClass, highlightLink) {
|
private key = 0;
|
||||||
super(highlightClass, highlightLink);
|
|
||||||
this._key = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* create a <span> node to hold the given content
|
/* create a <span> node to hold the given content
|
||||||
*
|
*
|
||||||
|
@ -348,11 +349,10 @@ class TextHighlighter extends BaseHighlighter {
|
||||||
*
|
*
|
||||||
* returns a React node
|
* returns a React node
|
||||||
*/
|
*/
|
||||||
_processSnippet(snippet, highlight) {
|
protected processSnippet(snippet: string, highlight: boolean): React.ReactNode {
|
||||||
const key = this._key++;
|
const key = this.key++;
|
||||||
|
|
||||||
let node =
|
let node = <span key={key} className={highlight ? this.highlightClass : null}>
|
||||||
<span key={key} className={highlight ? this.highlightClass : null}>
|
|
||||||
{ snippet }
|
{ snippet }
|
||||||
</span>;
|
</span>;
|
||||||
|
|
||||||
|
@ -364,6 +364,20 @@ class TextHighlighter extends BaseHighlighter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IContent {
|
||||||
|
format?: string;
|
||||||
|
formatted_body?: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IOpts {
|
||||||
|
highlightLink?: string;
|
||||||
|
disableBigEmoji?: boolean;
|
||||||
|
stripReplyFallback?: boolean;
|
||||||
|
returnString?: boolean;
|
||||||
|
forComposerQuote?: boolean;
|
||||||
|
ref?: React.Ref<any>;
|
||||||
|
}
|
||||||
|
|
||||||
/* turn a matrix event body into html
|
/* turn a matrix event body into html
|
||||||
*
|
*
|
||||||
|
@ -378,7 +392,7 @@ class TextHighlighter extends BaseHighlighter {
|
||||||
* opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer
|
* opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer
|
||||||
* opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString)
|
* opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString)
|
||||||
*/
|
*/
|
||||||
export function bodyToHtml(content, highlights, opts={}) {
|
export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) {
|
||||||
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
|
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
|
||||||
let bodyHasEmoji = false;
|
let bodyHasEmoji = false;
|
||||||
|
|
||||||
|
@ -387,9 +401,9 @@ export function bodyToHtml(content, highlights, opts={}) {
|
||||||
sanitizeParams = composerSanitizeHtmlParams;
|
sanitizeParams = composerSanitizeHtmlParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
let strippedBody;
|
let strippedBody: string;
|
||||||
let safeBody;
|
let safeBody: string;
|
||||||
let isDisplayedWithHtml;
|
let isDisplayedWithHtml: boolean;
|
||||||
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
|
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
|
||||||
// to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which
|
// to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which
|
||||||
// are interrupted by HTML tags (not that we did before) - e.g. foo<span/>bar won't get highlighted
|
// are interrupted by HTML tags (not that we did before) - e.g. foo<span/>bar won't get highlighted
|
||||||
|
@ -471,7 +485,7 @@ export function bodyToHtml(content, highlights, opts={}) {
|
||||||
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
|
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
|
||||||
* @returns {string} Linkified string
|
* @returns {string} Linkified string
|
||||||
*/
|
*/
|
||||||
export function linkifyString(str, options = linkifyMatrix.options) {
|
export function linkifyString(str: string, options = linkifyMatrix.options) {
|
||||||
return _linkifyString(str, options);
|
return _linkifyString(str, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -482,7 +496,7 @@ export function linkifyString(str, options = linkifyMatrix.options) {
|
||||||
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options
|
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options
|
||||||
* @returns {object}
|
* @returns {object}
|
||||||
*/
|
*/
|
||||||
export function linkifyElement(element, options = linkifyMatrix.options) {
|
export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options) {
|
||||||
return _linkifyElement(element, options);
|
return _linkifyElement(element, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -493,7 +507,7 @@ export function linkifyElement(element, options = linkifyMatrix.options) {
|
||||||
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
|
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.options) {
|
export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) {
|
||||||
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
|
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -504,7 +518,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.option
|
||||||
* @param {Node} node
|
* @param {Node} node
|
||||||
* @returns {bool}
|
* @returns {bool}
|
||||||
*/
|
*/
|
||||||
export function checkBlockNode(node) {
|
export function checkBlockNode(node: Node) {
|
||||||
switch (node.nodeName) {
|
switch (node.nodeName) {
|
||||||
case "H1":
|
case "H1":
|
||||||
case "H2":
|
case "H2":
|
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.
|
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";
|
|
@ -21,6 +21,7 @@ import classNames from "classnames";
|
||||||
import dis from "../../dispatcher/dispatcher";
|
import dis from "../../dispatcher/dispatcher";
|
||||||
import { _t } from "../../languageHandler";
|
import { _t } from "../../languageHandler";
|
||||||
import RoomList2 from "../views/rooms/RoomList2";
|
import RoomList2 from "../views/rooms/RoomList2";
|
||||||
|
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist2";
|
||||||
import { Action } from "../../dispatcher/actions";
|
import { Action } from "../../dispatcher/actions";
|
||||||
import UserMenu from "./UserMenu";
|
import UserMenu from "./UserMenu";
|
||||||
import RoomSearch from "./RoomSearch";
|
import RoomSearch from "./RoomSearch";
|
||||||
|
@ -135,7 +136,6 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
||||||
const bottom = rlRect.bottom;
|
const bottom = rlRect.bottom;
|
||||||
const top = rlRect.top;
|
const top = rlRect.top;
|
||||||
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist2");
|
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist2");
|
||||||
const headerHeight = 32; // Note: must match the CSS!
|
|
||||||
const headerRightMargin = 24; // calculated from margins and widths to align with non-sticky tiles
|
const headerRightMargin = 24; // calculated from margins and widths to align with non-sticky tiles
|
||||||
|
|
||||||
const headerStickyWidth = rlRect.width - headerRightMargin;
|
const headerStickyWidth = rlRect.width - headerRightMargin;
|
||||||
|
@ -146,23 +146,27 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
||||||
const slRect = sublist.getBoundingClientRect();
|
const slRect = sublist.getBoundingClientRect();
|
||||||
|
|
||||||
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist2_stickable");
|
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist2_stickable");
|
||||||
|
header.style.removeProperty("display"); // always clear display:none first
|
||||||
|
|
||||||
if (slRect.top + headerHeight > bottom && !gotBottom) {
|
if (slRect.top + HEADER_HEIGHT > bottom && !gotBottom) {
|
||||||
header.classList.add("mx_RoomSublist2_headerContainer_sticky");
|
header.classList.add("mx_RoomSublist2_headerContainer_sticky");
|
||||||
header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom");
|
header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom");
|
||||||
header.style.width = `${headerStickyWidth}px`;
|
header.style.width = `${headerStickyWidth}px`;
|
||||||
header.style.removeProperty("top");
|
header.style.removeProperty("top");
|
||||||
gotBottom = true;
|
gotBottom = true;
|
||||||
} else if ((slRect.top - (headerHeight / 3)) < top) {
|
} else if (((slRect.top - (HEADER_HEIGHT * 0.6) + HEADER_HEIGHT) < top) || sublist === sublists[0]) {
|
||||||
|
// the header should become sticky once it is 60% or less out of view at the top.
|
||||||
|
// We also add HEADER_HEIGHT because the sticky header is put above the scrollable area,
|
||||||
|
// into the padding of .mx_LeftPanel2_roomListWrapper,
|
||||||
|
// by subtracting HEADER_HEIGHT from the top below.
|
||||||
|
// We also always try to make the first sublist header sticky.
|
||||||
header.classList.add("mx_RoomSublist2_headerContainer_sticky");
|
header.classList.add("mx_RoomSublist2_headerContainer_sticky");
|
||||||
header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
|
header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
|
||||||
header.style.width = `${headerStickyWidth}px`;
|
header.style.width = `${headerStickyWidth}px`;
|
||||||
header.style.top = `${rlRect.top}px`;
|
header.style.top = `${rlRect.top - HEADER_HEIGHT}px`;
|
||||||
if (lastTopHeader) {
|
if (lastTopHeader) {
|
||||||
lastTopHeader.style.display = "none";
|
lastTopHeader.style.display = "none";
|
||||||
}
|
}
|
||||||
// first unset it, if set in last iteration
|
|
||||||
header.style.removeProperty("display");
|
|
||||||
lastTopHeader = header;
|
lastTopHeader = header;
|
||||||
} else {
|
} else {
|
||||||
header.classList.remove("mx_RoomSublist2_headerContainer_sticky");
|
header.classList.remove("mx_RoomSublist2_headerContainer_sticky");
|
||||||
|
@ -172,6 +176,26 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
||||||
header.style.removeProperty("top");
|
header.style.removeProperty("top");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// add appropriate sticky classes to wrapper so it has
|
||||||
|
// the necessary top/bottom padding to put the sticky header in
|
||||||
|
const listWrapper = list.parentElement;
|
||||||
|
if (gotBottom) {
|
||||||
|
listWrapper.classList.add("stickyBottom");
|
||||||
|
} else {
|
||||||
|
listWrapper.classList.remove("stickyBottom");
|
||||||
|
}
|
||||||
|
if (lastTopHeader) {
|
||||||
|
listWrapper.classList.add("stickyTop");
|
||||||
|
} else {
|
||||||
|
listWrapper.classList.remove("stickyTop");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure scroll doesn't go above the gap left by the header of
|
||||||
|
// the first sublist always being sticky if no other header is sticky
|
||||||
|
if (list.scrollTop < HEADER_HEIGHT) {
|
||||||
|
list.scrollTop = HEADER_HEIGHT;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Improve header reliability: https://github.com/vector-im/riot-web/issues/14232
|
// TODO: Improve header reliability: https://github.com/vector-im/riot-web/issues/14232
|
||||||
|
@ -325,6 +349,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
||||||
<aside className="mx_LeftPanel2_roomListContainer">
|
<aside className="mx_LeftPanel2_roomListContainer">
|
||||||
{this.renderHeader()}
|
{this.renderHeader()}
|
||||||
{this.renderSearchExplore()}
|
{this.renderSearchExplore()}
|
||||||
|
<div className="mx_LeftPanel2_roomListWrapper">
|
||||||
<div
|
<div
|
||||||
className={roomListClasses}
|
className={roomListClasses}
|
||||||
onScroll={this.onScroll}
|
onScroll={this.onScroll}
|
||||||
|
@ -335,6 +360,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
|
||||||
>
|
>
|
||||||
{roomList}
|
{roomList}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -26,6 +26,7 @@ import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||||
import RoomTile2 from "./RoomTile2";
|
import RoomTile2 from "./RoomTile2";
|
||||||
import { ListLayout } from "../../../stores/room-list/ListLayout";
|
import { ListLayout } from "../../../stores/room-list/ListLayout";
|
||||||
import {
|
import {
|
||||||
|
ChevronFace,
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuButton,
|
ContextMenuButton,
|
||||||
StyledMenuItemCheckbox,
|
StyledMenuItemCheckbox,
|
||||||
|
@ -39,6 +40,8 @@ import NotificationBadge from "./NotificationBadge";
|
||||||
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
|
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
|
||||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||||
import { Key } from "../../../Keyboard";
|
import { Key } from "../../../Keyboard";
|
||||||
|
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||||
|
import {ActionPayload} from "../../../dispatcher/payloads";
|
||||||
import { Enable, Resizable } from "re-resizable";
|
import { Enable, Resizable } from "re-resizable";
|
||||||
import { Direction } from "re-resizable/lib/resizer";
|
import { Direction } from "re-resizable/lib/resizer";
|
||||||
import { polyfillTouchEvent } from "../../../@types/polyfill";
|
import { polyfillTouchEvent } from "../../../@types/polyfill";
|
||||||
|
@ -56,6 +59,7 @@ import { polyfillTouchEvent } from "../../../@types/polyfill";
|
||||||
|
|
||||||
const SHOW_N_BUTTON_HEIGHT = 32; // As defined by CSS
|
const SHOW_N_BUTTON_HEIGHT = 32; // As defined by CSS
|
||||||
const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS
|
const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS
|
||||||
|
export const HEADER_HEIGHT = 32; // As defined by CSS
|
||||||
|
|
||||||
const MAX_PADDING_HEIGHT = SHOW_N_BUTTON_HEIGHT + RESIZE_HANDLE_HEIGHT;
|
const MAX_PADDING_HEIGHT = SHOW_N_BUTTON_HEIGHT + RESIZE_HANDLE_HEIGHT;
|
||||||
|
|
||||||
|
@ -93,6 +97,7 @@ interface IState {
|
||||||
export default class RoomSublist2 extends React.Component<IProps, IState> {
|
export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||||
private headerButton = createRef<HTMLDivElement>();
|
private headerButton = createRef<HTMLDivElement>();
|
||||||
private sublistRef = createRef<HTMLDivElement>();
|
private sublistRef = createRef<HTMLDivElement>();
|
||||||
|
private dispatcherRef: string;
|
||||||
|
|
||||||
constructor(props: IProps) {
|
constructor(props: IProps) {
|
||||||
super(props);
|
super(props);
|
||||||
|
@ -103,6 +108,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||||
isResizing: false,
|
isResizing: false,
|
||||||
};
|
};
|
||||||
this.state.notificationState.setRooms(this.props.rooms);
|
this.state.notificationState.setRooms(this.props.rooms);
|
||||||
|
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
private get numTiles(): number {
|
private get numTiles(): number {
|
||||||
|
@ -121,8 +127,29 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
public componentWillUnmount() {
|
public componentWillUnmount() {
|
||||||
this.state.notificationState.destroy();
|
this.state.notificationState.destroy();
|
||||||
|
defaultDispatcher.unregister(this.dispatcherRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onAction = (payload: ActionPayload) => {
|
||||||
|
if (payload.action === "view_room" && payload.show_room_tile && this.props.rooms) {
|
||||||
|
// XXX: we have to do this a tick later because we have incorrect intermediate props during a room change
|
||||||
|
// where we lose the room we are changing from temporarily and then it comes back in an update right after.
|
||||||
|
setImmediate(() => {
|
||||||
|
const isCollapsed = this.props.layout.isCollapsed;
|
||||||
|
const roomIndex = this.props.rooms.findIndex((r) => r.roomId === payload.room_id);
|
||||||
|
|
||||||
|
if (isCollapsed && roomIndex > -1) {
|
||||||
|
this.toggleCollapsed();
|
||||||
|
}
|
||||||
|
// extend the visible section to include the room if it is entirely invisible
|
||||||
|
if (roomIndex >= this.numVisibleTiles) {
|
||||||
|
this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(roomIndex + 1, MAX_PADDING_HEIGHT);
|
||||||
|
this.forceUpdate(); // because the layout doesn't trigger a re-render
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private onAddRoom = (e) => {
|
private onAddRoom = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (this.props.onAddRoom) this.props.onAddRoom();
|
if (this.props.onAddRoom) this.props.onAddRoom();
|
||||||
|
@ -180,7 +207,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private onOpenMenuClick = (ev: InputEvent) => {
|
private onOpenMenuClick = (ev: React.MouseEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const target = ev.target as HTMLButtonElement;
|
const target = ev.target as HTMLButtonElement;
|
||||||
|
@ -252,7 +279,11 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
const possibleSticky = target.parentElement;
|
const possibleSticky = target.parentElement;
|
||||||
const sublist = possibleSticky.parentElement.parentElement;
|
const sublist = possibleSticky.parentElement.parentElement;
|
||||||
if (possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky')) {
|
const list = sublist.parentElement.parentElement;
|
||||||
|
// the scrollTop is capped at the height of the header in LeftPanel2
|
||||||
|
const isAtTop = list.scrollTop <= HEADER_HEIGHT;
|
||||||
|
const isSticky = possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky');
|
||||||
|
if (isSticky && !isAtTop) {
|
||||||
// is sticky - jump to list
|
// is sticky - jump to list
|
||||||
sublist.scrollIntoView({behavior: 'smooth'});
|
sublist.scrollIntoView({behavior: 'smooth'});
|
||||||
} else {
|
} else {
|
||||||
|
@ -384,7 +415,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
contextMenu = (
|
contextMenu = (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
chevronFace="none"
|
chevronFace={ChevronFace.None}
|
||||||
left={this.state.contextMenuPosition.left}
|
left={this.state.contextMenuPosition.left}
|
||||||
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
|
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
|
||||||
onFinished={this.onCloseMenu}
|
onFinished={this.onCloseMenu}
|
||||||
|
|
|
@ -17,7 +17,7 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React, {createRef} from "react";
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||||
|
@ -27,6 +27,7 @@ import { Key } from "../../../Keyboard";
|
||||||
import ActiveRoomObserver from "../../../ActiveRoomObserver";
|
import ActiveRoomObserver from "../../../ActiveRoomObserver";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import {
|
import {
|
||||||
|
ChevronFace,
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuButton,
|
ContextMenuButton,
|
||||||
MenuItemRadio,
|
MenuItemRadio,
|
||||||
|
@ -50,6 +51,8 @@ import { INotificationState } from "../../../stores/notifications/INotificationS
|
||||||
import NotificationBadge from "./NotificationBadge";
|
import NotificationBadge from "./NotificationBadge";
|
||||||
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
|
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
|
||||||
import { Volume } from "../../../RoomNotifsTypes";
|
import { Volume } from "../../../RoomNotifsTypes";
|
||||||
|
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||||
|
import {ActionPayload} from "../../../dispatcher/payloads";
|
||||||
|
|
||||||
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
|
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
|
||||||
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
|
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
|
||||||
|
@ -87,7 +90,7 @@ const contextMenuBelow = (elementRect: PartialDOMRect) => {
|
||||||
// align the context menu's icons with the icon which opened the context menu
|
// align the context menu's icons with the icon which opened the context menu
|
||||||
const left = elementRect.left + window.pageXOffset - 9;
|
const left = elementRect.left + window.pageXOffset - 9;
|
||||||
const top = elementRect.bottom + window.pageYOffset + 17;
|
const top = elementRect.bottom + window.pageYOffset + 17;
|
||||||
const chevronFace = "none";
|
const chevronFace = ChevronFace.None;
|
||||||
return {left, top, chevronFace};
|
return {left, top, chevronFace};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -118,6 +121,10 @@ const NotifOption: React.FC<INotifOptionProps> = ({active, onClick, iconClassNam
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class RoomTile2 extends React.Component<IProps, IState> {
|
export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||||
|
private dispatcherRef: string;
|
||||||
|
private roomTileRef = createRef<HTMLDivElement>();
|
||||||
|
// TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
|
||||||
|
|
||||||
constructor(props: IProps) {
|
constructor(props: IProps) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
@ -130,6 +137,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||||
};
|
};
|
||||||
|
|
||||||
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
|
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
|
||||||
|
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
private get showContextMenu(): boolean {
|
private get showContextMenu(): boolean {
|
||||||
|
@ -140,12 +148,36 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||||
return !this.props.isMinimized && this.props.showMessagePreview;
|
return !this.props.isMinimized && this.props.showMessagePreview;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public componentDidMount() {
|
||||||
|
// when we're first rendered (or our sublist is expanded) make sure we are visible if we're active
|
||||||
|
if (this.state.selected) {
|
||||||
|
this.scrollIntoView();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public componentWillUnmount() {
|
public componentWillUnmount() {
|
||||||
if (this.props.room) {
|
if (this.props.room) {
|
||||||
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
|
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
|
||||||
}
|
}
|
||||||
|
defaultDispatcher.unregister(this.dispatcherRef);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onAction = (payload: ActionPayload) => {
|
||||||
|
if (payload.action === "view_room" && payload.room_id === this.props.room.roomId && payload.show_room_tile) {
|
||||||
|
setImmediate(() => {
|
||||||
|
this.scrollIntoView();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private scrollIntoView = () => {
|
||||||
|
if (!this.roomTileRef.current) return;
|
||||||
|
this.roomTileRef.current.scrollIntoView({
|
||||||
|
block: "nearest",
|
||||||
|
behavior: "auto",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
private onTileMouseEnter = () => {
|
private onTileMouseEnter = () => {
|
||||||
this.setState({hover: true});
|
this.setState({hover: true});
|
||||||
};
|
};
|
||||||
|
@ -159,7 +191,6 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
dis.dispatch({
|
dis.dispatch({
|
||||||
action: 'view_room',
|
action: 'view_room',
|
||||||
// TODO: Support show_room_tile in new room list: https://github.com/vector-im/riot-web/issues/14233
|
|
||||||
show_room_tile: true, // make sure the room is visible in the list
|
show_room_tile: true, // make sure the room is visible in the list
|
||||||
room_id: this.props.room.roomId,
|
room_id: this.props.room.roomId,
|
||||||
clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)),
|
clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)),
|
||||||
|
@ -170,7 +201,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||||
this.setState({selected: isActive});
|
this.setState({selected: isActive});
|
||||||
};
|
};
|
||||||
|
|
||||||
private onNotificationsMenuOpenClick = (ev: InputEvent) => {
|
private onNotificationsMenuOpenClick = (ev: React.MouseEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const target = ev.target as HTMLButtonElement;
|
const target = ev.target as HTMLButtonElement;
|
||||||
|
@ -181,7 +212,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||||
this.setState({notificationsMenuPosition: null});
|
this.setState({notificationsMenuPosition: null});
|
||||||
};
|
};
|
||||||
|
|
||||||
private onGeneralMenuOpenClick = (ev: InputEvent) => {
|
private onGeneralMenuOpenClick = (ev: React.MouseEvent) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const target = ev.target as HTMLButtonElement;
|
const target = ev.target as HTMLButtonElement;
|
||||||
|
@ -477,7 +508,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<RovingTabIndexWrapper>
|
<RovingTabIndexWrapper inputRef={this.roomTileRef}>
|
||||||
{({onFocus, isActive, ref}) =>
|
{({onFocus, isActive, ref}) =>
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
onFocus={onFocus}
|
onFocus={onFocus}
|
||||||
|
|
|
@ -20,6 +20,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
|
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
|
||||||
import ReplyThread from "../../../components/views/elements/ReplyThread";
|
import ReplyThread from "../../../components/views/elements/ReplyThread";
|
||||||
|
import { sanitizedHtmlNodeInnerText } from "../../../HtmlUtils";
|
||||||
|
|
||||||
export class MessageEventPreview implements IPreview {
|
export class MessageEventPreview implements IPreview {
|
||||||
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
|
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
|
||||||
|
@ -36,14 +37,27 @@ export class MessageEventPreview implements IPreview {
|
||||||
const msgtype = eventContent['msgtype'];
|
const msgtype = eventContent['msgtype'];
|
||||||
if (!body || !msgtype) return null; // invalid event, no preview
|
if (!body || !msgtype) return null; // invalid event, no preview
|
||||||
|
|
||||||
|
const hasHtml = eventContent.format === "org.matrix.custom.html" && eventContent.formatted_body;
|
||||||
|
if (hasHtml) {
|
||||||
|
body = eventContent.formatted_body;
|
||||||
|
}
|
||||||
|
|
||||||
// XXX: Newer relations have a getRelation() function which is not compatible with replies.
|
// XXX: Newer relations have a getRelation() function which is not compatible with replies.
|
||||||
const mRelatesTo = event.getWireContent()['m.relates_to'];
|
const mRelatesTo = event.getWireContent()['m.relates_to'];
|
||||||
if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
|
if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
|
||||||
// If this is a reply, get the real reply and use that
|
// If this is a reply, get the real reply and use that
|
||||||
|
if (hasHtml) {
|
||||||
|
body = (ReplyThread.stripHTMLReply(body) || '').trim();
|
||||||
|
} else {
|
||||||
body = (ReplyThread.stripPlainReply(body) || '').trim();
|
body = (ReplyThread.stripPlainReply(body) || '').trim();
|
||||||
|
}
|
||||||
if (!body) return null; // invalid event, no preview
|
if (!body) return null; // invalid event, no preview
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasHtml) {
|
||||||
|
body = sanitizedHtmlNodeInnerText(body);
|
||||||
|
}
|
||||||
|
|
||||||
if (msgtype === 'm.emote') {
|
if (msgtype === 'm.emote') {
|
||||||
return _t("%(senderName)s %(emote)s", {senderName: getSenderName(event), emote: body});
|
return _t("%(senderName)s %(emote)s", {senderName: getSenderName(event), emote: body});
|
||||||
}
|
}
|
||||||
|
|
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"
|
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"
|
||||||
|
|
Loading…
Reference in a new issue