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/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index e65c2bc606..b65f176089 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -52,6 +52,7 @@ import { } from "../../toasts/ServerLimitToast"; import { Action } from "../../dispatcher/actions"; import LeftPanel2 from "./LeftPanel2"; +import CallContainer from '../views/voip/CallContainer'; import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload"; // We need to fetch each pinned message individually (if we don't already have it) @@ -695,6 +696,7 @@ class LoggedInView 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 {
this.onTagRoom(e, DefaultTagID.Favourite)} - active={false} // TODO: https://github.com/vector-im/riot-web/issues/14283 - label={_t("Favourite")} + active={isFavorite} + label={favouriteLabel} > - - {_t("Favourite")} + + {favouriteLabel} @@ -437,11 +491,10 @@ export default class RoomTile2 extends React.Component { } } - const notificationColor = this.state.notificationState.color; const nameClasses = classNames({ "mx_RoomTile2_name": true, "mx_RoomTile2_nameWithPreview": !!messagePreview, - "mx_RoomTile2_nameHasUnreadEvents": notificationColor >= NotificationColor.Bold, + "mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.isUnread, }); let nameContainer = ( @@ -458,15 +511,15 @@ export default class RoomTile2 extends React.Component { // The following labels are written in such a fashion to increase screen reader efficiency (speed). if (this.props.tag === DefaultTagID.Invite) { // append nothing - } else if (notificationColor >= NotificationColor.Red) { + } else if (this.state.notificationState.hasMentions) { ariaLabel += " " + _t("%(count)s unread messages including mentions.", { count: this.state.notificationState.count, }); - } else if (notificationColor >= NotificationColor.Grey) { + } else if (this.state.notificationState.hasUnreadCount) { ariaLabel += " " + _t("%(count)s unread messages.", { count: this.state.notificationState.count, }); - } else if (notificationColor >= NotificationColor.Bold) { + } else if (this.state.notificationState.isUnread) { ariaLabel += " " + _t("Unread messages."); } @@ -477,7 +530,7 @@ export default class RoomTile2 extends React.Component { return ( - + {({onFocus, isActive, ref}) => void; } @@ -74,7 +73,7 @@ export default class TemporaryTile extends React.Component { const nameClasses = classNames({ "mx_RoomTile2_name": true, - "mx_RoomTile2_nameHasUnreadEvents": this.props.notificationState.color >= NotificationColor.Bold, + "mx_RoomTile2_nameHasUnreadEvents": this.props.notificationState.isUnread, }); let nameContainer = ( diff --git a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.js b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.js index f57d5d3798..2edf3021dc 100644 --- a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.js +++ b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.js @@ -22,6 +22,10 @@ import * as sdk from "../../../../.."; import AccessibleButton from "../../../elements/AccessibleButton"; import Modal from "../../../../../Modal"; import dis from "../../../../../dispatcher/dispatcher"; +import RoomListStore from "../../../../../stores/room-list/RoomListStore2"; +import RoomListActions from "../../../../../actions/RoomListActions"; +import { DefaultTagID } from '../../../../../stores/room-list/models'; +import LabelledToggleSwitch from '../../../elements/LabelledToggleSwitch'; export default class AdvancedRoomSettingsTab extends React.Component { static propTypes = { @@ -29,12 +33,16 @@ export default class AdvancedRoomSettingsTab extends React.Component { closeSettingsFn: PropTypes.func.isRequired, }; - constructor() { - super(); + constructor(props) { + super(props); + + const room = MatrixClientPeg.get().getRoom(props.roomId); + const roomTags = RoomListStore.instance.getTagsForRoom(room); this.state = { // This is eventually set to the value of room.getRecommendedVersion() upgradeRecommendation: null, + isLowPriorityRoom: roomTags.includes(DefaultTagID.LowPriority), }; } @@ -86,6 +94,25 @@ export default class AdvancedRoomSettingsTab extends React.Component { this.props.closeSettingsFn(); }; + _onToggleLowPriorityTag = (e) => { + this.setState({ + isLowPriorityRoom: !this.state.isLowPriorityRoom, + }); + + const removeTag = this.state.isLowPriorityRoom ? DefaultTagID.LowPriority : DefaultTagID.Favourite; + const addTag = this.state.isLowPriorityRoom ? null : DefaultTagID.LowPriority; + const client = MatrixClientPeg.get(); + + dis.dispatch(RoomListActions.tagRoom( + client, + client.getRoom(this.props.roomId), + removeTag, + addTag, + undefined, + 0, + )); + } + render() { const client = MatrixClientPeg.get(); const room = client.getRoom(this.props.roomId); @@ -156,6 +183,17 @@ export default class AdvancedRoomSettingsTab extends React.Component { {_t("Open Devtools")}
+
+ {_t('Make this room low priority')} + +
); } diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx index 9bed2fb039..3cb0028f45 100644 --- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx @@ -402,6 +402,12 @@ export default class AppearanceUserSettingsTab extends React.Component + this.setState({useIRCLayout: checked})} + /> {this.renderThemeSection()} {SettingsStore.isFeatureEnabled("feature_font_scaling") ? this.renderFontSection() : null} - {SettingsStore.isFeatureEnabled("feature_irc_ui") ? this.renderLayoutSection() : null} {this.renderAdvancedSection()}
); 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