diff --git a/package.json b/package.json
index 3fd7703afb..3d1fb535c0 100644
--- a/package.json
+++ b/package.json
@@ -89,11 +89,11 @@
"prop-types": "^15.5.8",
"qrcode": "^1.4.4",
"qs": "^6.6.0",
+ "re-resizable": "^6.5.2",
"react": "^16.9.0",
"react-beautiful-dnd": "^4.0.1",
"react-dom": "^16.9.0",
"react-focus-lock": "^2.2.1",
- "react-resizable": "^1.10.1",
"react-transition-group": "^4.4.1",
"resize-observer-polyfill": "^1.5.0",
"sanitize-html": "^1.18.4",
@@ -122,6 +122,7 @@
"@types/classnames": "^2.2.10",
"@types/counterpart": "^0.18.1",
"@types/flux": "^3.1.9",
+ "@types/linkifyjs": "^2.1.3",
"@types/lodash": "^4.14.152",
"@types/modernizr": "^3.5.3",
"@types/node": "^12.12.41",
@@ -129,6 +130,7 @@
"@types/react": "^16.9",
"@types/react-dom": "^16.9.8",
"@types/react-transition-group": "^4.4.0",
+ "@types/sanitize-html": "^1.23.3",
"@types/zxcvbn": "^4.4.0",
"babel-eslint": "^10.0.3",
"babel-jest": "^24.9.0",
diff --git a/res/css/_components.scss b/res/css/_components.scss
index 8288cf34f6..85e08110ea 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -51,6 +51,7 @@
@import "./views/avatars/_BaseAvatar.scss";
@import "./views/avatars/_DecoratedRoomAvatar.scss";
@import "./views/avatars/_MemberStatusMessageAvatar.scss";
+@import "./views/avatars/_PulsedAvatar.scss";
@import "./views/context_menus/_MessageContextMenu.scss";
@import "./views/context_menus/_RoomTileContextMenu.scss";
@import "./views/context_menus/_StatusMessageContextMenu.scss";
@@ -225,6 +226,8 @@
@import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss";
@import "./views/terms/_InlineTermsAgreement.scss";
@import "./views/verification/_VerificationShowSas.scss";
+@import "./views/voip/_CallContainer.scss";
@import "./views/voip/_CallView.scss";
+@import "./views/voip/_CallView2.scss";
@import "./views/voip/_IncomingCallbox.scss";
@import "./views/voip/_VideoView.scss";
diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss
index 10eb9dd2e9..6be5674d7f 100644
--- a/res/css/structures/_LeftPanel2.scss
+++ b/res/css/structures/_LeftPanel2.scss
@@ -121,6 +121,24 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
}
}
+ .mx_LeftPanel2_roomListWrapper {
+ // Create a flexbox to ensure the containing items cause appropriate overflow.
+ display: flex;
+
+ flex-grow: 1;
+ overflow: hidden;
+ min-height: 0;
+ margin-top: 12px; // so we're not up against the search/filter
+
+ &.mx_LeftPanel2_roomListWrapper_stickyBottom {
+ padding-bottom: 32px;
+ }
+
+ &.mx_LeftPanel2_roomListWrapper_stickyTop {
+ padding-top: 32px;
+ }
+ }
+
.mx_LeftPanel2_actualRoomListContainer {
flex-grow: 1; // fill the available space
overflow-y: auto;
diff --git a/res/css/views/avatars/_DecoratedRoomAvatar.scss b/res/css/views/avatars/_DecoratedRoomAvatar.scss
index b500d44a43..900f351074 100644
--- a/res/css/views/avatars/_DecoratedRoomAvatar.scss
+++ b/res/css/views/avatars/_DecoratedRoomAvatar.scss
@@ -24,7 +24,7 @@ limitations under the License.
right: 0;
}
- .mx_NotificationBadge {
+ .mx_NotificationBadge, .mx_RoomTile2_badgeContainer {
position: absolute;
top: 0;
right: 0;
diff --git a/res/css/views/avatars/_PulsedAvatar.scss b/res/css/views/avatars/_PulsedAvatar.scss
new file mode 100644
index 0000000000..ce9e3382ab
--- /dev/null
+++ b/res/css/views/avatars/_PulsedAvatar.scss
@@ -0,0 +1,30 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_PulsedAvatar {
+ @keyframes shadow-pulse {
+ 0% {
+ box-shadow: 0 0 0 0px rgba($accent-color, 0.2);
+ }
+ 100% {
+ box-shadow: 0 0 0 6px rgba($accent-color, 0);
+ }
+ }
+
+ img {
+ animation: shadow-pulse 1s infinite;
+ }
+}
diff --git a/res/css/views/rooms/_JumpToBottomButton.scss b/res/css/views/rooms/_JumpToBottomButton.scss
index 63cf574596..23018df8da 100644
--- a/res/css/views/rooms/_JumpToBottomButton.scss
+++ b/res/css/views/rooms/_JumpToBottomButton.scss
@@ -41,6 +41,11 @@ limitations under the License.
// with text-align in parent
display: inline-block;
padding: 0 4px;
+ color: $roomtile-badge-fg-color;
+ background-color: $roomtile-name-color;
+}
+
+.mx_JumpToBottomButton_highlight .mx_JumpToBottomButton_badge {
color: $secondary-accent-color;
background-color: $warning-color;
}
diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss
index 9c919023e6..7e85c8583d 100644
--- a/res/css/views/rooms/_RoomSublist2.scss
+++ b/res/css/views/rooms/_RoomSublist2.scss
@@ -24,10 +24,6 @@ limitations under the License.
margin-left: 8px;
width: 100%;
- &:first-child {
- margin-top: 12px; // so we're not up against the search/filter
- }
-
.mx_RoomSublist2_headerContainer {
// Create a flexbox to make alignment easy
display: flex;
@@ -49,13 +45,15 @@ limitations under the License.
padding-bottom: 8px;
height: 24px;
+ // Hide the header container if the contained element is stickied.
+ // We don't use display:none as that causes the header to go away too.
+ &.mx_RoomSublist2_headerContainer_hasSticky {
+ height: 0;
+ }
+
.mx_RoomSublist2_stickable {
flex: 1;
max-width: 100%;
- z-index: 2; // Prioritize headers in the visible list over sticky ones
-
- // Set the same background color as the room list for sticky headers
- background-color: $roomlist2-bg-color;
// Create a flexbox to make ordering easy
display: flex;
@@ -67,7 +65,6 @@ limitations under the License.
// when sticky scrolls instead of collapses the list.
&.mx_RoomSublist2_headerContainer_sticky {
position: fixed;
- z-index: 1; // over top of other elements, but still under the ones in the visible list
height: 32px; // to match the header container
// width set by JS
}
@@ -190,28 +187,29 @@ limitations under the License.
flex-direction: column;
overflow: hidden;
+ .mx_RoomSublist2_placeholder {
+ height: 44px; // Height of a room tile plus margins
+ }
+
.mx_RoomSublist2_showNButton {
cursor: pointer;
font-size: $font-13px;
line-height: $font-18px;
color: $roomtile2-preview-color;
- // This is the same color as the left panel background because it needs
- // to occlude the lastmost tile in the list.
- background-color: $roomlist2-bg-color;
-
// Update the render() function for RoomSublist2 if these change
// Update the ListLayout class for minVisibleTiles if these change.
//
- // At 24px high and 8px padding on the top this equates to 0.65 of
+ // At 24px high, 8px padding on the top and 4px padding on the bottom this equates to 0.73 of
// a tile due to how the padding calculations work.
height: 24px;
padding-top: 8px;
+ padding-bottom: 4px;
// We force this to the bottom so it will overlap rooms as needed.
// We account for the space it takes up (24px) in the code through padding.
position: absolute;
- bottom: 4px; // the height of the resize handle
+ bottom: 0;
left: 0;
right: 0;
@@ -238,39 +236,31 @@ limitations under the License.
.mx_RoomSublist2_showLessButtonChevron {
mask-image: url('$(res)/img/feather-customised/chevron-up.svg');
}
-
- &.mx_RoomSublist2_isCutting::before {
- content: '';
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- height: 4px;
- box-shadow: 0px -2px 3px rgba(46, 47, 50, 0.08);
- }
}
// Class name comes from the ResizableBox component
// The hover state needs to use the whole sublist, not just the resizable box,
// so that selector is below and one level higher.
- .react-resizable-handle {
+ .mx_RoomSublist2_resizerHandle {
cursor: ns-resize;
border-radius: 3px;
- // Update RESIZE_HANDLE_HEIGHT if this changes
- height: 4px;
+ // Override styles from library
+ width: unset !important;
+ height: 4px !important; // Update RESIZE_HANDLE_HEIGHT if this changes
// This is positioned directly below the 'show more' button.
position: absolute;
- bottom: 0;
+ bottom: 0 !important; // override from library
// Together, these make the bar 64px wide
- left: calc(50% - 32px);
- right: calc(50% - 32px);
+ // These are also overridden from the library
+ left: calc(50% - 32px) !important;
+ right: calc(50% - 32px) !important;
}
&:hover, &.mx_RoomSublist2_hasMenuOpen {
- .react-resizable-handle {
+ .mx_RoomSublist2_resizerHandle {
opacity: 0.8;
background-color: $primary-fg-color;
}
diff --git a/res/css/views/rooms/_RoomTile2.scss b/res/css/views/rooms/_RoomTile2.scss
index d844c14443..c8a3da3403 100644
--- a/res/css/views/rooms/_RoomTile2.scss
+++ b/res/css/views/rooms/_RoomTile2.scss
@@ -21,6 +21,10 @@ limitations under the License.
margin-bottom: 4px;
padding: 4px;
+ // allow scrollIntoView to ignore the sticky headers, must match combined height of .mx_RoomSublist2_headerContainer
+ scroll-margin-top: 32px;
+ scroll-margin-bottom: 32px;
+
// The tile is also a flexbox row itself
display: flex;
@@ -85,7 +89,6 @@ limitations under the License.
height: 16px;
// don't set width so that it takes no space when there is no badge to show
margin: auto 0; // vertically align
- position: relative; // fixes badge alignment in some scenarios
// Create a flexbox to make aligning dot badges easier
display: flex;
@@ -165,6 +168,11 @@ limitations under the License.
}
}
+// do not apply scroll-margin-bottom to the sublist which will not have a sticky header below it
+.mx_RoomSublist2:last-child .mx_RoomTile2 {
+ scroll-margin-bottom: 0;
+}
+
// We use these both in context menus and the room tiles
.mx_RoomTile2_iconBell::before {
mask-image: url('$(res)/img/feather-customised/bell.svg');
@@ -224,6 +232,10 @@ limitations under the License.
mask-image: url('$(res)/img/feather-customised/star.svg');
}
+ .mx_RoomTile2_iconFavorite::before {
+ mask-image: url('$(res)/img/feather-customised/favourites.svg');
+ }
+
.mx_RoomTile2_iconArrowDown::before {
mask-image: url('$(res)/img/feather-customised/arrow-down.svg');
}
diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss
new file mode 100644
index 0000000000..e13c851716
--- /dev/null
+++ b/res/css/views/voip/_CallContainer.scss
@@ -0,0 +1,89 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_CallContainer {
+ position: absolute;
+ right: 20px;
+ bottom: 72px;
+ border-radius: 8px;
+ overflow: hidden;
+ z-index: 100;
+ box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08);
+
+ cursor: pointer;
+
+ .mx_CallPreview {
+ .mx_VideoView {
+ width: 350px;
+ }
+
+ .mx_VideoView_localVideoFeed {
+ border-radius: 8px;
+ overflow: hidden;
+ }
+ }
+
+ .mx_IncomingCallBox2 {
+ min-width: 250px;
+ background-color: $primary-bg-color;
+ padding: 8px;
+
+ .mx_IncomingCallBox2_CallerInfo {
+ display: flex;
+ direction: row;
+
+ img {
+ margin: 8px;
+ }
+
+ > div {
+ display: flex;
+ flex-direction: column;
+
+ justify-content: center;
+ }
+
+ h1, p {
+ margin: 0px;
+ padding: 0px;
+ font-size: $font-14px;
+ line-height: $font-16px;
+ }
+
+ h1 {
+ font-weight: bold;
+ }
+ }
+
+ .mx_IncomingCallBox2_buttons {
+ padding: 8px;
+ display: flex;
+ flex-direction: row;
+
+ > .mx_IncomingCallBox2_spacer {
+ width: 8px;
+ }
+
+ > * {
+ flex-shrink: 0;
+ flex-grow: 1;
+ margin-right: 0;
+ font-size: $font-15px;
+ line-height: $font-24px;
+ }
+ }
+ }
+}
diff --git a/res/css/views/voip/_CallView2.scss b/res/css/views/voip/_CallView2.scss
new file mode 100644
index 0000000000..3b66e7a175
--- /dev/null
+++ b/res/css/views/voip/_CallView2.scss
@@ -0,0 +1,96 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+
+.mx_CallView2_voice {
+ background-color: $accent-color;
+ color: $accent-fg-color;
+ cursor: pointer;
+ padding: 6px;
+ font-weight: bold;
+
+ border-radius: 8px;
+ min-width: 200px;
+
+ display: flex;
+ align-items: center;
+
+ img {
+ margin: 4px;
+ margin-right: 10px;
+ }
+
+ > div {
+ display: flex;
+ flex-direction: column;
+ // Hacky vertical align
+ padding-top: 3px;
+ }
+
+ > div > p,
+ > div > h1 {
+ padding: 0;
+ margin: 0;
+ font-size: $font-13px;
+ line-height: $font-15px;
+ }
+
+ > div > p {
+ font-weight: bold;
+ }
+
+ > * {
+ flex-grow: 0;
+ flex-shrink: 0;
+ }
+}
+
+.mx_CallView2_hangup {
+ position: absolute;
+
+ right: 8px;
+ bottom: 10px;
+
+ height: 35px;
+ width: 35px;
+
+ border-radius: 35px;
+
+ background-color: $notice-primary-color;
+
+ z-index: 101;
+
+ cursor: pointer;
+
+ &::before {
+ content: '';
+ position: absolute;
+
+ height: 20px;
+ width: 20px;
+
+ top: 6.5px;
+ left: 7.5px;
+
+ mask: url('$(res)/img/hangup.svg');
+ mask-size: contain;
+ background-size: contain;
+
+ background-color: $primary-fg-color;
+ }
+}
diff --git a/res/img/feather-customised/favourites.svg b/res/img/feather-customised/favourites.svg
new file mode 100644
index 0000000000..80f08f6e55
--- /dev/null
+++ b/res/img/feather-customised/favourites.svg
@@ -0,0 +1,3 @@
+
diff --git a/src/@types/common.ts b/src/@types/common.ts
index 9109993541..a24d47ac9e 100644
--- a/src/@types/common.ts
+++ b/src/@types/common.ts
@@ -17,3 +17,4 @@ limitations under the License.
// Based on https://stackoverflow.com/a/53229857/3532235
export type Without = {[P in Exclude] ? : never};
export type XOR = (T | U) extends object ? (Without & U) | (Without & T) : T | U;
+export type Writeable = { -readonly [P in keyof T]: T[P] };
diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts
index 2c2fec759c..fc52296d8b 100644
--- a/src/@types/global.d.ts
+++ b/src/@types/global.d.ts
@@ -21,6 +21,7 @@ import ToastStore from "../stores/ToastStore";
import DeviceListener from "../DeviceListener";
import { RoomListStore2 } from "../stores/room-list/RoomListStore2";
import { PlatformPeg } from "../PlatformPeg";
+import RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore";
declare global {
interface Window {
@@ -34,6 +35,7 @@ declare global {
mx_ToastStore: ToastStore;
mx_DeviceListener: DeviceListener;
mx_RoomListStore2: RoomListStore2;
+ mx_RoomListLayoutStore: RoomListLayoutStore;
mxPlatformPeg: PlatformPeg;
}
diff --git a/src/@types/polyfill.ts b/src/@types/polyfill.ts
new file mode 100644
index 0000000000..3ce05d9c2f
--- /dev/null
+++ b/src/@types/polyfill.ts
@@ -0,0 +1,38 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// This is intended to fix re-resizer because of its unguarded `instanceof TouchEvent` checks.
+export function polyfillTouchEvent() {
+ // Firefox doesn't have touch events without touch devices being present, so create a fake
+ // one we can rely on lying about.
+ if (!window.TouchEvent) {
+ // We have no intention of actually using this, so just lie.
+ window.TouchEvent = class TouchEvent extends UIEvent {
+ public get altKey(): boolean { return false; }
+ public get changedTouches(): any { return []; }
+ public get ctrlKey(): boolean { return false; }
+ public get metaKey(): boolean { return false; }
+ public get shiftKey(): boolean { return false; }
+ public get targetTouches(): any { return []; }
+ public get touches(): any { return []; }
+ public get rotation(): number { return 0.0; }
+ public get scale(): number { return 0.0; }
+ constructor(eventType: string, params?: any) {
+ super(eventType, params);
+ }
+ };
+ }
+}
diff --git a/src/HtmlUtils.js b/src/HtmlUtils.tsx
similarity index 83%
rename from src/HtmlUtils.js
rename to src/HtmlUtils.tsx
index 34e9e55d25..6dba041685 100644
--- a/src/HtmlUtils.js
+++ b/src/HtmlUtils.tsx
@@ -17,10 +17,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-'use strict';
-
-import ReplyThread from "./components/views/elements/ReplyThread";
-
import React from 'react';
import sanitizeHtml from 'sanitize-html';
import * as linkify from 'linkifyjs';
@@ -28,12 +24,13 @@ import linkifyMatrix from './linkify-matrix';
import _linkifyElement from 'linkifyjs/element';
import _linkifyString from 'linkifyjs/string';
import classNames from 'classnames';
-import {MatrixClientPeg} from './MatrixClientPeg';
+import EMOJIBASE_REGEX from 'emojibase-regex';
import url from 'url';
-import EMOJIBASE_REGEX from 'emojibase-regex';
+import {MatrixClientPeg} from './MatrixClientPeg';
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
+import ReplyThread from "./components/views/elements/ReplyThread";
linkifyMatrix(linkify);
@@ -64,7 +61,7 @@ const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
* need emojification.
* unicodeToImage uses this function.
*/
-function mightContainEmoji(str) {
+function mightContainEmoji(str: string) {
return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
}
@@ -74,7 +71,7 @@ function mightContainEmoji(str) {
* @param {String} char The emoji character
* @return {String} The shortcode (such as :thumbup:)
*/
-export function unicodeToShortcode(char) {
+export function unicodeToShortcode(char: string) {
const data = getEmojiFromUnicode(char);
return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : '');
}
@@ -85,7 +82,7 @@ export function unicodeToShortcode(char) {
* @param {String} shortcode The shortcode (such as :thumbup:)
* @return {String} The emoji character; null if none exists
*/
-export function shortcodeToUnicode(shortcode) {
+export function shortcodeToUnicode(shortcode: string) {
shortcode = shortcode.slice(1, shortcode.length - 1);
const data = SHORTCODE_TO_EMOJI.get(shortcode);
return data ? data.unicode : null;
@@ -100,7 +97,7 @@ export function processHtmlForSending(html: string): string {
}
let contentHTML = "";
- for (let i=0; i < contentDiv.children.length; i++) {
+ for (let i = 0; i < contentDiv.children.length; i++) {
const element = contentDiv.children[i];
if (element.tagName.toLowerCase() === 'p') {
contentHTML += element.innerHTML;
@@ -122,12 +119,19 @@ export function processHtmlForSending(html: string): string {
* Given an untrusted HTML string, return a React node with an sanitized version
* of that HTML.
*/
-export function sanitizedHtmlNode(insaneHtml) {
+export function sanitizedHtmlNode(insaneHtml: string) {
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
return ;
}
+export function sanitizedHtmlNodeInnerText(insaneHtml: string) {
+ const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
+ const contentDiv = document.createElement("div");
+ contentDiv.innerHTML = saneHtml;
+ return contentDiv.innerText;
+}
+
/**
* Tests if a URL from an untrusted source may be safely put into the DOM
* The biggest threat here is javascript: URIs.
@@ -136,7 +140,7 @@ export function sanitizedHtmlNode(insaneHtml) {
* other places we need to sanitise URLs.
* @return true if permitted, otherwise false
*/
-export function isUrlPermitted(inputUrl) {
+export function isUrlPermitted(inputUrl: string) {
try {
const parsed = url.parse(inputUrl);
if (!parsed.protocol) return false;
@@ -147,9 +151,9 @@ export function isUrlPermitted(inputUrl) {
}
}
-const transformTags = { // custom to matrix
+const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to matrix
// add blank targets to all hyperlinks except vector URLs
- 'a': function(tagName, attribs) {
+ 'a': function(tagName: string, attribs: sanitizeHtml.Attributes) {
if (attribs.href) {
attribs.target = '_blank'; // by default
@@ -162,7 +166,7 @@ const transformTags = { // custom to matrix
attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/
return { tagName, attribs };
},
- 'img': function(tagName, attribs) {
+ 'img': function(tagName: string, attribs: sanitizeHtml.Attributes) {
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and
// we don't want to allow images with `https?` `src`s.
@@ -176,7 +180,7 @@ const transformTags = { // custom to matrix
);
return { tagName, attribs };
},
- 'code': function(tagName, attribs) {
+ 'code': function(tagName: string, attribs: sanitizeHtml.Attributes) {
if (typeof attribs.class !== 'undefined') {
// Filter out all classes other than ones starting with language- for syntax highlighting.
const classes = attribs.class.split(/\s/).filter(function(cl) {
@@ -186,7 +190,7 @@ const transformTags = { // custom to matrix
}
return { tagName, attribs };
},
- '*': function(tagName, attribs) {
+ '*': function(tagName: string, attribs: sanitizeHtml.Attributes) {
// Delete any style previously assigned, style is an allowedTag for font and span
// because attributes are stripped after transforming
delete attribs.style;
@@ -220,7 +224,7 @@ const transformTags = { // custom to matrix
},
};
-const sanitizeHtmlParams = {
+const sanitizeHtmlParams: sanitizeHtml.IOptions = {
allowedTags: [
'font', // custom to matrix for IRC-style font coloring
'del', // for markdown
@@ -247,16 +251,16 @@ const sanitizeHtmlParams = {
};
// this is the same as the above except with less rewriting
-const composerSanitizeHtmlParams = Object.assign({}, sanitizeHtmlParams);
-composerSanitizeHtmlParams.transformTags = {
- 'code': transformTags['code'],
- '*': transformTags['*'],
+const composerSanitizeHtmlParams: sanitizeHtml.IOptions = {
+ ...sanitizeHtmlParams,
+ transformTags: {
+ 'code': transformTags['code'],
+ '*': transformTags['*'],
+ },
};
-class BaseHighlighter {
- constructor(highlightClass, highlightLink) {
- this.highlightClass = highlightClass;
- this.highlightLink = highlightLink;
+abstract class BaseHighlighter {
+ constructor(public highlightClass: string, public highlightLink: string) {
}
/**
@@ -270,47 +274,49 @@ class BaseHighlighter {
* returns a list of results (strings for HtmlHighligher, react nodes for
* TextHighlighter).
*/
- applyHighlights(safeSnippet, safeHighlights) {
+ public applyHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
let lastOffset = 0;
let offset;
- let nodes = [];
+ let nodes: T[] = [];
const safeHighlight = safeHighlights[0];
while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) {
// handle preamble
if (offset > lastOffset) {
- var subSnippet = safeSnippet.substring(lastOffset, offset);
- nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights));
+ const subSnippet = safeSnippet.substring(lastOffset, offset);
+ nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
}
// do highlight. use the original string rather than safeHighlight
// to preserve the original casing.
const endOffset = offset + safeHighlight.length;
- nodes.push(this._processSnippet(safeSnippet.substring(offset, endOffset), true));
+ nodes.push(this.processSnippet(safeSnippet.substring(offset, endOffset), true));
lastOffset = endOffset;
}
// handle postamble
if (lastOffset !== safeSnippet.length) {
- subSnippet = safeSnippet.substring(lastOffset, undefined);
- nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights));
+ const subSnippet = safeSnippet.substring(lastOffset, undefined);
+ nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
}
return nodes;
}
- _applySubHighlights(safeSnippet, safeHighlights) {
+ private applySubHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
if (safeHighlights[1]) {
// recurse into this range to check for the next set of highlight matches
return this.applyHighlights(safeSnippet, safeHighlights.slice(1));
} else {
// no more highlights to be found, just return the unhighlighted string
- return [this._processSnippet(safeSnippet, false)];
+ return [this.processSnippet(safeSnippet, false)];
}
}
+
+ protected abstract processSnippet(snippet: string, highlight: boolean): T;
}
-class HtmlHighlighter extends BaseHighlighter {
+class HtmlHighlighter extends BaseHighlighter {
/* highlight the given snippet if required
*
* snippet: content of the span; must have been sanitised
@@ -318,28 +324,23 @@ class HtmlHighlighter extends BaseHighlighter {
*
* returns an HTML string
*/
- _processSnippet(snippet, highlight) {
+ protected processSnippet(snippet: string, highlight: boolean): string {
if (!highlight) {
// nothing required here
return snippet;
}
- let span = ""
- + snippet + "";
+ let span = `${snippet}`;
if (this.highlightLink) {
- span = ""
- +span+"";
+ span = `${span}`;
}
return span;
}
}
-class TextHighlighter extends BaseHighlighter {
- constructor(highlightClass, highlightLink) {
- super(highlightClass, highlightLink);
- this._key = 0;
- }
+class TextHighlighter extends BaseHighlighter {
+ private key = 0;
/* create a node to hold the given content
*
@@ -348,13 +349,12 @@ class TextHighlighter extends BaseHighlighter {
*
* returns a React node
*/
- _processSnippet(snippet, highlight) {
- const key = this._key++;
+ protected processSnippet(snippet: string, highlight: boolean): React.ReactNode {
+ const key = this.key++;
- let node =
-
- { snippet }
- ;
+ let node =
+ { snippet }
+ ;
if (highlight && this.highlightLink) {
node = { node };
@@ -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;
+}
/* turn a matrix event body into html
*
@@ -378,7 +392,7 @@ class TextHighlighter extends BaseHighlighter {
* opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer
* opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString)
*/
-export function bodyToHtml(content, highlights, opts={}) {
+export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) {
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
let bodyHasEmoji = false;
@@ -387,9 +401,9 @@ export function bodyToHtml(content, highlights, opts={}) {
sanitizeParams = composerSanitizeHtmlParams;
}
- let strippedBody;
- let safeBody;
- let isDisplayedWithHtml;
+ let strippedBody: string;
+ let safeBody: string;
+ let isDisplayedWithHtml: boolean;
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
// to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which
// are interrupted by HTML tags (not that we did before) - e.g. foobar won't get highlighted
@@ -471,7 +485,7 @@ export function bodyToHtml(content, highlights, opts={}) {
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string} Linkified string
*/
-export function linkifyString(str, options = linkifyMatrix.options) {
+export function linkifyString(str: string, options = linkifyMatrix.options) {
return _linkifyString(str, options);
}
@@ -482,7 +496,7 @@ export function linkifyString(str, options = linkifyMatrix.options) {
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options
* @returns {object}
*/
-export function linkifyElement(element, options = linkifyMatrix.options) {
+export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options) {
return _linkifyElement(element, options);
}
@@ -493,7 +507,7 @@ export function linkifyElement(element, options = linkifyMatrix.options) {
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string}
*/
-export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.options) {
+export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) {
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
}
@@ -504,7 +518,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.option
* @param {Node} node
* @returns {bool}
*/
-export function checkBlockNode(node) {
+export function checkBlockNode(node: Node) {
switch (node.nodeName) {
case "H1":
case "H2":
diff --git a/src/accessibility/context_menu/ContextMenuButton.tsx b/src/accessibility/context_menu/ContextMenuButton.tsx
new file mode 100644
index 0000000000..c358155e10
--- /dev/null
+++ b/src/accessibility/context_menu/ContextMenuButton.tsx
@@ -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
+export const ContextMenuButton: React.FC = ({
+ label,
+ isExpanded,
+ children,
+ onClick,
+ onContextMenu,
+ ...props
+}) => {
+ return (
+
+ { children }
+
+ );
+};
diff --git a/src/accessibility/context_menu/MenuGroup.tsx b/src/accessibility/context_menu/MenuGroup.tsx
new file mode 100644
index 0000000000..9334e17a18
--- /dev/null
+++ b/src/accessibility/context_menu/MenuGroup.tsx
@@ -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 {
+ label: string;
+}
+
+// Semantic component for representing a role=group for grouping menu radios/checkboxes
+export const MenuGroup: React.FC = ({children, label, ...props}) => {
+ return
+ { children }
+
;
+};
diff --git a/src/accessibility/context_menu/MenuItem.tsx b/src/accessibility/context_menu/MenuItem.tsx
new file mode 100644
index 0000000000..64233e51ad
--- /dev/null
+++ b/src/accessibility/context_menu/MenuItem.tsx
@@ -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 {
+ label?: string;
+}
+
+// Semantic component for representing a role=menuitem
+export const MenuItem: React.FC = ({children, label, ...props}) => {
+ return (
+
+ { children }
+
+ );
+};
+
diff --git a/src/accessibility/context_menu/MenuItemCheckbox.tsx b/src/accessibility/context_menu/MenuItemCheckbox.tsx
new file mode 100644
index 0000000000..5eb8cc4819
--- /dev/null
+++ b/src/accessibility/context_menu/MenuItemCheckbox.tsx
@@ -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 {
+ label?: string;
+ active: boolean;
+}
+
+// Semantic component for representing a role=menuitemcheckbox
+export const MenuItemCheckbox: React.FC = ({children, label, active, disabled, ...props}) => {
+ return (
+
+ { children }
+
+ );
+};
diff --git a/src/accessibility/context_menu/MenuItemRadio.tsx b/src/accessibility/context_menu/MenuItemRadio.tsx
new file mode 100644
index 0000000000..472f13ff14
--- /dev/null
+++ b/src/accessibility/context_menu/MenuItemRadio.tsx
@@ -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 {
+ label?: string;
+ active: boolean;
+}
+
+// Semantic component for representing a role=menuitemradio
+export const MenuItemRadio: React.FC = ({children, label, active, disabled, ...props}) => {
+ return (
+
+ { children }
+
+ );
+};
diff --git a/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx b/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx
new file mode 100644
index 0000000000..d373f892c9
--- /dev/null
+++ b/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx
@@ -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 {
+ 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 = ({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 (
+
+ { children }
+
+ );
+};
diff --git a/src/accessibility/context_menu/StyledMenuItemRadio.tsx b/src/accessibility/context_menu/StyledMenuItemRadio.tsx
new file mode 100644
index 0000000000..5e5aa90a38
--- /dev/null
+++ b/src/accessibility/context_menu/StyledMenuItemRadio.tsx
@@ -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 {
+ 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 = ({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 (
+
+ { children }
+
+ );
+};
diff --git a/src/components/structures/ContextMenu.js b/src/components/structures/ContextMenu.tsx
similarity index 53%
rename from src/components/structures/ContextMenu.js
rename to src/components/structures/ContextMenu.tsx
index bda194ddd0..cb1349da4b 100644
--- a/src/components/structures/ContextMenu.js
+++ b/src/components/structures/ContextMenu.tsx
@@ -16,15 +16,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import React, {useRef, useState} from 'react';
-import ReactDOM from 'react-dom';
-import PropTypes from 'prop-types';
-import classNames from 'classnames';
+import React, {CSSProperties, useRef, useState} from "react";
+import ReactDOM from "react-dom";
+import classNames from "classnames";
+
import {Key} from "../../Keyboard";
-import * as sdk from "../../index";
-import AccessibleButton from "../views/elements/AccessibleButton";
-import StyledCheckbox from "../views/elements/StyledCheckbox";
-import StyledRadioButton from "../views/elements/StyledRadioButton";
+import {Writeable} from "../../@types/common";
// Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and
@@ -32,8 +29,8 @@ import StyledRadioButton from "../views/elements/StyledRadioButton";
const ContextualMenuContainerId = "mx_ContextualMenu_Container";
-function getOrCreateContainer() {
- let container = document.getElementById(ContextualMenuContainerId);
+function getOrCreateContainer(): HTMLDivElement {
+ let container = document.getElementById(ContextualMenuContainerId) as HTMLDivElement;
if (!container) {
container = document.createElement("div");
@@ -45,50 +42,70 @@ function getOrCreateContainer() {
}
const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
+
+interface IPosition {
+ top?: number;
+ bottom?: number;
+ left?: number;
+ right?: number;
+}
+
+export enum ChevronFace {
+ Top = "top",
+ Bottom = "bottom",
+ Left = "left",
+ Right = "right",
+ None = "none",
+}
+
+interface IProps extends IPosition {
+ menuWidth?: number;
+ menuHeight?: number;
+
+ chevronOffset?: number;
+ chevronFace?: ChevronFace;
+
+ menuPaddingTop?: number;
+ menuPaddingBottom?: number;
+ menuPaddingLeft?: number;
+ menuPaddingRight?: number;
+
+ zIndex?: number;
+
+ // If true, insert an invisible screen-sized element behind the menu that when clicked will close it.
+ hasBackground?: boolean;
+ // whether this context menu should be focus managed. If false it must handle itself
+ managed?: boolean;
+
+ // Function to be called on menu close
+ onFinished();
+ // on resize callback
+ windowResize?();
+}
+
+interface IState {
+ contextMenuElem: HTMLDivElement;
+}
+
// Generic ContextMenu Portal wrapper
// all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1}
// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
-export class ContextMenu extends React.Component {
- static propTypes = {
- top: PropTypes.number,
- bottom: PropTypes.number,
- left: PropTypes.number,
- right: PropTypes.number,
- menuWidth: PropTypes.number,
- menuHeight: PropTypes.number,
- chevronOffset: PropTypes.number,
- chevronFace: PropTypes.string, // top, bottom, left, right or none
- // Function to be called on menu close
- onFinished: PropTypes.func.isRequired,
- menuPaddingTop: PropTypes.number,
- menuPaddingRight: PropTypes.number,
- menuPaddingBottom: PropTypes.number,
- menuPaddingLeft: PropTypes.number,
- zIndex: PropTypes.number,
-
- // If true, insert an invisible screen-sized element behind the
- // menu that when clicked will close it.
- hasBackground: PropTypes.bool,
-
- // on resize callback
- windowResize: PropTypes.func,
-
- managed: PropTypes.bool, // whether this context menu should be focus managed. If false it must handle itself
- };
+export class ContextMenu extends React.PureComponent {
+ private initialFocus: HTMLElement;
static defaultProps = {
hasBackground: true,
managed: true,
};
- constructor() {
- super();
+ constructor(props, context) {
+ super(props, context);
this.state = {
contextMenuElem: null,
};
// persist what had focus when we got initialized so we can return it after
- this.initialFocus = document.activeElement;
+ this.initialFocus = document.activeElement as HTMLElement;
}
componentWillUnmount() {
@@ -96,7 +113,7 @@ export class ContextMenu extends React.Component {
this.initialFocus.focus();
}
- collectContextMenuRect = (element) => {
+ private collectContextMenuRect = (element) => {
// We don't need to clean up when unmounting, so ignore
if (!element) return;
@@ -113,7 +130,7 @@ export class ContextMenu extends React.Component {
});
};
- onContextMenu = (e) => {
+ private onContextMenu = (e) => {
if (this.props.onFinished) {
this.props.onFinished();
@@ -136,20 +153,20 @@ export class ContextMenu extends React.Component {
}
};
- onContextMenuPreventBubbling = (e) => {
+ private onContextMenuPreventBubbling = (e) => {
// stop propagation so that any context menu handlers don't leak out of this context menu
// but do not inhibit the default browser menu
e.stopPropagation();
};
// Prevent clicks on the background from going through to the component which opened the menu.
- _onFinished = (ev: InputEvent) => {
+ private onFinished = (ev: React.MouseEvent) => {
ev.stopPropagation();
ev.preventDefault();
if (this.props.onFinished) this.props.onFinished();
};
- _onMoveFocus = (element, up) => {
+ private onMoveFocus = (element: Element, up: boolean) => {
let descending = false; // are we currently descending or ascending through the DOM tree?
do {
@@ -183,25 +200,25 @@ export class ContextMenu extends React.Component {
} while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role")));
if (element) {
- element.focus();
+ (element as HTMLElement).focus();
}
};
- _onMoveFocusHomeEnd = (element, up) => {
+ private onMoveFocusHomeEnd = (element: Element, up: boolean) => {
let results = element.querySelectorAll('[role^="menuitem"]');
if (!results) {
results = element.querySelectorAll('[tab-index]');
}
if (results && results.length) {
if (up) {
- results[0].focus();
+ (results[0] as HTMLElement).focus();
} else {
- results[results.length - 1].focus();
+ (results[results.length - 1] as HTMLElement).focus();
}
}
};
- _onKeyDown = (ev) => {
+ private onKeyDown = (ev: React.KeyboardEvent) => {
if (!this.props.managed) {
if (ev.key === Key.ESCAPE) {
this.props.onFinished();
@@ -219,16 +236,16 @@ export class ContextMenu extends React.Component {
this.props.onFinished();
break;
case Key.ARROW_UP:
- this._onMoveFocus(ev.target, true);
+ this.onMoveFocus(ev.target as Element, true);
break;
case Key.ARROW_DOWN:
- this._onMoveFocus(ev.target, false);
+ this.onMoveFocus(ev.target as Element, false);
break;
case Key.HOME:
- this._onMoveFocusHomeEnd(this.state.contextMenuElem, true);
+ this.onMoveFocusHomeEnd(this.state.contextMenuElem, true);
break;
case Key.END:
- this._onMoveFocusHomeEnd(this.state.contextMenuElem, false);
+ this.onMoveFocusHomeEnd(this.state.contextMenuElem, false);
break;
default:
handled = false;
@@ -241,9 +258,8 @@ export class ContextMenu extends React.Component {
}
};
- renderMenu(hasBackground=this.props.hasBackground) {
- const position = {};
- let chevronFace = null;
+ protected renderMenu(hasBackground = this.props.hasBackground) {
+ const position: Partial> = {};
const props = this.props;
if (props.top) {
@@ -252,23 +268,24 @@ export class ContextMenu extends React.Component {
position.bottom = props.bottom;
}
+ let chevronFace: ChevronFace;
if (props.left) {
position.left = props.left;
- chevronFace = 'left';
+ chevronFace = ChevronFace.Left;
} else {
position.right = props.right;
- chevronFace = 'right';
+ chevronFace = ChevronFace.Right;
}
const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null;
- const chevronOffset = {};
+ const chevronOffset: CSSProperties = {};
if (props.chevronFace) {
chevronFace = props.chevronFace;
}
- const hasChevron = chevronFace && chevronFace !== "none";
+ const hasChevron = chevronFace && chevronFace !== ChevronFace.None;
- if (chevronFace === 'top' || chevronFace === 'bottom') {
+ if (chevronFace === ChevronFace.Top || chevronFace === ChevronFace.Bottom) {
chevronOffset.left = props.chevronOffset;
} else if (position.top !== undefined) {
const target = position.top;
@@ -298,13 +315,13 @@ export class ContextMenu extends React.Component {
'mx_ContextualMenu_right': !hasChevron && position.right,
'mx_ContextualMenu_top': !hasChevron && position.top,
'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
- 'mx_ContextualMenu_withChevron_left': chevronFace === 'left',
- 'mx_ContextualMenu_withChevron_right': chevronFace === 'right',
- 'mx_ContextualMenu_withChevron_top': chevronFace === 'top',
- 'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom',
+ 'mx_ContextualMenu_withChevron_left': chevronFace === ChevronFace.Left,
+ 'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
+ 'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
+ 'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom,
});
- const menuStyle = {};
+ const menuStyle: CSSProperties = {};
if (props.menuWidth) {
menuStyle.width = props.menuWidth;
}
@@ -335,13 +352,28 @@ export class ContextMenu extends React.Component {
let background;
if (hasBackground) {
background = (
-
+
);
}
return (
-
-
+
+
{ chevron }
{ props.children }
@@ -350,195 +382,13 @@ export class ContextMenu extends React.Component {
);
}
- render() {
+ render(): React.ReactChild {
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
}
}
-// Semantic component for representing the AccessibleButton which launches a
-export const ContextMenuButton = ({ label, isExpanded, children, onClick, onContextMenu, ...props }) => {
- const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
- return (
-
- { children }
-
- );
-};
-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 (
-
- { children }
-
- );
-};
-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
- { children }
-
;
-};
-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 (
-
- { children }
-
- );
-};
-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 (
-
- { children }
-
- );
-};
-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 (
-
- { children }
-
- );
-};
-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 (
-
- { children }
-
- );
-};
-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 to position context menu to right of elementRect with chevronOffset
-export const toRightOf = (elementRect, chevronOffset=12) => {
+export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => {
const left = elementRect.right + window.pageXOffset + 3;
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
top -= chevronOffset + 8; // where 8 is half the height of the chevron
@@ -546,8 +396,8 @@ export const toRightOf = (elementRect, chevronOffset=12) => {
};
// Placement method for to position context menu right-aligned and flowing to the left of elementRect
-export const aboveLeftOf = (elementRect, chevronFace="none") => {
- const menuOptions = { chevronFace };
+export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None) => {
+ const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.pageXOffset;
const buttonBottom = elementRect.bottom + window.pageYOffset;
@@ -605,3 +455,12 @@ export function createMenu(ElementClass, props) {
return {close: onFinished};
}
+
+// re-export the semantic helper components for simplicity
+export {ContextMenuButton} from "../../accessibility/context_menu/ContextMenuButton";
+export {MenuGroup} from "../../accessibility/context_menu/MenuGroup";
+export {MenuItem} from "../../accessibility/context_menu/MenuItem";
+export {MenuItemCheckbox} from "../../accessibility/context_menu/MenuItemCheckbox";
+export {MenuItemRadio} from "../../accessibility/context_menu/MenuItemRadio";
+export {StyledMenuItemCheckbox} from "../../accessibility/context_menu/StyledMenuItemCheckbox";
+export {StyledMenuItemRadio} from "../../accessibility/context_menu/StyledMenuItemRadio";
diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx
index 769f7daefd..bb1e2d4991 100644
--- a/src/components/structures/LeftPanel2.tsx
+++ b/src/components/structures/LeftPanel2.tsx
@@ -21,6 +21,7 @@ import classNames from "classnames";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import RoomList2 from "../views/rooms/RoomList2";
+import { HEADER_HEIGHT } from "../views/rooms/RoomSublist2";
import { Action } from "../../dispatcher/actions";
import UserMenu from "./UserMenu";
import RoomSearch from "./RoomSearch";
@@ -114,64 +115,131 @@ export default class LeftPanel2 extends React.Component {
};
private handleStickyHeaders(list: HTMLDivElement) {
- // TODO: Evaluate if this has any performance benefit or detriment.
- // See https://github.com/vector-im/riot-web/issues/14035
-
if (this.isDoingStickyHeaders) return;
this.isDoingStickyHeaders = true;
- if (window.requestAnimationFrame) {
- window.requestAnimationFrame(() => {
- this.doStickyHeaders(list);
- this.isDoingStickyHeaders = false;
- });
- } else {
+ window.requestAnimationFrame(() => {
this.doStickyHeaders(list);
this.isDoingStickyHeaders = false;
- }
+ });
}
private doStickyHeaders(list: HTMLDivElement) {
- const rlRect = list.getBoundingClientRect();
- const bottom = rlRect.bottom;
- const top = rlRect.top;
+ const topEdge = list.scrollTop;
+ const bottomEdge = list.offsetHeight + list.scrollTop;
const sublists = list.querySelectorAll(".mx_RoomSublist2");
- const headerHeight = 32; // Note: must match the CSS!
- const headerRightMargin = 24; // calculated from margins and widths to align with non-sticky tiles
- const headerStickyWidth = rlRect.width - headerRightMargin;
+ const headerRightMargin = 16; // calculated from margins and widths to align with non-sticky tiles
+ const headerStickyWidth = list.clientWidth - headerRightMargin;
+
+ // We track which styles we want on a target before making the changes to avoid
+ // excessive layout updates.
+ const targetStyles = new Map();
- let gotBottom = false;
let lastTopHeader;
+ let firstBottomHeader;
for (const sublist of sublists) {
- const slRect = sublist.getBoundingClientRect();
-
const header = sublist.querySelector(".mx_RoomSublist2_stickable");
+ header.style.removeProperty("display"); // always clear display:none first
- if (slRect.top + headerHeight > bottom && !gotBottom) {
- header.classList.add("mx_RoomSublist2_headerContainer_sticky");
- header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom");
- header.style.width = `${headerStickyWidth}px`;
- header.style.removeProperty("top");
- gotBottom = true;
- } else if ((slRect.top - (headerHeight / 3)) < top) {
- header.classList.add("mx_RoomSublist2_headerContainer_sticky");
- header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
- header.style.width = `${headerStickyWidth}px`;
- header.style.top = `${rlRect.top}px`;
+ // When an element is <=40% off screen, make it take over
+ const offScreenFactor = 0.4;
+ const isOffTop = (sublist.offsetTop + (offScreenFactor * HEADER_HEIGHT)) <= topEdge;
+ const isOffBottom = (sublist.offsetTop + (offScreenFactor * HEADER_HEIGHT)) >= bottomEdge;
+
+ if (isOffTop || sublist === sublists[0]) {
+ targetStyles.set(header, { stickyTop: true });
if (lastTopHeader) {
lastTopHeader.style.display = "none";
+ targetStyles.set(lastTopHeader, { makeInvisible: true });
}
- // first unset it, if set in last iteration
- header.style.removeProperty("display");
lastTopHeader = header;
+ } else if (isOffBottom && !firstBottomHeader) {
+ targetStyles.set(header, { stickyBottom: true });
+ firstBottomHeader = header;
} else {
- header.classList.remove("mx_RoomSublist2_headerContainer_sticky");
- header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop");
- header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom");
- header.style.removeProperty("width");
- header.style.removeProperty("top");
+ targetStyles.set(header, {}); // nothing == clear
}
}
+
+ // Run over the style changes and make them reality. We check to see if we're about to
+ // cause a no-op update, as adding/removing properties that are/aren't there cause
+ // layout updates.
+ for (const header of targetStyles.keys()) {
+ const style = targetStyles.get(header);
+ const headerContainer = header.parentElement; // .mx_RoomSublist2_headerContainer
+
+ if (style.makeInvisible) {
+ // we will have already removed the 'display: none', so add it back.
+ header.style.display = "none";
+ continue; // nothing else to do, even if sticky somehow
+ }
+
+ if (style.stickyTop) {
+ if (!header.classList.contains("mx_RoomSublist2_headerContainer_stickyTop")) {
+ header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
+ }
+
+ const newTop = `${list.parentElement.offsetTop}px`;
+ if (header.style.top !== newTop) {
+ header.style.top = newTop;
+ }
+ } else if (style.stickyBottom) {
+ if (!header.classList.contains("mx_RoomSublist2_headerContainer_stickyBottom")) {
+ header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom");
+ }
+ }
+
+ if (style.stickyTop || style.stickyBottom) {
+ if (!header.classList.contains("mx_RoomSublist2_headerContainer_sticky")) {
+ header.classList.add("mx_RoomSublist2_headerContainer_sticky");
+ }
+ if (!headerContainer.classList.contains("mx_RoomSublist2_headerContainer_hasSticky")) {
+ headerContainer.classList.add("mx_RoomSublist2_headerContainer_hasSticky");
+ }
+
+ const newWidth = `${headerStickyWidth}px`;
+ if (header.style.width !== newWidth) {
+ header.style.width = newWidth;
+ }
+ } else if (!style.stickyTop && !style.stickyBottom) {
+ if (header.classList.contains("mx_RoomSublist2_headerContainer_sticky")) {
+ header.classList.remove("mx_RoomSublist2_headerContainer_sticky");
+ }
+ if (header.classList.contains("mx_RoomSublist2_headerContainer_stickyTop")) {
+ header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop");
+ }
+ if (header.classList.contains("mx_RoomSublist2_headerContainer_stickyBottom")) {
+ header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom");
+ }
+ if (headerContainer.classList.contains("mx_RoomSublist2_headerContainer_hasSticky")) {
+ headerContainer.classList.remove("mx_RoomSublist2_headerContainer_hasSticky");
+ }
+ if (header.style.width) {
+ header.style.removeProperty('width');
+ }
+ if (header.style.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; // .mx_LeftPanel2_roomListWrapper
+ if (lastTopHeader) {
+ listWrapper.classList.add("mx_LeftPanel2_roomListWrapper_stickyTop");
+ } else {
+ listWrapper.classList.remove("mx_LeftPanel2_roomListWrapper_stickyTop");
+ }
+ if (firstBottomHeader) {
+ listWrapper.classList.add("mx_LeftPanel2_roomListWrapper_stickyBottom");
+ } else {
+ listWrapper.classList.remove("mx_LeftPanel2_roomListWrapper_stickyBottom");
+ }
}
// TODO: Improve header reliability: https://github.com/vector-im/riot-web/issues/14232
@@ -325,15 +393,17 @@ export default class LeftPanel2 extends React.Component {
+
);
}
diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx
index d152a2b030..cdc41b872d 100644
--- a/src/components/structures/RoomSearch.tsx
+++ b/src/components/structures/RoomSearch.tsx
@@ -81,6 +81,7 @@ export default class RoomSearch extends React.PureComponent {
private openSearch = () => {
defaultDispatcher.dispatch({action: "show_left_panel"});
+ defaultDispatcher.dispatch({action: "focus_room_filter"});
};
private onChange = () => {
@@ -104,7 +105,7 @@ export default class RoomSearch extends React.PureComponent {
ev.target.select();
};
- private onBlur = () => {
+ private onBlur = (ev: React.FocusEvent) => {
this.setState({focused: false});
};
diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js
index 519c4c1f8e..a9f75ce632 100644
--- a/src/components/structures/RoomView.js
+++ b/src/components/structures/RoomView.js
@@ -2044,6 +2044,7 @@ export default createReactClass({
if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) {
const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton');
jumpToBottom = ( 0}
numUnreadMessages={this.state.numUnreadMessages}
onScrollToBottomClick={this.jumpToLiveTimeline}
/>);
diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx
index 221ec07439..a6eabe25f7 100644
--- a/src/components/structures/UserMenu.tsx
+++ b/src/components/structures/UserMenu.tsx
@@ -14,14 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import * as React from "react";
+import React, { createRef } from "react";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
import { Action } from "../../dispatcher/actions";
-import { createRef } from "react";
import { _t } from "../../languageHandler";
-import {ContextMenu, ContextMenuButton, MenuItem} from "./ContextMenu";
+import { ChevronFace, ContextMenu, ContextMenuButton, MenuItem } from "./ContextMenu";
import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog";
@@ -122,7 +121,7 @@ export default class UserMenu extends React.Component {
}
};
- private onOpenMenuClick = (ev: InputEvent) => {
+ private onOpenMenuClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
@@ -235,7 +234,7 @@ export default class UserMenu extends React.Component {
return (
{
label={_t("All settings")}
onClick={(e) => this.onSettingsOpen(e, null)}
/>
-
+ /> */}
{
{name}
{buttons}
- {this.renderContextMenu()}
+ {this.renderContextMenu()}
);
}
diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.tsx
similarity index 78%
rename from src/components/views/avatars/BaseAvatar.js
rename to src/components/views/avatars/BaseAvatar.tsx
index 53e8d0072b..aa2c0ea954 100644
--- a/src/components/views/avatars/BaseAvatar.js
+++ b/src/components/views/avatars/BaseAvatar.tsx
@@ -18,7 +18,7 @@ limitations under the License.
*/
import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react';
-import PropTypes from 'prop-types';
+import classNames from 'classnames';
import * as AvatarLogic from '../../../Avatar';
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from '../elements/AccessibleButton';
@@ -26,9 +26,25 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import {toPx} from "../../../utils/units";
-const useImageUrl = ({url, urls}) => {
- const [imageUrls, setUrls] = useState([]);
- const [urlsIndex, setIndex] = useState();
+interface IProps {
+ name: string; // The name (first initial used as default)
+ idName?: string; // ID for generating hash colours
+ title?: string; // onHover title text
+ url?: string; // highest priority of them all, shortcut to set in urls[0]
+ urls?: string[]; // [highest_priority, ... , lowest_priority]
+ width?: number;
+ height?: number;
+ // XXX: resizeMethod not actually used.
+ resizeMethod?: string;
+ defaultToInitialLetter?: boolean; // true to add default url
+ onClick?: React.MouseEventHandler;
+ inputRef?: React.RefObject;
+ className?: string;
+}
+
+const useImageUrl = ({url, urls}): [string, () => void] => {
+ const [imageUrls, setUrls] = useState([]);
+ const [urlsIndex, setIndex] = useState();
const onError = useCallback(() => {
setIndex(i => i + 1); // try the next one
@@ -70,17 +86,17 @@ const useImageUrl = ({url, urls}) => {
return [imageUrl, onError];
};
-const BaseAvatar = (props) => {
+const BaseAvatar = (props: IProps) => {
const {
name,
idName,
title,
url,
urls,
- width=40,
- height=40,
- resizeMethod="crop", // eslint-disable-line no-unused-vars
- defaultToInitialLetter=true,
+ width = 40,
+ height = 40,
+ resizeMethod = "crop", // eslint-disable-line no-unused-vars
+ defaultToInitialLetter = true,
onClick,
inputRef,
...otherProps
@@ -117,7 +133,7 @@ const BaseAvatar = (props) => {
aria-hidden="true" />
);
- if (onClick != null) {
+ if (onClick !== null) {
return (
{
);
} else {
return (
-
+
{ textNode }
{ imgNode }
@@ -140,7 +161,7 @@ const BaseAvatar = (props) => {
}
}
- if (onClick != null) {
+ if (onClick !== null) {
return (
{
}
};
-BaseAvatar.displayName = "BaseAvatar";
-
-BaseAvatar.propTypes = {
- name: PropTypes.string.isRequired, // The name (first initial used as default)
- idName: PropTypes.string, // ID for generating hash colours
- title: PropTypes.string, // onHover title text
- url: PropTypes.string, // highest priority of them all, shortcut to set in urls[0]
- urls: PropTypes.array, // [highest_priority, ... , lowest_priority]
- width: PropTypes.number,
- height: PropTypes.number,
- // XXX resizeMethod not actually used.
- resizeMethod: PropTypes.string,
- defaultToInitialLetter: PropTypes.bool, // true to add default url
- onClick: PropTypes.func,
- inputRef: PropTypes.oneOfType([
- // Either a function
- PropTypes.func,
- // Or the instance of a DOM native element
- PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
- ]),
-};
-
export default BaseAvatar;
+export type BaseAvatarType = React.FC;
\ No newline at end of file
diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx
index e0ad3202b8..40ba15af33 100644
--- a/src/components/views/avatars/DecoratedRoomAvatar.tsx
+++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx
@@ -21,8 +21,8 @@ import { TagID } from '../../../stores/room-list/models';
import RoomAvatar from "./RoomAvatar";
import RoomTileIcon from "../rooms/RoomTileIcon";
import NotificationBadge from '../rooms/NotificationBadge';
-import { INotificationState } from "../../../stores/notifications/INotificationState";
-import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState";
+import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
+import { NotificationState } from "../../../stores/notifications/NotificationState";
interface IProps {
room: Room;
@@ -33,7 +33,7 @@ interface IProps {
}
interface IState {
- notificationState?: INotificationState;
+ notificationState?: NotificationState;
}
export default class DecoratedRoomAvatar extends React.PureComponent {
@@ -42,7 +42,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent {
+ public static defaultProps = {
+ width: 36,
+ height: 36,
+ resizeMethod: 'crop',
+ };
- getDefaultProps: function() {
- return {
- width: 36,
- height: 36,
- resizeMethod: 'crop',
- };
- },
-
- getGroupAvatarUrl: function() {
+ getGroupAvatarUrl() {
return MatrixClientPeg.get().mxcUrlToHttp(
this.props.groupAvatarUrl,
this.props.width,
this.props.height,
this.props.resizeMethod,
);
- },
+ }
- render: function() {
- const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
+ render() {
// extract the props we use from props so we can pass any others through
// should consider adding this as a global rule in js-sdk?
/*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
@@ -65,5 +58,5 @@ export default createReactClass({
{...otherProps}
/>
);
- },
-});
+ }
+}
diff --git a/src/components/views/avatars/MemberAvatar.js b/src/components/views/avatars/MemberAvatar.tsx
similarity index 64%
rename from src/components/views/avatars/MemberAvatar.js
rename to src/components/views/avatars/MemberAvatar.tsx
index b763129dd8..1d23d85b0f 100644
--- a/src/components/views/avatars/MemberAvatar.js
+++ b/src/components/views/avatars/MemberAvatar.tsx
@@ -16,48 +16,50 @@ limitations under the License.
*/
import React from 'react';
-import PropTypes from 'prop-types';
-import createReactClass from 'create-react-class';
-import * as sdk from "../../../index";
import dis from "../../../dispatcher/dispatcher";
import {Action} from "../../../dispatcher/actions";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
+import BaseAvatar from "./BaseAvatar";
-export default createReactClass({
- displayName: 'MemberAvatar',
+interface IProps {
+ // TODO: replace with correct type
+ member: any;
+ fallbackUserId: string;
+ width: number;
+ height: number;
+ resizeMethod: string;
+ // The onClick to give the avatar
+ onClick: React.MouseEventHandler;
+ // Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser`
+ viewUserOnClick: boolean;
+ title: string;
+}
- propTypes: {
- member: PropTypes.object,
- fallbackUserId: PropTypes.string,
- width: PropTypes.number,
- height: PropTypes.number,
- resizeMethod: PropTypes.string,
- // The onClick to give the avatar
- onClick: PropTypes.func,
- // Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser`
- viewUserOnClick: PropTypes.bool,
- title: PropTypes.string,
- },
+interface IState {
+ name: string;
+ title: string;
+ imageUrl?: string;
+}
- getDefaultProps: function() {
- return {
- width: 40,
- height: 40,
- resizeMethod: 'crop',
- viewUserOnClick: false,
- };
- },
+export default class MemberAvatar extends React.Component {
+ public static defaultProps = {
+ width: 40,
+ height: 40,
+ resizeMethod: 'crop',
+ viewUserOnClick: false,
+ };
- getInitialState: function() {
- return this._getState(this.props);
- },
+ constructor(props: IProps) {
+ super(props);
- // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
- UNSAFE_componentWillReceiveProps: function(nextProps) {
- this.setState(this._getState(nextProps));
- },
+ this.state = MemberAvatar.getState(props);
+ }
- _getState: function(props) {
+ public static getDerivedStateFromProps(nextProps: IProps): IState {
+ return MemberAvatar.getState(nextProps);
+ }
+
+ private static getState(props: IProps): IState {
if (props.member && props.member.name) {
return {
name: props.member.name,
@@ -79,11 +81,9 @@ export default createReactClass({
} else {
console.error("MemberAvatar called somehow with null member or fallbackUserId");
}
- },
-
- render: function() {
- const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
+ }
+ render() {
let {member, fallbackUserId, onClick, viewUserOnClick, ...otherProps} = this.props;
const userId = member ? member.userId : fallbackUserId;
@@ -100,5 +100,5 @@ export default createReactClass({
);
- },
-});
+ }
+}
diff --git a/src/stores/notifications/INotificationState.ts b/src/components/views/avatars/PulsedAvatar.tsx
similarity index 67%
rename from src/stores/notifications/INotificationState.ts
rename to src/components/views/avatars/PulsedAvatar.tsx
index 65bd7b7957..94a6c87687 100644
--- a/src/stores/notifications/INotificationState.ts
+++ b/src/components/views/avatars/PulsedAvatar.tsx
@@ -14,13 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { EventEmitter } from "events";
-import { NotificationColor } from "./NotificationColor";
+import React from 'react';
-export const NOTIFICATION_STATE_UPDATE = "update";
-
-export interface INotificationState extends EventEmitter {
- symbol?: string;
- count: number;
- color: NotificationColor;
+interface IProps {
}
+
+const PulsedAvatar: React.FC = (props) => {
+ return
+ {props.children}
+
;
+};
+
+export default PulsedAvatar;
\ No newline at end of file
diff --git a/src/components/views/avatars/RoomAvatar.js b/src/components/views/avatars/RoomAvatar.tsx
similarity index 59%
rename from src/components/views/avatars/RoomAvatar.js
rename to src/components/views/avatars/RoomAvatar.tsx
index a72d318b8d..0947157652 100644
--- a/src/components/views/avatars/RoomAvatar.js
+++ b/src/components/views/avatars/RoomAvatar.tsx
@@ -13,90 +13,96 @@ 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 PropTypes from 'prop-types';
-import createReactClass from 'create-react-class';
-import {MatrixClientPeg} from "../../../MatrixClientPeg";
+import React from 'react';
+import Room from 'matrix-js-sdk/src/models/room';
+import {getHttpUriForMxc} from 'matrix-js-sdk/src/content-repo';
+
+import BaseAvatar from './BaseAvatar';
+import ImageView from '../elements/ImageView';
+import {MatrixClientPeg} from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
-import * as sdk from "../../../index";
import * as Avatar from '../../../Avatar';
-import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
-
-export default createReactClass({
- displayName: 'RoomAvatar',
+interface IProps {
// Room may be left unset here, but if it is,
// oobData.avatarUrl should be set (else there
// would be nowhere to get the avatar from)
- propTypes: {
- room: PropTypes.object,
- oobData: PropTypes.object,
- width: PropTypes.number,
- height: PropTypes.number,
- resizeMethod: PropTypes.string,
- viewAvatarOnClick: PropTypes.bool,
- },
+ room?: Room;
+ // TODO: type when js-sdk has types
+ oobData?: any;
+ width?: number;
+ height?: number;
+ resizeMethod?: string;
+ viewAvatarOnClick?: boolean;
+}
- getDefaultProps: function() {
- return {
- width: 36,
- height: 36,
- resizeMethod: 'crop',
- oobData: {},
+interface IState {
+ urls: string[];
+}
+
+export default class RoomAvatar extends React.Component {
+ public static defaultProps = {
+ width: 36,
+ height: 36,
+ resizeMethod: 'crop',
+ oobData: {},
+ };
+
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ urls: RoomAvatar.getImageUrls(this.props),
};
- },
+ }
- getInitialState: function() {
- return {
- urls: this.getImageUrls(this.props),
- };
- },
-
- componentDidMount: function() {
+ public componentDidMount() {
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
- },
+ }
- componentWillUnmount: function() {
+ public componentWillUnmount() {
const cli = MatrixClientPeg.get();
if (cli) {
cli.removeListener("RoomState.events", this.onRoomStateEvents);
}
- },
+ }
- // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
- UNSAFE_componentWillReceiveProps: function(newProps) {
- this.setState({
- urls: this.getImageUrls(newProps),
- });
- },
+ public static getDerivedStateFromProps(nextProps: IProps): IState {
+ return {
+ urls: RoomAvatar.getImageUrls(nextProps),
+ };
+ }
- onRoomStateEvents: function(ev) {
+ // TODO: type when js-sdk has types
+ private onRoomStateEvents = (ev: any) => {
if (!this.props.room ||
ev.getRoomId() !== this.props.room.roomId ||
ev.getType() !== 'm.room.avatar'
) return;
this.setState({
- urls: this.getImageUrls(this.props),
+ urls: RoomAvatar.getImageUrls(this.props),
});
- },
+ };
- getImageUrls: function(props) {
+ private static getImageUrls(props: IProps): string[] {
return [
getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(),
+ // Default props don't play nicely with getDerivedStateFromProps
+ //props.oobData !== undefined ? props.oobData.avatarUrl : {},
props.oobData.avatarUrl,
Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
), // highest priority
- this.getRoomAvatarUrl(props),
+ RoomAvatar.getRoomAvatarUrl(props),
].filter(function(url) {
- return (url != null && url != "");
+ return (url !== null && url !== "");
});
- },
+ }
- getRoomAvatarUrl: function(props) {
+ private static getRoomAvatarUrl(props: IProps): string {
if (!props.room) return null;
return Avatar.avatarUrlForRoom(
@@ -105,24 +111,21 @@ export default createReactClass({
Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod,
);
- },
+ }
- onRoomAvatarClick: function() {
+ private onRoomAvatarClick = () => {
const avatarUrl = this.props.room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(),
null, null, null, false);
- const ImageView = sdk.getComponent("elements.ImageView");
const params = {
src: avatarUrl,
name: this.props.room.name,
};
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
- },
-
- render: function() {
- const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
+ };
+ public render() {
/*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props;
@@ -132,8 +135,8 @@ export default createReactClass({
+ onClick={this.props.viewAvatarOnClick && !this.state.urls[0] ? this.onRoomAvatarClick : null}
+ />
);
- },
-});
+ }
+}
diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx
index 040147bb16..34481601f7 100644
--- a/src/components/views/elements/AccessibleButton.tsx
+++ b/src/components/views/elements/AccessibleButton.tsx
@@ -64,7 +64,6 @@ export default function AccessibleButton({
className,
...restProps
}: IProps) {
-
const newProps: IAccessibleButtonProps = restProps;
if (!disabled) {
newProps.onClick = onClick;
diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.js
index fc79fc87d0..956b69ca7b 100644
--- a/src/components/views/elements/MemberEventListSummary.js
+++ b/src/components/views/elements/MemberEventListSummary.js
@@ -1,6 +1,6 @@
/*
Copyright 2016 OpenMarket Ltd
-Copyright 2019 The Matrix.org Foundation C.I.C.
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License");
@@ -23,6 +23,7 @@ import { _t } from '../../../languageHandler';
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
import * as sdk from "../../../index";
import {MatrixEvent} from "matrix-js-sdk";
+import {isValid3pidInvite} from "../../../RoomInvite";
export default createReactClass({
displayName: 'MemberEventListSummary',
@@ -284,6 +285,9 @@ export default createReactClass({
_getTransition: function(e) {
if (e.mxEvent.getType() === 'm.room.third_party_invite') {
// Handle 3pid invites the same as invites so they get bundled together
+ if (!isValid3pidInvite(e.mxEvent)) {
+ return 'invite_withdrawal';
+ }
return 'invited';
}
diff --git a/src/components/views/rooms/JumpToBottomButton.js b/src/components/views/rooms/JumpToBottomButton.js
index d3305f498a..b6cefc1231 100644
--- a/src/components/views/rooms/JumpToBottomButton.js
+++ b/src/components/views/rooms/JumpToBottomButton.js
@@ -16,13 +16,18 @@ limitations under the License.
import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton';
+import classNames from 'classnames';
export default (props) => {
+ const className = classNames({
+ 'mx_JumpToBottomButton': true,
+ 'mx_JumpToBottomButton_highlight': props.highlight,
+ });
let badge;
if (props.numUnreadMessages) {
badge = (
{props.numUnreadMessages}
);
}
- return (
+ return (
diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx
index 829b05fbfc..941a057927 100644
--- a/src/components/views/rooms/NotificationBadge.tsx
+++ b/src/components/views/rooms/NotificationBadge.tsx
@@ -22,11 +22,10 @@ import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { readReceiptChangeIsFor } from "../../../utils/read-receipts";
import AccessibleButton from "../elements/AccessibleButton";
import { XOR } from "../../../@types/common";
-import { INotificationState, NOTIFICATION_STATE_UPDATE } from "../../../stores/notifications/INotificationState";
-import { NotificationColor } from "../../../stores/notifications/NotificationColor";
+import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState";
interface IProps {
- notification: INotificationState;
+ notification: NotificationState;
/**
* If true, the badge will show a count if at all possible. This is typically
@@ -97,19 +96,17 @@ export default class NotificationBadge extends React.PureComponent= NotificationColor.Red;
- const hasCount = notification.color >= NotificationColor.Grey;
const hasAnySymbol = notification.symbol || notification.count > 0;
- let isEmptyBadge = !hasAnySymbol || !hasCount;
+ let isEmptyBadge = !hasAnySymbol || !notification.hasUnreadCount;
if (forceCount) {
isEmptyBadge = false;
- if (!hasCount) return null; // Can't render a badge
+ if (!notification.hasUnreadCount) return null; // Can't render a badge
}
let symbol = notification.symbol || formatMinimalBadgeCount(notification.count);
@@ -117,8 +114,8 @@ export default class NotificationBadge extends React.PureComponent 0 && symbol.length < 3,
'mx_NotificationBadge_3char': symbol.length > 2,
diff --git a/src/components/views/rooms/RoomBreadcrumbs2.tsx b/src/components/views/rooms/RoomBreadcrumbs2.tsx
index fce8c6ee3a..7d0584ef66 100644
--- a/src/components/views/rooms/RoomBreadcrumbs2.tsx
+++ b/src/components/views/rooms/RoomBreadcrumbs2.tsx
@@ -16,7 +16,6 @@ limitations under the License.
import React from "react";
import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
-import AccessibleButton from "../elements/AccessibleButton";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { _t } from "../../../languageHandler";
import { Room } from "matrix-js-sdk/src/models/room";
@@ -92,9 +91,6 @@ export default class RoomBreadcrumbs2 extends React.PureComponent {
const roomTags = RoomListStore.instance.getTagsForRoom(r);
const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0];
diff --git a/src/components/views/rooms/RoomList2.tsx b/src/components/views/rooms/RoomList2.tsx
index db246b182d..710af076cd 100644
--- a/src/components/views/rooms/RoomList2.tsx
+++ b/src/components/views/rooms/RoomList2.tsx
@@ -32,15 +32,14 @@ import defaultDispatcher from "../../../dispatcher/dispatcher";
import RoomSublist2 from "./RoomSublist2";
import { ActionPayload } from "../../../dispatcher/payloads";
import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition";
-import { ListLayout } from "../../../stores/room-list/ListLayout";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import GroupAvatar from "../avatars/GroupAvatar";
import TemporaryTile from "./TemporaryTile";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
-import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState";
import { Action } from "../../../dispatcher/actions";
import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
+import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14367
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
@@ -66,7 +65,6 @@ interface IProps {
interface IState {
sublists: ITagMap;
- layouts: Map;
}
const TAG_ORDER: TagID[] = [
@@ -151,7 +149,6 @@ export default class RoomList2 extends React.Component {
this.state = {
sublists: {},
- layouts: new Map(),
};
this.dispatcherRef = defaultDispatcher.register(this.onAction);
@@ -204,14 +201,11 @@ export default class RoomList2 extends React.Component {
let listRooms = lists[t];
if (unread) {
- // TODO Be smarter and not spin up a bunch of wasted listeners just to kill them 4 lines later
- // https://github.com/vector-im/riot-web/issues/14035
- const notificationStates = rooms.map(r => new TagSpecificNotificationState(r, t));
// filter to only notification rooms (and our current active room so we can index properly)
- listRooms = notificationStates.filter(state => {
- return state.room.roomId === roomId || state.color >= NotificationColor.Bold;
+ listRooms = listRooms.filter(r => {
+ const state = RoomNotificationStateStore.instance.getRoomState(r, t);
+ return state.room.roomId === roomId || state.isUnread;
});
- notificationStates.forEach(state => state.destroy());
}
rooms.push(...listRooms);
@@ -227,12 +221,7 @@ export default class RoomList2 extends React.Component {
const newLists = RoomListStore.instance.orderedLists;
console.log("new lists", newLists);
- const layoutMap = new Map();
- for (const tagId of Object.keys(newLists)) {
- layoutMap.set(tagId, new ListLayout(tagId));
- }
-
- this.setState({sublists: newLists, layouts: layoutMap}, () => {
+ this.setState({sublists: newLists}, () => {
this.props.onResize();
});
};
@@ -301,11 +290,10 @@ export default class RoomList2 extends React.Component {
label={_t(aesthetics.sectionLabel)}
onAddRoom={onAddRoomFn}
addRoomLabel={aesthetics.addRoomLabel}
- isInvite={aesthetics.isInvite}
- layout={this.state.layouts.get(orderedTagId)}
isMinimized={this.props.isMinimized}
onResize={this.props.onResize}
extraBadTilesThatShouldntExist={extraTiles}
+ isFiltered={!!this.searchFilter.search}
/>
);
}
diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx
index 60d4e307a1..6d55c41c44 100644
--- a/src/components/views/rooms/RoomSublist2.tsx
+++ b/src/components/views/rooms/RoomSublist2.tsx
@@ -24,9 +24,9 @@ import {RovingAccessibleButton, RovingTabIndexWrapper} from "../../../accessibil
import { _t } from "../../../languageHandler";
import AccessibleButton from "../../views/elements/AccessibleButton";
import RoomTile2 from "./RoomTile2";
-import { ResizableBox, ResizeCallbackData } from "react-resizable";
import { ListLayout } from "../../../stores/room-list/ListLayout";
import {
+ ChevronFace,
ContextMenu,
ContextMenuButton,
StyledMenuItemCheckbox,
@@ -40,7 +40,13 @@ import NotificationBadge from "./NotificationBadge";
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { Key } from "../../../Keyboard";
-import StyledCheckbox from "../elements/StyledCheckbox";
+import defaultDispatcher from "../../../dispatcher/dispatcher";
+import {ActionPayload} from "../../../dispatcher/payloads";
+import { Enable, Resizable } from "re-resizable";
+import { Direction } from "re-resizable/lib/resizer";
+import { polyfillTouchEvent } from "../../../@types/polyfill";
+import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
+import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore";
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14367
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
@@ -55,9 +61,13 @@ import StyledCheckbox from "../elements/StyledCheckbox";
const SHOW_N_BUTTON_HEIGHT = 32; // As defined by CSS
const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS
+export const HEADER_HEIGHT = 32; // As defined by CSS
const MAX_PADDING_HEIGHT = SHOW_N_BUTTON_HEIGHT + RESIZE_HANDLE_HEIGHT;
+// HACK: We really shouldn't have to do this.
+polyfillTouchEvent();
+
interface IProps {
forRooms: boolean;
rooms?: Room[];
@@ -65,11 +75,10 @@ interface IProps {
label: string;
onAddRoom?: () => void;
addRoomLabel: string;
- isInvite: boolean;
- layout?: ListLayout;
isMinimized: boolean;
tagId: TagID;
onResize: () => void;
+ isFiltered: boolean;
// TODO: Don't use this. It's for community invites, and community invites shouldn't be here.
// You should feel bad if you use this.
@@ -84,21 +93,28 @@ interface IState {
notificationState: ListNotificationState;
contextMenuPosition: PartialDOMRect;
isResizing: boolean;
+ isExpanded: boolean; // used for the for expand of the sublist when the room list is being filtered
}
export default class RoomSublist2 extends React.Component {
private headerButton = createRef();
private sublistRef = createRef();
+ private dispatcherRef: string;
+ private layout: ListLayout;
constructor(props: IProps) {
super(props);
+ this.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId);
+
this.state = {
- notificationState: new ListNotificationState(this.props.isInvite, this.props.tagId),
+ notificationState: RoomNotificationStateStore.instance.getListState(this.props.tagId),
contextMenuPosition: null,
isResizing: false,
+ isExpanded: this.props.isFiltered ? this.props.isFiltered : !this.layout.isCollapsed
};
this.state.notificationState.setRooms(this.props.rooms);
+ this.dispatcherRef = defaultDispatcher.register(this.onAction);
}
private get numTiles(): number {
@@ -106,28 +122,69 @@ export default class RoomSublist2 extends React.Component {
}
private get numVisibleTiles(): number {
- if (!this.props.layout) return 0;
- const nVisible = Math.floor(this.props.layout.visibleTiles);
+ const nVisible = Math.floor(this.layout.visibleTiles);
return Math.min(nVisible, this.numTiles);
}
- public componentDidUpdate() {
+ public componentDidUpdate(prevProps: Readonly) {
this.state.notificationState.setRooms(this.props.rooms);
+ if (prevProps.isFiltered !== this.props.isFiltered) {
+ if (this.props.isFiltered) {
+ this.setState({isExpanded: true});
+ } else {
+ this.setState({isExpanded: !this.layout.isCollapsed});
+ }
+ }
}
public componentWillUnmount() {
this.state.notificationState.destroy();
+ defaultDispatcher.unregister(this.dispatcherRef);
}
+ private onAction = (payload: ActionPayload) => {
+ if (payload.action === "view_room" && payload.show_room_tile && this.props.rooms) {
+ // XXX: we have to do this a tick later because we have incorrect intermediate props during a room change
+ // where we lose the room we are changing from temporarily and then it comes back in an update right after.
+ setImmediate(() => {
+ const roomIndex = this.props.rooms.findIndex((r) => r.roomId === payload.room_id);
+
+ if (!this.state.isExpanded && roomIndex > -1) {
+ this.toggleCollapsed();
+ }
+ // extend the visible section to include the room if it is entirely invisible
+ if (roomIndex >= this.numVisibleTiles) {
+ this.layout.visibleTiles = this.layout.tilesWithPadding(roomIndex + 1, MAX_PADDING_HEIGHT);
+ this.forceUpdate(); // because the layout doesn't trigger a re-render
+ }
+ });
+ }
+ };
+
private onAddRoom = (e) => {
e.stopPropagation();
if (this.props.onAddRoom) this.props.onAddRoom();
};
- private onResize = (e: React.MouseEvent, data: ResizeCallbackData) => {
- const direction = e.movementY < 0 ? -1 : +1;
- const tileDiff = this.props.layout.pixelsToTiles(Math.abs(e.movementY)) * direction;
- this.props.layout.setVisibleTilesWithin(tileDiff, this.numTiles);
+ private onResize = (
+ e: MouseEvent | TouchEvent,
+ travelDirection: Direction,
+ refToElement: HTMLDivElement,
+ delta: { width: number, height: number }, // TODO: Use re-resizer's NumberSize when it is exposed as the type
+ ) => {
+ // Do some sanity checks, but in reality we shouldn't need these.
+ if (travelDirection !== "bottom") return;
+ if (delta.height === 0) return; // something went wrong, so just ignore it.
+
+ // NOTE: the movement in the MouseEvent (not present on a TouchEvent) is inaccurate
+ // for our purposes. The delta provided by the library is also a change *from when
+ // resizing started*, meaning it is fairly useless for us. This is why we just use
+ // the client height and run with it.
+
+ const heightBefore = this.layout.visibleTiles;
+ const heightInTiles = this.layout.pixelsToTiles(refToElement.clientHeight);
+ this.layout.setVisibleTilesWithin(heightInTiles, this.numTiles);
+ if (heightBefore === this.layout.visibleTiles) return; // no-op
this.forceUpdate(); // because the layout doesn't trigger a re-render
};
@@ -141,13 +198,13 @@ export default class RoomSublist2 extends React.Component {
private onShowAllClick = () => {
const numVisibleTiles = this.numVisibleTiles;
- this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT);
+ this.layout.visibleTiles = this.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT);
this.forceUpdate(); // because the layout doesn't trigger a re-render
setImmediate(this.focusRoomTile, numVisibleTiles); // focus the tile after the current bottom one
};
private onShowLessClick = () => {
- this.props.layout.visibleTiles = this.props.layout.defaultVisibleTiles;
+ this.layout.visibleTiles = this.layout.defaultVisibleTiles;
this.forceUpdate(); // because the layout doesn't trigger a re-render
// focus will flow to the show more button here
};
@@ -161,7 +218,7 @@ export default class RoomSublist2 extends React.Component {
}
};
- private onOpenMenuClick = (ev: InputEvent) => {
+ private onOpenMenuClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
@@ -195,7 +252,7 @@ export default class RoomSublist2 extends React.Component {
};
private onMessagePreviewChanged = () => {
- this.props.layout.showPreviews = !this.props.layout.showPreviews;
+ this.layout.showPreviews = !this.layout.showPreviews;
this.forceUpdate(); // because the layout doesn't trigger a re-render
};
@@ -233,7 +290,11 @@ export default class RoomSublist2 extends React.Component {
const possibleSticky = target.parentElement;
const sublist = possibleSticky.parentElement.parentElement;
- if (possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky')) {
+ const list = sublist.parentElement.parentElement;
+ // the scrollTop is capped at the height of the header in LeftPanel2
+ const isAtTop = list.scrollTop <= HEADER_HEIGHT;
+ const isSticky = possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky');
+ if (isSticky && !isAtTop) {
// is sticky - jump to list
sublist.scrollIntoView({behavior: 'smooth'});
} else {
@@ -243,24 +304,23 @@ export default class RoomSublist2 extends React.Component {
};
private toggleCollapsed = () => {
- this.props.layout.isCollapsed = !this.props.layout.isCollapsed;
- this.forceUpdate(); // because the layout doesn't trigger an update
+ this.layout.isCollapsed = this.state.isExpanded;
+ this.setState({isExpanded: !this.layout.isCollapsed});
setImmediate(() => this.props.onResize()); // needs to happen when the DOM is updated
};
private onHeaderKeyDown = (ev: React.KeyboardEvent) => {
- const isCollapsed = this.props.layout && this.props.layout.isCollapsed;
switch (ev.key) {
case Key.ARROW_LEFT:
ev.stopPropagation();
- if (!isCollapsed) {
+ if (this.state.isExpanded) {
// On ARROW_LEFT collapse the room sublist if it isn't already
this.toggleCollapsed();
}
break;
case Key.ARROW_RIGHT: {
ev.stopPropagation();
- if (isCollapsed) {
+ if (!this.state.isExpanded) {
// On ARROW_RIGHT expand the room sublist if it isn't already
this.toggleCollapsed();
} else if (this.sublistRef.current) {
@@ -289,7 +349,7 @@ export default class RoomSublist2 extends React.Component {
};
private renderVisibleTiles(): React.ReactElement[] {
- if (this.props.layout && this.props.layout.isCollapsed) {
+ if (!this.state.isExpanded) {
// don't waste time on rendering
return [];
}
@@ -303,7 +363,7 @@ export default class RoomSublist2 extends React.Component {
@@ -354,7 +414,7 @@ export default class RoomSublist2 extends React.Component {
{_t("Message preview")}
@@ -365,7 +425,7 @@ export default class RoomSublist2 extends React.Component {
contextMenu = (
{
const collapseClasses = classNames({
'mx_RoomSublist2_collapseBtn': true,
- 'mx_RoomSublist2_collapseBtn_collapsed': this.props.layout && this.props.layout.isCollapsed,
+ 'mx_RoomSublist2_collapseBtn_collapsed': !this.state.isExpanded,
});
const classes = classNames({
@@ -474,7 +534,7 @@ export default class RoomSublist2 extends React.Component {
tabIndex={tabIndex}
className="mx_RoomSublist2_headerText"
role="treeitem"
- aria-expanded={!this.props.layout || !this.props.layout.isCollapsed}
+ aria-expanded={this.state.isExpanded}
aria-level={1}
onClick={this.onHeaderClick}
onContextMenu={this.onContextMenu}
@@ -508,12 +568,10 @@ export default class RoomSublist2 extends React.Component {
let content = null;
if (visibleTiles.length > 0) {
- const layout = this.props.layout; // to shorten calls
+ const layout = this.layout; // to shorten calls
- const maxTilesFactored = layout.tilesWithResizerBoxFactor(this.numTiles);
const showMoreBtnClasses = classNames({
'mx_RoomSublist2_showNButton': true,
- 'mx_RoomSublist2_isCutting': this.state.isResizing && layout.visibleTiles < maxTilesFactored,
});
// If we're hiding rooms, show a 'show more' button to the user. This button
@@ -537,7 +595,7 @@ export default class RoomSublist2 extends React.Component {
{showMoreText}
);
- } else if (this.numTiles <= visibleTiles.length && this.numTiles > this.props.layout.defaultVisibleTiles) {
+ } else if (this.numTiles <= visibleTiles.length && this.numTiles > this.layout.defaultVisibleTiles) {
// we have all tiles visible - add a button to show less
let showLessText = (
@@ -556,9 +614,19 @@ export default class RoomSublist2 extends React.Component {
}
// Figure out if we need a handle
- let handles = ['s'];
+ const handles: Enable = {
+ bottom: true, // the only one we need, but the others must be explicitly false
+ bottomLeft: false,
+ bottomRight: false,
+ left: false,
+ right: false,
+ top: false,
+ topLeft: false,
+ topRight: false,
+ };
if (layout.visibleTiles >= this.numTiles && this.numTiles <= layout.minVisibleTiles) {
- handles = []; // no handles, we're at a minimum
+ // we're at a minimum, don't have a bottom handle
+ handles.bottom = false;
}
// We have to account for padding so we can accommodate a 'show more' button and
@@ -582,22 +650,33 @@ export default class RoomSublist2 extends React.Component {
const tilesWithoutPadding = Math.min(relativeTiles, layout.visibleTiles);
const tilesPx = layout.calculateTilesToPixelsMin(relativeTiles, tilesWithoutPadding, padding);
+ // Now that we know our padding constraints, let's find out if we need to chop off the
+ // last rendered visible tile so it doesn't collide with the 'show more' button
+ let visibleUnpaddedTiles = Math.round(layout.visibleTiles - layout.pixelsToTiles(padding));
+ if (visibleUnpaddedTiles === visibleTiles.length - 1) {
+ const placeholder = ;
+ visibleTiles.splice(visibleUnpaddedTiles, 1, placeholder);
+ }
+
+ const dimensions = {
+ height: tilesPx,
+ };
content = (
-
{visibleTiles}
{showNButton}
-
+
);
}
diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx
index 3b15d7b27a..54395fa2b7 100644
--- a/src/components/views/rooms/RoomTile2.tsx
+++ b/src/components/views/rooms/RoomTile2.tsx
@@ -17,7 +17,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import React from "react";
+import React, {createRef} from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
@@ -27,6 +27,7 @@ import { Key } from "../../../Keyboard";
import ActiveRoomObserver from "../../../ActiveRoomObserver";
import { _t } from "../../../languageHandler";
import {
+ ChevronFace,
ContextMenu,
ContextMenuButton,
MenuItemRadio,
@@ -45,11 +46,14 @@ import {
MUTE,
} from "../../../RoomNotifs";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
-import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState";
-import { INotificationState } from "../../../stores/notifications/INotificationState";
import NotificationBadge from "./NotificationBadge";
-import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { Volume } from "../../../RoomNotifsTypes";
+import RoomListStore from "../../../stores/room-list/RoomListStore2";
+import RoomListActions from "../../../actions/RoomListActions";
+import defaultDispatcher from "../../../dispatcher/dispatcher";
+import {ActionPayload} from "../../../dispatcher/payloads";
+import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
+import { NotificationState } from "../../../stores/notifications/NotificationState";
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14367
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
@@ -75,7 +79,7 @@ type PartialDOMRect = Pick;
interface IState {
hover: boolean;
- notificationState: INotificationState;
+ notificationState: NotificationState;
selected: boolean;
notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: PartialDOMRect;
@@ -87,7 +91,7 @@ const contextMenuBelow = (elementRect: PartialDOMRect) => {
// align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.pageXOffset - 9;
const top = elementRect.bottom + window.pageYOffset + 17;
- const chevronFace = "none";
+ const chevronFace = ChevronFace.None;
return {left, top, chevronFace};
};
@@ -118,18 +122,23 @@ const NotifOption: React.FC = ({active, onClick, iconClassNam
};
export default class RoomTile2 extends React.Component {
+ private dispatcherRef: string;
+ private roomTileRef = createRef();
+ // TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
+
constructor(props: IProps) {
super(props);
this.state = {
hover: false,
- notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag),
+ notificationState: RoomNotificationStateStore.instance.getRoomState(this.props.room, this.props.tag),
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
notificationsMenuPosition: null,
generalMenuPosition: null,
};
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
+ this.dispatcherRef = defaultDispatcher.register(this.onAction);
}
private get showContextMenu(): boolean {
@@ -140,12 +149,36 @@ export default class RoomTile2 extends React.Component {
return !this.props.isMinimized && this.props.showMessagePreview;
}
+ public componentDidMount() {
+ // when we're first rendered (or our sublist is expanded) make sure we are visible if we're active
+ if (this.state.selected) {
+ this.scrollIntoView();
+ }
+ }
+
public componentWillUnmount() {
if (this.props.room) {
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
}
+ defaultDispatcher.unregister(this.dispatcherRef);
}
+ private onAction = (payload: ActionPayload) => {
+ if (payload.action === "view_room" && payload.room_id === this.props.room.roomId && payload.show_room_tile) {
+ setImmediate(() => {
+ this.scrollIntoView();
+ });
+ }
+ };
+
+ private scrollIntoView = () => {
+ if (!this.roomTileRef.current) return;
+ this.roomTileRef.current.scrollIntoView({
+ block: "nearest",
+ behavior: "auto",
+ });
+ };
+
private onTileMouseEnter = () => {
this.setState({hover: true});
};
@@ -159,7 +192,6 @@ export default class RoomTile2 extends React.Component {
ev.stopPropagation();
dis.dispatch({
action: 'view_room',
- // TODO: Support show_room_tile in new room list: https://github.com/vector-im/riot-web/issues/14233
show_room_tile: true, // make sure the room is visible in the list
room_id: this.props.room.roomId,
clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)),
@@ -170,7 +202,7 @@ export default class RoomTile2 extends React.Component {
this.setState({selected: isActive});
};
- private onNotificationsMenuOpenClick = (ev: InputEvent) => {
+ private onNotificationsMenuOpenClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
@@ -181,7 +213,7 @@ export default class RoomTile2 extends React.Component {
this.setState({notificationsMenuPosition: null});
};
- private onGeneralMenuOpenClick = (ev: InputEvent) => {
+ private onGeneralMenuOpenClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
@@ -210,8 +242,22 @@ export default class RoomTile2 extends React.Component {
ev.preventDefault();
ev.stopPropagation();
- // TODO: Support tagging: https://github.com/vector-im/riot-web/issues/14211
- // TODO: XOR favourites and low priority: https://github.com/vector-im/riot-web/issues/14210
+ if (tagId === DefaultTagID.Favourite) {
+ const roomTags = RoomListStore.instance.getTagsForRoom(this.props.room);
+ const isFavourite = roomTags.includes(DefaultTagID.Favourite);
+ const removeTag = isFavourite ? DefaultTagID.Favourite : DefaultTagID.LowPriority;
+ const addTag = isFavourite ? null : DefaultTagID.Favourite;
+ dis.dispatch(RoomListActions.tagRoom(
+ MatrixClientPeg.get(),
+ this.props.room,
+ removeTag,
+ addTag,
+ undefined,
+ 0
+ ));
+ } else {
+ console.log(`Unexpected tag ${tagId} applied to ${this.props.room.room_id}`);
+ }
if ((ev as React.KeyboardEvent).key === Key.ENTER) {
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
@@ -343,6 +389,13 @@ export default class RoomTile2 extends React.Component {
// TODO: We could do with a proper invite context menu, unlike what showContextMenu suggests
+ const roomTags = RoomListStore.instance.getTagsForRoom(this.props.room);
+
+ const isFavorite = roomTags.includes(DefaultTagID.Favourite);
+ const favouriteIconClassName = isFavorite ? "mx_RoomTile2_iconFavorite" : "mx_RoomTile2_iconStar";
+ const favouriteLabelClassName = isFavorite ? "mx_RoomTile2_contextMenu_activeRow" : "";
+ const favouriteLabel = isFavorite ? _t("Favourited") : _t("Favourite");
+
let contextMenu = null;
if (this.state.generalMenuPosition) {
contextMenu = (
@@ -350,12 +403,13 @@ export default class RoomTile2 extends React.Component {
);
diff --git a/src/components/views/voip/CallContainer.tsx b/src/components/views/voip/CallContainer.tsx
new file mode 100644
index 0000000000..0e901fac7d
--- /dev/null
+++ b/src/components/views/voip/CallContainer.tsx
@@ -0,0 +1,37 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+import IncomingCallBox2 from './IncomingCallBox2';
+import CallPreview from './CallPreview2';
+import * as VectorConferenceHandler from '../../../VectorConferenceHandler';
+
+interface IProps {
+
+}
+
+interface IState {
+
+}
+
+export default class CallContainer extends React.PureComponent {
+ public render() {
+ return
+
+
+
;
+ }
+}
\ No newline at end of file
diff --git a/src/components/views/voip/CallPreview2.tsx b/src/components/views/voip/CallPreview2.tsx
new file mode 100644
index 0000000000..1f2caf5ef8
--- /dev/null
+++ b/src/components/views/voip/CallPreview2.tsx
@@ -0,0 +1,129 @@
+/*
+Copyright 2017, 2018 New Vector Ltd
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+
+import React from 'react';
+
+import CallView from "./CallView2";
+import RoomViewStore from '../../../stores/RoomViewStore';
+import CallHandler from '../../../CallHandler';
+import dis from '../../../dispatcher/dispatcher';
+import { ActionPayload } from '../../../dispatcher/payloads';
+import PersistentApp from "../elements/PersistentApp";
+import SettingsStore from "../../../settings/SettingsStore";
+
+interface IProps {
+ // A Conference Handler implementation
+ // Must have a function signature:
+ // getConferenceCallForRoom(roomId: string): MatrixCall
+ ConferenceHandler: any;
+}
+
+interface IState {
+ roomId: string;
+ activeCall: any;
+ newRoomListActive: boolean;
+}
+
+export default class CallPreview extends React.Component {
+ private roomStoreToken: any;
+ private dispatcherRef: string;
+ private settingsWatcherRef: string;
+
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ roomId: RoomViewStore.getRoomId(),
+ activeCall: CallHandler.getAnyActiveCall(),
+ newRoomListActive: SettingsStore.getValue("feature_new_room_list"),
+ };
+
+ this.settingsWatcherRef = SettingsStore.watchSetting("feature_new_room_list", null, (name, roomId, level, valAtLevel, newVal) => this.setState({
+ newRoomListActive: newVal,
+ }));
+ }
+
+ public componentDidMount() {
+ this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
+ this.dispatcherRef = dis.register(this.onAction);
+ }
+
+ public componentWillUnmount() {
+ if (this.roomStoreToken) {
+ this.roomStoreToken.remove();
+ }
+ dis.unregister(this.dispatcherRef);
+ SettingsStore.unwatchSetting(this.settingsWatcherRef);
+ }
+
+ private onRoomViewStoreUpdate = (payload) => {
+ if (RoomViewStore.getRoomId() === this.state.roomId) return;
+ this.setState({
+ roomId: RoomViewStore.getRoomId(),
+ });
+ };
+
+ private onAction = (payload: ActionPayload) => {
+ switch (payload.action) {
+ // listen for call state changes to prod the render method, which
+ // may hide the global CallView if the call it is tracking is dead
+ case 'call_state':
+ this.setState({
+ activeCall: CallHandler.getAnyActiveCall(),
+ });
+ break;
+ }
+ };
+
+ private onCallViewClick = () => {
+ const call = CallHandler.getAnyActiveCall();
+ if (call) {
+ dis.dispatch({
+ action: 'view_room',
+ room_id: call.groupRoomId || call.roomId,
+ });
+ }
+ };
+
+ public render() {
+ if (this.state.newRoomListActive) {
+ const callForRoom = CallHandler.getCallForRoom(this.state.roomId);
+ const showCall = (
+ this.state.activeCall &&
+ this.state.activeCall.call_state === 'connected' &&
+ !callForRoom
+ );
+
+ if (showCall) {
+ return (
+
+ );
+ }
+
+ return ;
+ }
+
+ return null;
+ }
+}
+
diff --git a/src/components/views/voip/CallView2.tsx b/src/components/views/voip/CallView2.tsx
new file mode 100644
index 0000000000..c80d82d395
--- /dev/null
+++ b/src/components/views/voip/CallView2.tsx
@@ -0,0 +1,200 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+
+import React, {createRef} from 'react';
+import Room from 'matrix-js-sdk/src/models/room';
+import dis from '../../../dispatcher/dispatcher';
+import CallHandler from '../../../CallHandler';
+import {MatrixClientPeg} from '../../../MatrixClientPeg';
+import { _t } from '../../../languageHandler';
+import AccessibleButton from '../elements/AccessibleButton';
+import VideoView from "./VideoView";
+import RoomAvatar from "../avatars/RoomAvatar";
+import PulsedAvatar from '../avatars/PulsedAvatar';
+
+interface IProps {
+ // js-sdk room object. If set, we will only show calls for the given
+ // room; if not, we will show any active call.
+ room?: Room;
+
+ // A Conference Handler implementation
+ // Must have a function signature:
+ // getConferenceCallForRoom(roomId: string): MatrixCall
+ ConferenceHandler?: any;
+
+ // maxHeight style attribute for the video panel
+ maxVideoHeight?: number;
+
+ // a callback which is called when the user clicks on the video div
+ onClick?: React.MouseEventHandler;
+
+ // a callback which is called when the content in the callview changes
+ // in a way that is likely to cause a resize.
+ onResize?: any;
+
+ // classname applied to view,
+ className?: string;
+
+ // Whether to show the hang up icon:W
+ showHangup?: boolean;
+}
+
+interface IState {
+ call: any;
+}
+
+export default class CallView extends React.Component {
+ private videoref: React.RefObject;
+ private dispatcherRef: string;
+ public call: any;
+
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ // the call this view is displaying (if any)
+ call: null,
+ };
+
+ this.videoref = createRef();
+ }
+
+ public componentDidMount() {
+ this.dispatcherRef = dis.register(this.onAction);
+ this.showCall();
+ }
+
+ public componentWillUnmount() {
+ dis.unregister(this.dispatcherRef);
+ }
+
+ private onAction = (payload) => {
+ // don't filter out payloads for room IDs other than props.room because
+ // we may be interested in the conf 1:1 room
+ if (payload.action !== 'call_state') {
+ return;
+ }
+ this.showCall();
+ };
+
+ private showCall() {
+ let call;
+
+ if (this.props.room) {
+ const roomId = this.props.room.roomId;
+ call = CallHandler.getCallForRoom(roomId) ||
+ (this.props.ConferenceHandler ?
+ this.props.ConferenceHandler.getConferenceCallForRoom(roomId) :
+ null
+ );
+
+ if (this.call) {
+ this.setState({ call: call });
+ }
+ } else {
+ call = CallHandler.getAnyActiveCall();
+ // Ignore calls if we can't get the room associated with them.
+ // I think the underlying problem is that the js-sdk sends events
+ // for calls before it has made the rooms available in the store,
+ // although this isn't confirmed.
+ if (MatrixClientPeg.get().getRoom(call.roomId) === null) {
+ call = null;
+ }
+ this.setState({ call: call });
+ }
+
+ if (call) {
+ call.setLocalVideoElement(this.getVideoView().getLocalVideoElement());
+ call.setRemoteVideoElement(this.getVideoView().getRemoteVideoElement());
+ // always use a separate element for audio stream playback.
+ // this is to let us move CallView around the DOM without interrupting remote audio
+ // during playback, by having the audio rendered by a top-level element.
+ // rather than being rendered by the main remoteVideo element.
+ call.setRemoteAudioElement(this.getVideoView().getRemoteAudioElement());
+ }
+ if (call && call.type === "video" && call.call_state !== "ended" && call.call_state !== "ringing") {
+ // if this call is a conf call, don't display local video as the
+ // conference will have us in it
+ this.getVideoView().getLocalVideoElement().style.display = (
+ call.confUserId ? "none" : "block"
+ );
+ this.getVideoView().getRemoteVideoElement().style.display = "block";
+ } else {
+ this.getVideoView().getLocalVideoElement().style.display = "none";
+ this.getVideoView().getRemoteVideoElement().style.display = "none";
+ dis.dispatch({action: 'video_fullscreen', fullscreen: false});
+ }
+
+ if (this.props.onResize) {
+ this.props.onResize();
+ }
+ }
+
+ private getVideoView() {
+ return this.videoref.current;
+ }
+
+ public render() {
+ let view: React.ReactNode;
+ if (this.state.call && this.state.call.type === "voice") {
+ const client = MatrixClientPeg.get();
+ const callRoom = client.getRoom(this.state.call.roomId);
+
+ view =
+
+
+
+
;
+ }
+}
+
diff --git a/src/components/views/voip/IncomingCallBox2.tsx b/src/components/views/voip/IncomingCallBox2.tsx
new file mode 100644
index 0000000000..6dfcb4bcee
--- /dev/null
+++ b/src/components/views/voip/IncomingCallBox2.tsx
@@ -0,0 +1,141 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 New Vector Ltd
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+
+import React from 'react';
+import {MatrixClientPeg} from '../../../MatrixClientPeg';
+import dis from '../../../dispatcher/dispatcher';
+import { _t } from '../../../languageHandler';
+import { ActionPayload } from '../../../dispatcher/payloads';
+import CallHandler from '../../../CallHandler';
+import PulsedAvatar from '../avatars/PulsedAvatar';
+import RoomAvatar from '../avatars/RoomAvatar';
+import FormButton from '../elements/FormButton';
+
+interface IProps {
+}
+
+interface IState {
+ incomingCall: any;
+}
+
+export default class IncomingCallBox2 extends React.Component {
+ private dispatcherRef: string;
+
+ constructor(props: IProps) {
+ super(props);
+
+ this.dispatcherRef = dis.register(this.onAction);
+ this.state = {
+ incomingCall: null,
+ };
+ }
+
+ public componentWillUnmount() {
+ dis.unregister(this.dispatcherRef);
+ }
+
+ private onAction = (payload: ActionPayload) => {
+ switch (payload.action) {
+ case 'call_state':
+ const call = CallHandler.getCall(payload.room_id);
+ if (call && call.call_state === 'ringing') {
+ this.setState({
+ incomingCall: call,
+ });
+ } else {
+ this.setState({
+ incomingCall: null,
+ });
+ }
+ }
+ };
+
+ private onAnswerClick: React.MouseEventHandler = (e) => {
+ e.stopPropagation();
+ dis.dispatch({
+ action: 'answer',
+ room_id: this.state.incomingCall.roomId,
+ });
+ };
+
+ private onRejectClick: React.MouseEventHandler = (e) => {
+ e.stopPropagation();
+ dis.dispatch({
+ action: 'hangup',
+ room_id: this.state.incomingCall.roomId,
+ });
+ };
+
+ public render() {
+ if (!this.state.incomingCall) {
+ return null;
+ }
+
+ let room = null;
+ if (this.state.incomingCall) {
+ room = MatrixClientPeg.get().getRoom(this.state.incomingCall.roomId);
+ }
+
+ const caller = room ? room.name : _t("Unknown caller");
+
+ let incomingCallText = null;
+ if (this.state.incomingCall) {
+ if (this.state.incomingCall.type === "voice") {
+ incomingCallText = _t("Incoming voice call");
+ } else if (this.state.incomingCall.type === "video") {
+ incomingCallText = _t("Incoming video call");
+ } else {
+ incomingCallText = _t("Incoming call");
+ }
+ }
+
+ return
+
+
+
+
+
+
{caller}
+
{incomingCallText}
+
+
+
+
+
+
+
+
;
+ }
+}
+
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index f16fb38f86..fb97bfa26c 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -489,7 +489,6 @@
"Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)",
"Use the improved room list (will refresh to apply changes)": "Use the improved room list (will refresh to apply changes)",
"Support adding custom themes": "Support adding custom themes",
- "Enable IRC layout option in the appearance tab": "Enable IRC layout option in the appearance tab",
"Show info about bridges in room settings": "Show info about bridges in room settings",
"Font size": "Font size",
"Use custom size": "Use custom size",
@@ -539,7 +538,7 @@
"How fast should messages be downloaded.": "How fast should messages be downloaded.",
"Manually verify all remote sessions": "Manually verify all remote sessions",
"IRC display name width": "IRC display name width",
- "Use IRC layout": "Use IRC layout",
+ "Enable experimental, compact IRC style layout": "Enable experimental, compact IRC style layout",
"Collecting app version information": "Collecting app version information",
"Collecting logs": "Collecting logs",
"Uploading report": "Uploading report",
@@ -558,12 +557,17 @@
"My Ban List": "My Ban List",
"This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!",
"Active call (%(roomName)s)": "Active call (%(roomName)s)",
+ "Active call": "Active call",
"unknown caller": "unknown caller",
"Incoming voice call from %(name)s": "Incoming voice call from %(name)s",
"Incoming video call from %(name)s": "Incoming video call from %(name)s",
"Incoming call from %(name)s": "Incoming call from %(name)s",
"Decline": "Decline",
"Accept": "Accept",
+ "Unknown caller": "Unknown caller",
+ "Incoming voice call": "Incoming voice call",
+ "Incoming video call": "Incoming video call",
+ "Incoming call": "Incoming call",
"The other party cancelled the verification.": "The other party cancelled the verification.",
"Verified!": "Verified!",
"You've successfully verified this user.": "You've successfully verified this user.",
@@ -966,6 +970,8 @@
"Room version:": "Room version:",
"Developer options": "Developer options",
"Open Devtools": "Open Devtools",
+ "Make this room low priority": "Make this room low priority",
+ "Low priority rooms show up at the bottom of your room list in a dedicated section at the bottom of your room list": "Low priority rooms show up at the bottom of your room list in a dedicated section at the bottom of your room list",
"This room is bridging messages to the following platforms. Learn more.": "This room is bridging messages to the following platforms. Learn more.",
"This room isn’t bridging messages to any platforms. Learn more.": "This room isn’t bridging messages to any platforms. Learn more.",
"Bridges": "Bridges",
@@ -1224,6 +1230,7 @@
"All messages": "All messages",
"Mentions & Keywords": "Mentions & Keywords",
"Notification options": "Notification options",
+ "Favourited": "Favourited",
"Favourite": "Favourite",
"Leave Room": "Leave Room",
"Room options": "Room options",
@@ -2102,7 +2109,6 @@
"%(count)s Resend all or cancel all now. You can also select individual messages to resend or cancel.|one": "Resend message or cancel message now.",
"Connectivity to the server has been lost.": "Connectivity to the server has been lost.",
"Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.",
- "Active call": "Active call",
"There's no one else here! Would you like to invite others or stop warning about the empty room?": "There's no one else here! Would you like to invite others or stop warning about the empty room?",
"You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?",
"You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?",
@@ -2130,7 +2136,6 @@
"Switch theme": "Switch theme",
"Security & privacy": "Security & privacy",
"All settings": "All settings",
- "Archived rooms": "Archived rooms",
"Feedback": "Feedback",
"User menu": "User menu",
"Could not load user profile": "Could not load user profile",
diff --git a/src/settings/Settings.js b/src/settings/Settings.js
index ad380509f0..49a7140acf 100644
--- a/src/settings/Settings.js
+++ b/src/settings/Settings.js
@@ -160,12 +160,6 @@ export const SETTINGS = {
supportedLevels: LEVELS_FEATURE,
default: false,
},
- "feature_irc_ui": {
- supportedLevels: LEVELS_ACCOUNT_SETTINGS,
- displayName: _td('Enable IRC layout option in the appearance tab'),
- default: false,
- isFeature: true,
- },
"mjolnirRooms": {
supportedLevels: ['account'],
default: [],
@@ -575,7 +569,7 @@ export const SETTINGS = {
},
"useIRCLayout": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
- displayName: _td("Use IRC layout"),
+ displayName: _td("Enable experimental, compact IRC style layout"),
default: false,
},
};
diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts
index 70d4e19a32..48ef75cb59 100644
--- a/src/stores/BreadcrumbsStore.ts
+++ b/src/stores/BreadcrumbsStore.ts
@@ -125,6 +125,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient {
}
private async appendRoom(room: Room) {
+ let updated = false;
const rooms = (this.state.rooms || []).slice(); // cheap clone
// If the room is upgraded, use that room instead. We'll also splice out
@@ -136,30 +137,42 @@ export class BreadcrumbsStore extends AsyncStoreWithClient {
// Take out any room that isn't the most recent room
for (let i = 0; i < history.length - 1; i++) {
const idx = rooms.findIndex(r => r.roomId === history[i].roomId);
- if (idx !== -1) rooms.splice(idx, 1);
+ if (idx !== -1) {
+ rooms.splice(idx, 1);
+ updated = true;
+ }
}
}
// Remove the existing room, if it is present
const existingIdx = rooms.findIndex(r => r.roomId === room.roomId);
- if (existingIdx !== -1) {
- rooms.splice(existingIdx, 1);
- }
- // Splice the room to the start of the list
- rooms.splice(0, 0, room);
+ // If we're focusing on the first room no-op
+ if (existingIdx !== 0) {
+ if (existingIdx !== -1) {
+ rooms.splice(existingIdx, 1);
+ }
+
+ // Splice the room to the start of the list
+ rooms.splice(0, 0, room);
+ updated = true;
+ }
if (rooms.length > MAX_ROOMS) {
// This looks weird, but it's saying to start at the MAX_ROOMS point in the
// list and delete everything after it.
rooms.splice(MAX_ROOMS, rooms.length - MAX_ROOMS);
+ updated = true;
}
- // Update the breadcrumbs
- await this.updateState({rooms});
- const roomIds = rooms.map(r => r.roomId);
- if (roomIds.length > 0) {
- await SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds);
+
+ if (updated) {
+ // Update the breadcrumbs
+ await this.updateState({rooms});
+ const roomIds = rooms.map(r => r.roomId);
+ if (roomIds.length > 0) {
+ await SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds);
+ }
}
}
diff --git a/src/stores/notifications/ListNotificationState.ts b/src/stores/notifications/ListNotificationState.ts
index 5773693b47..6c67dbdd08 100644
--- a/src/stores/notifications/ListNotificationState.ts
+++ b/src/stores/notifications/ListNotificationState.ts
@@ -14,23 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { EventEmitter } from "events";
-import { INotificationState, NOTIFICATION_STATE_UPDATE } from "./INotificationState";
import { NotificationColor } from "./NotificationColor";
-import { IDestroyable } from "../../utils/IDestroyable";
import { TagID } from "../room-list/models";
import { Room } from "matrix-js-sdk/src/models/room";
import { arrayDiff } from "../../utils/arrays";
import { RoomNotificationState } from "./RoomNotificationState";
-import { TagSpecificNotificationState } from "./TagSpecificNotificationState";
+import { NOTIFICATION_STATE_UPDATE, NotificationState } from "./NotificationState";
-export class ListNotificationState extends EventEmitter implements IDestroyable, INotificationState {
- private _count: number;
- private _color: NotificationColor;
+export type FetchRoomFn = (room: Room) => RoomNotificationState;
+
+export class ListNotificationState extends NotificationState {
private rooms: Room[] = [];
private states: { [roomId: string]: RoomNotificationState } = {};
- constructor(private byTileCount = false, private tagId: TagID) {
+ constructor(private byTileCount = false, private tagId: TagID, private getRoomFn: FetchRoomFn) {
super();
}
@@ -38,14 +35,6 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
return null; // This notification state doesn't support symbols
}
- public get count(): number {
- return this._count;
- }
-
- public get color(): NotificationColor {
- return this._color;
- }
-
public setRooms(rooms: Room[]) {
// If we're only concerned about the tile count, don't bother setting up listeners.
if (this.byTileCount) {
@@ -62,16 +51,10 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
if (!state) continue; // We likely just didn't have a badge (race condition)
delete this.states[oldRoom.roomId];
state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
- state.destroy();
}
for (const newRoom of diff.added) {
- const state = new TagSpecificNotificationState(newRoom, this.tagId);
+ const state = this.getRoomFn(newRoom);
state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
- if (this.states[newRoom.roomId]) {
- // "Should never happen" disclaimer.
- console.warn("Overwriting notification state for room:", newRoom.roomId);
- this.states[newRoom.roomId].destroy();
- }
this.states[newRoom.roomId] = state;
}
@@ -85,8 +68,9 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
}
public destroy() {
+ super.destroy();
for (const state of Object.values(this.states)) {
- state.destroy();
+ state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
}
this.states = {};
}
@@ -96,7 +80,7 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
};
private calculateTotalState() {
- const before = {count: this.count, symbol: this.symbol, color: this.color};
+ const snapshot = this.snapshot();
if (this.byTileCount) {
this._color = NotificationColor.Red;
@@ -111,10 +95,7 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
}
// finally, publish an update if needed
- const after = {count: this.count, symbol: this.symbol, color: this.color};
- if (JSON.stringify(before) !== JSON.stringify(after)) {
- this.emit(NOTIFICATION_STATE_UPDATE);
- }
+ this.emitIfUpdated(snapshot);
}
}
diff --git a/src/stores/notifications/NotificationState.ts b/src/stores/notifications/NotificationState.ts
new file mode 100644
index 0000000000..c8ef0ba859
--- /dev/null
+++ b/src/stores/notifications/NotificationState.ts
@@ -0,0 +1,87 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { EventEmitter } from "events";
+import { NotificationColor } from "./NotificationColor";
+import { IDestroyable } from "../../utils/IDestroyable";
+
+export const NOTIFICATION_STATE_UPDATE = "update";
+
+export abstract class NotificationState extends EventEmitter implements IDestroyable {
+ protected _symbol: string;
+ protected _count: number;
+ protected _color: NotificationColor;
+
+ public get symbol(): string {
+ return this._symbol;
+ }
+
+ public get count(): number {
+ return this._count;
+ }
+
+ public get color(): NotificationColor {
+ return this._color;
+ }
+
+ public get isIdle(): boolean {
+ return this.color <= NotificationColor.None;
+ }
+
+ public get isUnread(): boolean {
+ return this.color >= NotificationColor.Bold;
+ }
+
+ public get hasUnreadCount(): boolean {
+ return this.color >= NotificationColor.Grey && (!!this.count || !!this.symbol);
+ }
+
+ public get hasMentions(): boolean {
+ return this.color >= NotificationColor.Red;
+ }
+
+ protected emitIfUpdated(snapshot: NotificationStateSnapshot) {
+ if (snapshot.isDifferentFrom(this)) {
+ this.emit(NOTIFICATION_STATE_UPDATE);
+ }
+ }
+
+ protected snapshot(): NotificationStateSnapshot {
+ return new NotificationStateSnapshot(this);
+ }
+
+ public destroy(): void {
+ this.removeAllListeners(NOTIFICATION_STATE_UPDATE);
+ }
+}
+
+export class NotificationStateSnapshot {
+ private readonly symbol: string;
+ private readonly count: number;
+ private readonly color: NotificationColor;
+
+ constructor(state: NotificationState) {
+ this.symbol = state.symbol;
+ this.count = state.count;
+ this.color = state.color;
+ }
+
+ public isDifferentFrom(other: NotificationState): boolean {
+ const before = {count: this.count, symbol: this.symbol, color: this.color};
+ const after = {count: other.count, symbol: other.symbol, color: other.color};
+ return JSON.stringify(before) !== JSON.stringify(after);
+ }
+}
diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts
index 51355a2d4d..ab354c0e93 100644
--- a/src/stores/notifications/RoomNotificationState.ts
+++ b/src/stores/notifications/RoomNotificationState.ts
@@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { EventEmitter } from "events";
-import { INotificationState, NOTIFICATION_STATE_UPDATE } from "./INotificationState";
import { NotificationColor } from "./NotificationColor";
import { IDestroyable } from "../../utils/IDestroyable";
import { MatrixClientPeg } from "../../MatrixClientPeg";
@@ -25,12 +23,9 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import * as RoomNotifs from '../../RoomNotifs';
import * as Unread from '../../Unread';
+import { NotificationState } from "./NotificationState";
-export class RoomNotificationState extends EventEmitter implements IDestroyable, INotificationState {
- private _symbol: string;
- private _count: number;
- private _color: NotificationColor;
-
+export class RoomNotificationState extends NotificationState implements IDestroyable {
constructor(public readonly room: Room) {
super();
this.room.on("Room.receipt", this.handleReadReceipt);
@@ -41,23 +36,12 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable,
this.updateNotificationState();
}
- public get symbol(): string {
- return this._symbol;
- }
-
- public get count(): number {
- return this._count;
- }
-
- public get color(): NotificationColor {
- return this._color;
- }
-
private get roomIsInvite(): boolean {
return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite;
}
public destroy(): void {
+ super.destroy();
this.room.removeListener("Room.receipt", this.handleReadReceipt);
this.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
this.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
@@ -87,7 +71,7 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable,
};
private updateNotificationState() {
- const before = {count: this.count, symbol: this.symbol, color: this.color};
+ const snapshot = this.snapshot();
if (RoomNotifs.getRoomNotifsState(this.room.roomId) === RoomNotifs.MUTE) {
// When muted we suppress all notification states, even if we have context on them.
@@ -136,9 +120,6 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable,
}
// finally, publish an update if needed
- const after = {count: this.count, symbol: this.symbol, color: this.color};
- if (JSON.stringify(before) !== JSON.stringify(after)) {
- this.emit(NOTIFICATION_STATE_UPDATE);
- }
+ this.emitIfUpdated(snapshot);
}
}
diff --git a/src/stores/notifications/RoomNotificationStateStore.ts b/src/stores/notifications/RoomNotificationStateStore.ts
new file mode 100644
index 0000000000..311dcdf2d6
--- /dev/null
+++ b/src/stores/notifications/RoomNotificationStateStore.ts
@@ -0,0 +1,101 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { ActionPayload } from "../../dispatcher/payloads";
+import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
+import defaultDispatcher from "../../dispatcher/dispatcher";
+import { DefaultTagID, TagID } from "../room-list/models";
+import { FetchRoomFn, ListNotificationState } from "./ListNotificationState";
+import { Room } from "matrix-js-sdk/src/models/room";
+import { RoomNotificationState } from "./RoomNotificationState";
+import { TagSpecificNotificationState } from "./TagSpecificNotificationState";
+
+const INSPECIFIC_TAG = "INSPECIFIC_TAG";
+type INSPECIFIC_TAG = "INSPECIFIC_TAG";
+
+interface IState {}
+
+export class RoomNotificationStateStore extends AsyncStoreWithClient {
+ private static internalInstance = new RoomNotificationStateStore();
+
+ private roomMap = new Map>();
+
+ private constructor() {
+ super(defaultDispatcher, {});
+ }
+
+ /**
+ * Creates a new list notification state. The consumer is expected to set the rooms
+ * on the notification state, and destroy the state when it no longer needs it.
+ * @param tagId The tag to create the notification state for.
+ * @returns The notification state for the tag.
+ */
+ public getListState(tagId: TagID): ListNotificationState {
+ // Note: we don't cache these notification states as the consumer is expected to call
+ // .setRooms() on the returned object, which could confuse other consumers.
+
+ // TODO: Update if/when invites move out of the room list.
+ const useTileCount = tagId === DefaultTagID.Invite;
+ const getRoomFn: FetchRoomFn = (room: Room) => {
+ return this.getRoomState(room, tagId);
+ };
+ return new ListNotificationState(useTileCount, tagId, getRoomFn);
+ }
+
+ /**
+ * Gets a copy of the notification state for a room. The consumer should not
+ * attempt to destroy the returned state as it may be shared with other
+ * consumers.
+ * @param room The room to get the notification state for.
+ * @param inTagId Optional tag ID to scope the notification state to.
+ * @returns The room's notification state.
+ */
+ public getRoomState(room: Room, inTagId?: TagID): RoomNotificationState {
+ if (!this.roomMap.has(room)) {
+ this.roomMap.set(room, new Map());
+ }
+
+ const targetTag = inTagId ? inTagId : INSPECIFIC_TAG;
+
+ const forRoomMap = this.roomMap.get(room);
+ if (!forRoomMap.has(targetTag)) {
+ if (inTagId) {
+ forRoomMap.set(inTagId, new TagSpecificNotificationState(room, inTagId));
+ } else {
+ forRoomMap.set(INSPECIFIC_TAG, new RoomNotificationState(room));
+ }
+ }
+
+ return forRoomMap.get(targetTag);
+ }
+
+ public static get instance(): RoomNotificationStateStore {
+ return RoomNotificationStateStore.internalInstance;
+ }
+
+ protected async onNotReady(): Promise {
+ for (const roomMap of this.roomMap.values()) {
+ for (const roomState of roomMap.values()) {
+ roomState.destroy();
+ }
+ }
+ }
+
+ // We don't need this, but our contract says we do.
+ protected async onAction(payload: ActionPayload) {
+ return Promise.resolve();
+ }
+}
diff --git a/src/stores/notifications/StaticNotificationState.ts b/src/stores/notifications/StaticNotificationState.ts
index 51902688fe..0392ed3716 100644
--- a/src/stores/notifications/StaticNotificationState.ts
+++ b/src/stores/notifications/StaticNotificationState.ts
@@ -14,13 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { EventEmitter } from "events";
-import { INotificationState } from "./INotificationState";
import { NotificationColor } from "./NotificationColor";
+import { NotificationState } from "./NotificationState";
-export class StaticNotificationState extends EventEmitter implements INotificationState {
- constructor(public symbol: string, public count: number, public color: NotificationColor) {
+export class StaticNotificationState extends NotificationState {
+ constructor(symbol: string, count: number, color: NotificationColor) {
super();
+ this._symbol = symbol;
+ this._count = count;
+ this._color = color;
}
public static forCount(count: number, color: NotificationColor): StaticNotificationState {
diff --git a/src/stores/room-list/ListLayout.ts b/src/stores/room-list/ListLayout.ts
index f31e92b8ae..5169c5e4e5 100644
--- a/src/stores/room-list/ListLayout.ts
+++ b/src/stores/room-list/ListLayout.ts
@@ -89,11 +89,12 @@ export class ListLayout {
return 5 + RESIZER_BOX_FACTOR;
}
- public setVisibleTilesWithin(diff: number, maxPossible: number) {
- if (this.visibleTiles > maxPossible) {
- this.visibleTiles = maxPossible + diff;
+ public setVisibleTilesWithin(newVal: number, maxPossible: number) {
+ maxPossible = maxPossible + RESIZER_BOX_FACTOR;
+ if (newVal > maxPossible) {
+ this.visibleTiles = maxPossible;
} else {
- this.visibleTiles += diff;
+ this.visibleTiles = newVal;
}
}
@@ -108,10 +109,6 @@ export class ListLayout {
return this.tilesToPixels(Math.min(maxTiles, n)) + padding;
}
- public tilesWithResizerBoxFactor(n: number): number {
- return n + RESIZER_BOX_FACTOR;
- }
-
public tilesWithPadding(n: number, paddingPx: number): number {
return this.pixelsToTiles(this.tilesToPixelsWithPadding(n, paddingPx));
}
diff --git a/src/stores/room-list/RoomListLayoutStore.ts b/src/stores/room-list/RoomListLayoutStore.ts
new file mode 100644
index 0000000000..fbc7d7719d
--- /dev/null
+++ b/src/stores/room-list/RoomListLayoutStore.ts
@@ -0,0 +1,73 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { TagID } from "./models";
+import { ListLayout } from "./ListLayout";
+import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
+import defaultDispatcher from "../../dispatcher/dispatcher";
+import { ActionPayload } from "../../dispatcher/payloads";
+
+interface IState {}
+
+export default class RoomListLayoutStore extends AsyncStoreWithClient {
+ private static internalInstance: RoomListLayoutStore;
+
+ private readonly layoutMap = new Map();
+
+ constructor() {
+ super(defaultDispatcher);
+ }
+
+ public static get instance(): RoomListLayoutStore {
+ if (!RoomListLayoutStore.internalInstance) {
+ RoomListLayoutStore.internalInstance = new RoomListLayoutStore();
+ }
+ return RoomListLayoutStore.internalInstance;
+ }
+
+ public ensureLayoutExists(tagId: TagID) {
+ if (!this.layoutMap.has(tagId)) {
+ this.layoutMap.set(tagId, new ListLayout(tagId));
+ }
+ }
+
+ public getLayoutFor(tagId: TagID): ListLayout {
+ if (!this.layoutMap.has(tagId)) {
+ this.layoutMap.set(tagId, new ListLayout(tagId));
+ }
+ return this.layoutMap.get(tagId);
+ }
+
+ // Note: this primarily exists for debugging, and isn't really intended to be used by anything.
+ public async resetLayouts() {
+ console.warn("Resetting layouts for room list");
+ for (const layout of this.layoutMap.values()) {
+ layout.reset();
+ }
+ }
+
+ protected async onNotReady(): Promise {
+ // On logout, clear the map.
+ this.layoutMap.clear();
+ }
+
+ // We don't need this function, but our contract says we do
+ protected async onAction(payload: ActionPayload): Promise {
+ return Promise.resolve();
+ }
+}
+
+window.mx_RoomListLayoutStore = RoomListLayoutStore.instance;
diff --git a/src/stores/room-list/RoomListStore2.ts b/src/stores/room-list/RoomListStore2.ts
index 5185e08759..bb3cd63ceb 100644
--- a/src/stores/room-list/RoomListStore2.ts
+++ b/src/stores/room-list/RoomListStore2.ts
@@ -32,6 +32,7 @@ import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm";
import { EffectiveMembership, getEffectiveMembership } from "./membership";
import { ListLayout } from "./ListLayout";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
+import RoomListLayoutStore from "./RoomListLayoutStore";
interface IState {
tagsEnabled?: boolean;
@@ -50,6 +51,7 @@ export class RoomListStore2 extends AsyncStore {
private algorithm = new Algorithm();
private filterConditions: IFilterCondition[] = [];
private tagWatcher = new TagWatcher(this);
+ private layoutMap: Map = new Map();
private readonly watchedSettings = [
'feature_custom_tags',
@@ -435,6 +437,8 @@ export class RoomListStore2 extends AsyncStore {
for (const tagId of OrderedDefaultTagIDs) {
sorts[tagId] = this.calculateTagSorting(tagId);
orders[tagId] = this.calculateListOrder(tagId);
+
+ RoomListLayoutStore.instance.ensureLayoutExists(tagId);
}
if (this.state.tagsEnabled) {
@@ -453,15 +457,6 @@ export class RoomListStore2 extends AsyncStore {
this.emit(LISTS_UPDATE_EVENT, this);
}
- // Note: this primarily exists for debugging, and isn't really intended to be used by anything.
- public async resetLayouts() {
- console.warn("Resetting layouts for room list");
- for (const tagId of Object.keys(this.orderedLists)) {
- new ListLayout(tagId).reset();
- }
- await this.regenerateAllLists();
- }
-
public addFilter(filter: IFilterCondition): void {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log("Adding filter condition:", filter);
diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts
index 8c7bbc8615..35511a461d 100644
--- a/src/stores/room-list/algorithms/Algorithm.ts
+++ b/src/stores/room-list/algorithms/Algorithm.ts
@@ -41,6 +41,17 @@ import { getListAlgorithmInstance } from "./list-ordering";
*/
export const LIST_UPDATED_EVENT = "list_updated_event";
+// These are the causes which require a room to be known in order for us to handle them. If
+// a cause in this list is raised and we don't know about the room, we don't handle the update.
+//
+// Note: these typically happen when a new room is coming in, such as the user creating or
+// joining the room. For these cases, we need to know about the room prior to handling it otherwise
+// we'll make bad assumptions.
+const CAUSES_REQUIRING_ROOM = [
+ RoomUpdateCause.Timeline,
+ RoomUpdateCause.ReadReceipt,
+];
+
interface IStickyRoom {
room: Room;
position: number;
@@ -655,24 +666,36 @@ export class Algorithm extends EventEmitter {
cause = RoomUpdateCause.PossibleTagChange;
}
- // If we have tags for a room and don't have the room referenced, the room reference
- // probably changed. We need to swap out the problematic reference.
- if (hasTags && !this.rooms.includes(room) && !isSticky) {
- console.warn(`${room.roomId} is missing from room array but is known - trying to find duplicate`);
+ // Check to see if the room is known first
+ let knownRoomRef = this.rooms.includes(room);
+ if (hasTags && !knownRoomRef) {
+ console.warn(`${room.roomId} might be a reference change - attempting to update reference`);
this.rooms = this.rooms.map(r => r.roomId === room.roomId ? room : r);
-
- // Sanity check
- if (!this.rooms.includes(room)) {
- throw new Error(`Failed to replace ${room.roomId} with an updated reference`);
+ knownRoomRef = this.rooms.includes(room);
+ if (!knownRoomRef) {
+ console.warn(`${room.roomId} is still not referenced. It may be sticky.`);
}
}
+ // If we have tags for a room and don't have the room referenced, something went horribly
+ // wrong - the reference should have been updated above.
+ if (hasTags && !knownRoomRef && !isSticky) {
+ throw new Error(`${room.roomId} is missing from room array but is known - trying to find duplicate`);
+ }
+
// Like above, update the reference to the sticky room if we need to
if (hasTags && isSticky) {
// Go directly in and set the sticky room's new reference, being careful not
// to trigger a sticky room update ourselves.
this._stickyRoom.room = room;
}
+
+ // If after all that we're still a NewRoom update, add the room if applicable.
+ // We don't do this for the sticky room (because it causes duplication issues)
+ // or if we know about the reference (as it should be replaced).
+ if (cause === RoomUpdateCause.NewRoom && !isSticky && !knownRoomRef) {
+ this.rooms.push(room);
+ }
}
if (cause === RoomUpdateCause.PossibleTagChange) {
@@ -687,6 +710,7 @@ export class Algorithm extends EventEmitter {
const algorithm: OrderingAlgorithm = this.algorithms[rmTag];
if (!algorithm) throw new Error(`No algorithm for ${rmTag}`);
await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
+ this.cachedRooms[rmTag] = algorithm.orderedRooms;
}
for (const addTag of diff.added) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
@@ -694,6 +718,7 @@ export class Algorithm extends EventEmitter {
const algorithm: OrderingAlgorithm = this.algorithms[addTag];
if (!algorithm) throw new Error(`No algorithm for ${addTag}`);
await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom);
+ this.cachedRooms[addTag] = algorithm.orderedRooms;
}
// Update the tag map so we don't regen it in a moment
@@ -738,6 +763,11 @@ export class Algorithm extends EventEmitter {
}
if (!this.roomIdsToTags[room.roomId]) {
+ if (CAUSES_REQUIRING_ROOM.includes(cause)) {
+ console.warn(`Skipping tag update for ${room.roomId} because we don't know about the room`);
+ return false;
+ }
+
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Updating tags for room ${room.roomId} (${room.name})`);
diff --git a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts
index e95f92f985..3acd9f924e 100644
--- a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts
+++ b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts
@@ -160,7 +160,10 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
this.cachedOrderedRooms.splice(this.indices[category], 0, room); // splice in the new room (pre-adjusted)
} else if (cause === RoomUpdateCause.RoomRemoved) {
const roomIdx = this.getRoomIndex(room);
- if (roomIdx === -1) return false; // no change
+ if (roomIdx === -1) {
+ console.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`);
+ return false; // no change
+ }
const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices);
this.alterCategoryPositionBy(oldCategory, -1, this.indices);
this.cachedOrderedRooms.splice(roomIdx, 1); // remove the room
@@ -169,15 +172,6 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
}
}
- private getRoomIndex(room: Room): number {
- let roomIdx = this.cachedOrderedRooms.indexOf(room);
- if (roomIdx === -1) { // can only happen if the js-sdk's store goes sideways.
- console.warn(`Degrading performance to find missing room in "${this.tagId}": ${room.roomId}`);
- roomIdx = this.cachedOrderedRooms.findIndex(r => r.roomId === room.roomId);
- }
- return roomIdx;
- }
-
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise {
try {
await this.updateLock.acquireAsync();
diff --git a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts
index f74329cb4d..849c8a2877 100644
--- a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts
+++ b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts
@@ -50,8 +50,12 @@ export class NaturalAlgorithm extends OrderingAlgorithm {
if (cause === RoomUpdateCause.NewRoom) {
this.cachedOrderedRooms.push(room);
} else if (cause === RoomUpdateCause.RoomRemoved) {
- const idx = this.cachedOrderedRooms.indexOf(room);
- if (idx >= 0) this.cachedOrderedRooms.splice(idx, 1);
+ const idx = this.getRoomIndex(room);
+ if (idx >= 0) {
+ this.cachedOrderedRooms.splice(idx, 1);
+ } else {
+ console.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`);
+ }
}
// TODO: Optimize this to avoid useless operations: https://github.com/vector-im/riot-web/issues/14035
diff --git a/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts
index 4ab7650367..c47a35523c 100644
--- a/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts
+++ b/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts
@@ -70,4 +70,13 @@ export abstract class OrderingAlgorithm {
* @returns True if the update requires the Algorithm to update the presentation layers.
*/
public abstract handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise;
+
+ protected getRoomIndex(room: Room): number {
+ let roomIdx = this.cachedOrderedRooms.indexOf(room);
+ if (roomIdx === -1) { // can only happen if the js-sdk's store goes sideways.
+ console.warn(`Degrading performance to find missing room in "${this.tagId}": ${room.roomId}`);
+ roomIdx = this.cachedOrderedRooms.findIndex(r => r.roomId === room.roomId);
+ }
+ return roomIdx;
+ }
}
diff --git a/src/stores/room-list/previews/MessageEventPreview.ts b/src/stores/room-list/previews/MessageEventPreview.ts
index 86ec4c539b..86cb51ef15 100644
--- a/src/stores/room-list/previews/MessageEventPreview.ts
+++ b/src/stores/room-list/previews/MessageEventPreview.ts
@@ -20,6 +20,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import ReplyThread from "../../../components/views/elements/ReplyThread";
+import { sanitizedHtmlNodeInnerText } from "../../../HtmlUtils";
export class MessageEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string {
@@ -36,14 +37,27 @@ export class MessageEventPreview implements IPreview {
const msgtype = eventContent['msgtype'];
if (!body || !msgtype) return null; // invalid event, no preview
+ const hasHtml = eventContent.format === "org.matrix.custom.html" && eventContent.formatted_body;
+ if (hasHtml) {
+ body = eventContent.formatted_body;
+ }
+
// XXX: Newer relations have a getRelation() function which is not compatible with replies.
const mRelatesTo = event.getWireContent()['m.relates_to'];
if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
// If this is a reply, get the real reply and use that
- body = (ReplyThread.stripPlainReply(body) || '').trim();
+ if (hasHtml) {
+ body = (ReplyThread.stripHTMLReply(body) || '').trim();
+ } else {
+ body = (ReplyThread.stripPlainReply(body) || '').trim();
+ }
if (!body) return null; // invalid event, no preview
}
+ if (hasHtml) {
+ body = sanitizedHtmlNodeInnerText(body);
+ }
+
if (msgtype === 'm.emote') {
return _t("%(senderName)s %(emote)s", {senderName: getSenderName(event), emote: body});
}
diff --git a/test/components/views/messages/TextualBody-test.js b/test/components/views/messages/TextualBody-test.js
index 07cd51edbd..1f0749aff5 100644
--- a/test/components/views/messages/TextualBody-test.js
+++ b/test/components/views/messages/TextualBody-test.js
@@ -205,8 +205,9 @@ describe("", () => {
expect(content.html()).toBe('' +
'Hey ' +
'' +
- 'Member' +
+ 'Member' +
'');
});
});
diff --git a/yarn.lock b/yarn.lock
index d8106febab..f3dc163b00 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1308,6 +1308,13 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339"
integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==
+"@types/linkifyjs@^2.1.3":
+ version "2.1.3"
+ resolved "https://registry.yarnpkg.com/@types/linkifyjs/-/linkifyjs-2.1.3.tgz#80195c3c88c5e75d9f660e3046ce4a42be2c2fa4"
+ integrity sha512-V3Xt9wgaOvDPXcpOy3dC8qXCxy3cs0Lr/Hqgd9Bi6m3sf/vpbpTtfmVR0LJklrqYEjaAmc7e3Xh/INT2rCAKjQ==
+ dependencies:
+ "@types/react" "*"
+
"@types/lodash@^4.14.152":
version "4.14.155"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.155.tgz#e2b4514f46a261fd11542e47519c20ebce7bc23a"
@@ -1372,6 +1379,13 @@
"@types/prop-types" "*"
csstype "^2.2.0"
+"@types/sanitize-html@^1.23.3":
+ version "1.23.3"
+ resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-1.23.3.tgz#26527783aba3bf195ad8a3c3e51bd3713526fc0d"
+ integrity sha512-Isg8N0ifKdDq6/kaNlIcWfapDXxxquMSk2XC5THsOICRyOIhQGds95XH75/PL/g9mExi4bL8otIqJM/Wo96WxA==
+ dependencies:
+ htmlparser2 "^4.1.0"
+
"@types/stack-utils@^1.0.1":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
@@ -2499,7 +2513,7 @@ class-utils@^0.3.5:
isobject "^3.0.0"
static-extend "^0.1.1"
-classnames@^2.1.2, classnames@^2.2.5:
+classnames@^2.1.2:
version "2.2.6"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==
@@ -3779,6 +3793,11 @@ fast-levenshtein@~2.0.6:
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
+fast-memoize@^2.5.1:
+ version "2.5.2"
+ resolved "https://registry.yarnpkg.com/fast-memoize/-/fast-memoize-2.5.2.tgz#79e3bb6a4ec867ea40ba0e7146816f6cdce9b57e"
+ integrity sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==
+
fb-watchman@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.1.tgz#fc84fb39d2709cf3ff6d743706157bb5708a8a85"
@@ -6882,7 +6901,7 @@ prop-types-exact@^1.2.0:
object.assign "^4.1.0"
reflect.ownkeys "^0.2.0"
-prop-types@15.x, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
+prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
@@ -7053,6 +7072,13 @@ rc@1.2.8, rc@^1.2.8:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
+re-resizable@^6.5.2:
+ version "6.5.2"
+ resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.5.2.tgz#7eb1928c673285d4dcf654211e47acb9a3801c3e"
+ integrity sha512-Pjo3ydkr/meTr6j3YZqyv+9fRS5UNOj5SaAI06gHFQ35BnpsZKmwNvupCnbo11gjQ1I62Uy+UzlHLO9xPQEuWQ==
+ dependencies:
+ fast-memoize "^2.5.1"
+
react-beautiful-dnd@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-4.0.1.tgz#3b0a49bf6be75af351176c904f012611dd292b81"
@@ -7086,14 +7112,6 @@ react-dom@^16.9.0:
prop-types "^15.6.2"
scheduler "^0.19.1"
-react-draggable@^4.0.3:
- version "4.4.2"
- resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.4.2.tgz#f3cefecee25f467f865144cda0d066e5f05f94a0"
- integrity sha512-zLQs4R4bnBCGnCVTZiD8hPsHtkiJxgMpGDlRESM+EHQo8ysXhKJ2GKdJ8UxxLJdRVceX1j19jy+hQS2wHislPQ==
- dependencies:
- classnames "^2.2.5"
- prop-types "^15.6.0"
-
react-focus-lock@^2.2.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.3.1.tgz#9d5d85899773609c7eefa4fc54fff6a0f5f2fc47"
@@ -7138,14 +7156,6 @@ react-redux@^5.0.6:
react-is "^16.6.0"
react-lifecycles-compat "^3.0.0"
-react-resizable@^1.10.1:
- version "1.10.1"
- resolved "https://registry.yarnpkg.com/react-resizable/-/react-resizable-1.10.1.tgz#f0c2cf1d83b3470b87676ce6d6b02bbe3f4d8cd4"
- integrity sha512-Jd/bKOKx6+19NwC4/aMLRu/J9/krfxlDnElP41Oc+oLiUWs/zwV1S9yBfBZRnqAwQb6vQ/HRSk3bsSWGSgVbpw==
- dependencies:
- prop-types "15.x"
- react-draggable "^4.0.3"
-
react-test-renderer@^16.0.0-0, react-test-renderer@^16.9.0:
version "16.13.1"
resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.13.1.tgz#de25ea358d9012606de51e012d9742e7f0deabc1"