Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into joriks/room-list-auto-expand-on-search

This commit is contained in:
Jorik Schellekens 2020-07-09 17:14:52 +01:00
commit 9b79de7fe7
63 changed files with 2300 additions and 799 deletions

View file

@ -89,11 +89,11 @@
"prop-types": "^15.5.8", "prop-types": "^15.5.8",
"qrcode": "^1.4.4", "qrcode": "^1.4.4",
"qs": "^6.6.0", "qs": "^6.6.0",
"re-resizable": "^6.5.2",
"react": "^16.9.0", "react": "^16.9.0",
"react-beautiful-dnd": "^4.0.1", "react-beautiful-dnd": "^4.0.1",
"react-dom": "^16.9.0", "react-dom": "^16.9.0",
"react-focus-lock": "^2.2.1", "react-focus-lock": "^2.2.1",
"react-resizable": "^1.10.1",
"react-transition-group": "^4.4.1", "react-transition-group": "^4.4.1",
"resize-observer-polyfill": "^1.5.0", "resize-observer-polyfill": "^1.5.0",
"sanitize-html": "^1.18.4", "sanitize-html": "^1.18.4",
@ -122,6 +122,7 @@
"@types/classnames": "^2.2.10", "@types/classnames": "^2.2.10",
"@types/counterpart": "^0.18.1", "@types/counterpart": "^0.18.1",
"@types/flux": "^3.1.9", "@types/flux": "^3.1.9",
"@types/linkifyjs": "^2.1.3",
"@types/lodash": "^4.14.152", "@types/lodash": "^4.14.152",
"@types/modernizr": "^3.5.3", "@types/modernizr": "^3.5.3",
"@types/node": "^12.12.41", "@types/node": "^12.12.41",
@ -129,6 +130,7 @@
"@types/react": "^16.9", "@types/react": "^16.9",
"@types/react-dom": "^16.9.8", "@types/react-dom": "^16.9.8",
"@types/react-transition-group": "^4.4.0", "@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "^1.23.3",
"@types/zxcvbn": "^4.4.0", "@types/zxcvbn": "^4.4.0",
"babel-eslint": "^10.0.3", "babel-eslint": "^10.0.3",
"babel-jest": "^24.9.0", "babel-jest": "^24.9.0",

View file

@ -51,6 +51,7 @@
@import "./views/avatars/_BaseAvatar.scss"; @import "./views/avatars/_BaseAvatar.scss";
@import "./views/avatars/_DecoratedRoomAvatar.scss"; @import "./views/avatars/_DecoratedRoomAvatar.scss";
@import "./views/avatars/_MemberStatusMessageAvatar.scss"; @import "./views/avatars/_MemberStatusMessageAvatar.scss";
@import "./views/avatars/_PulsedAvatar.scss";
@import "./views/context_menus/_MessageContextMenu.scss"; @import "./views/context_menus/_MessageContextMenu.scss";
@import "./views/context_menus/_RoomTileContextMenu.scss"; @import "./views/context_menus/_RoomTileContextMenu.scss";
@import "./views/context_menus/_StatusMessageContextMenu.scss"; @import "./views/context_menus/_StatusMessageContextMenu.scss";
@ -225,6 +226,8 @@
@import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss"; @import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss";
@import "./views/terms/_InlineTermsAgreement.scss"; @import "./views/terms/_InlineTermsAgreement.scss";
@import "./views/verification/_VerificationShowSas.scss"; @import "./views/verification/_VerificationShowSas.scss";
@import "./views/voip/_CallContainer.scss";
@import "./views/voip/_CallView.scss"; @import "./views/voip/_CallView.scss";
@import "./views/voip/_CallView2.scss";
@import "./views/voip/_IncomingCallbox.scss"; @import "./views/voip/_IncomingCallbox.scss";
@import "./views/voip/_VideoView.scss"; @import "./views/voip/_VideoView.scss";

View file

@ -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 { .mx_LeftPanel2_actualRoomListContainer {
flex-grow: 1; // fill the available space flex-grow: 1; // fill the available space
overflow-y: auto; overflow-y: auto;

View file

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

View file

@ -41,6 +41,11 @@ limitations under the License.
// with text-align in parent // with text-align in parent
display: inline-block; display: inline-block;
padding: 0 4px; padding: 0 4px;
color: $roomtile-badge-fg-color;
background-color: $roomtile-name-color;
}
.mx_JumpToBottomButton_highlight .mx_JumpToBottomButton_badge {
color: $secondary-accent-color; color: $secondary-accent-color;
background-color: $warning-color; background-color: $warning-color;
} }

View file

@ -24,10 +24,6 @@ limitations under the License.
margin-left: 8px; margin-left: 8px;
width: 100%; width: 100%;
&:first-child {
margin-top: 12px; // so we're not up against the search/filter
}
.mx_RoomSublist2_headerContainer { .mx_RoomSublist2_headerContainer {
// Create a flexbox to make alignment easy // Create a flexbox to make alignment easy
display: flex; display: flex;
@ -49,13 +45,15 @@ limitations under the License.
padding-bottom: 8px; padding-bottom: 8px;
height: 24px; 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 { .mx_RoomSublist2_stickable {
flex: 1; flex: 1;
max-width: 100%; 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 // Create a flexbox to make ordering easy
display: flex; display: flex;
@ -67,7 +65,6 @@ limitations under the License.
// when sticky scrolls instead of collapses the list. // when sticky scrolls instead of collapses the list.
&.mx_RoomSublist2_headerContainer_sticky { &.mx_RoomSublist2_headerContainer_sticky {
position: fixed; 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 height: 32px; // to match the header container
// width set by JS // width set by JS
} }
@ -190,28 +187,29 @@ limitations under the License.
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
.mx_RoomSublist2_placeholder {
height: 44px; // Height of a room tile plus margins
}
.mx_RoomSublist2_showNButton { .mx_RoomSublist2_showNButton {
cursor: pointer; cursor: pointer;
font-size: $font-13px; font-size: $font-13px;
line-height: $font-18px; line-height: $font-18px;
color: $roomtile2-preview-color; 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 render() function for RoomSublist2 if these change
// Update the ListLayout class for minVisibleTiles if these change. // Update the ListLayout class for minVisibleTiles if these change.
// //
// At 24px high and 8px padding on the top this equates to 0.65 of // At 24px high, 8px padding on the top and 4px padding on the bottom this equates to 0.73 of
// a tile due to how the padding calculations work. // a tile due to how the padding calculations work.
height: 24px; height: 24px;
padding-top: 8px; padding-top: 8px;
padding-bottom: 4px;
// We force this to the bottom so it will overlap rooms as needed. // We force this to the bottom so it will overlap rooms as needed.
// We account for the space it takes up (24px) in the code through padding. // We account for the space it takes up (24px) in the code through padding.
position: absolute; position: absolute;
bottom: 4px; // the height of the resize handle bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
@ -238,39 +236,31 @@ limitations under the License.
.mx_RoomSublist2_showLessButtonChevron { .mx_RoomSublist2_showLessButtonChevron {
mask-image: url('$(res)/img/feather-customised/chevron-up.svg'); 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 // Class name comes from the ResizableBox component
// The hover state needs to use the whole sublist, not just the resizable box, // The hover state needs to use the whole sublist, not just the resizable box,
// so that selector is below and one level higher. // so that selector is below and one level higher.
.react-resizable-handle { .mx_RoomSublist2_resizerHandle {
cursor: ns-resize; cursor: ns-resize;
border-radius: 3px; border-radius: 3px;
// Update RESIZE_HANDLE_HEIGHT if this changes // Override styles from library
height: 4px; width: unset !important;
height: 4px !important; // Update RESIZE_HANDLE_HEIGHT if this changes
// This is positioned directly below the 'show more' button. // This is positioned directly below the 'show more' button.
position: absolute; position: absolute;
bottom: 0; bottom: 0 !important; // override from library
// Together, these make the bar 64px wide // Together, these make the bar 64px wide
left: calc(50% - 32px); // These are also overridden from the library
right: calc(50% - 32px); left: calc(50% - 32px) !important;
right: calc(50% - 32px) !important;
} }
&:hover, &.mx_RoomSublist2_hasMenuOpen { &:hover, &.mx_RoomSublist2_hasMenuOpen {
.react-resizable-handle { .mx_RoomSublist2_resizerHandle {
opacity: 0.8; opacity: 0.8;
background-color: $primary-fg-color; background-color: $primary-fg-color;
} }

View file

@ -21,6 +21,10 @@ limitations under the License.
margin-bottom: 4px; margin-bottom: 4px;
padding: 4px; padding: 4px;
// allow scrollIntoView to ignore the sticky headers, must match combined height of .mx_RoomSublist2_headerContainer
scroll-margin-top: 32px;
scroll-margin-bottom: 32px;
// The tile is also a flexbox row itself // The tile is also a flexbox row itself
display: flex; display: flex;
@ -165,6 +169,11 @@ limitations under the License.
} }
} }
// do not apply scroll-margin-bottom to the sublist which will not have a sticky header below it
.mx_RoomSublist2:last-child .mx_RoomTile2 {
scroll-margin-bottom: 0;
}
// We use these both in context menus and the room tiles // We use these both in context menus and the room tiles
.mx_RoomTile2_iconBell::before { .mx_RoomTile2_iconBell::before {
mask-image: url('$(res)/img/feather-customised/bell.svg'); mask-image: url('$(res)/img/feather-customised/bell.svg');
@ -224,6 +233,10 @@ limitations under the License.
mask-image: url('$(res)/img/feather-customised/star.svg'); 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 { .mx_RoomTile2_iconArrowDown::before {
mask-image: url('$(res)/img/feather-customised/arrow-down.svg'); mask-image: url('$(res)/img/feather-customised/arrow-down.svg');
} }

View file

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

View file

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

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.41411 0.432179C7.59217 -0.144061 8.40783 -0.144059 8.58589 0.43218L10.1715 5.56319H15.3856C15.9721 5.56319 16.224 6.30764 15.7578 6.66373L11.5135 9.90611L13.1185 15.1001C13.2948 15.6705 12.6348 16.1309 12.1604 15.7684L8 12.5902L3.83965 15.7684C3.3652 16.1309 2.70521 15.6705 2.88148 15.1001L4.4865 9.90611L0.242159 6.66373C-0.223967 6.30764 0.0278507 5.56319 0.614427 5.56319H5.82854L7.41411 0.432179Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 533 B

View file

@ -17,3 +17,4 @@ limitations under the License.
// Based on https://stackoverflow.com/a/53229857/3532235 // Based on https://stackoverflow.com/a/53229857/3532235
export type Without<T, U> = {[P in Exclude<keyof T, keyof U>] ? : never}; export type Without<T, U> = {[P in Exclude<keyof T, keyof U>] ? : never};
export type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U; export type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
export type Writeable<T> = { -readonly [P in keyof T]: T[P] };

View file

@ -21,6 +21,7 @@ import ToastStore from "../stores/ToastStore";
import DeviceListener from "../DeviceListener"; import DeviceListener from "../DeviceListener";
import { RoomListStore2 } from "../stores/room-list/RoomListStore2"; import { RoomListStore2 } from "../stores/room-list/RoomListStore2";
import { PlatformPeg } from "../PlatformPeg"; import { PlatformPeg } from "../PlatformPeg";
import RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore";
declare global { declare global {
interface Window { interface Window {
@ -34,6 +35,7 @@ declare global {
mx_ToastStore: ToastStore; mx_ToastStore: ToastStore;
mx_DeviceListener: DeviceListener; mx_DeviceListener: DeviceListener;
mx_RoomListStore2: RoomListStore2; mx_RoomListStore2: RoomListStore2;
mx_RoomListLayoutStore: RoomListLayoutStore;
mxPlatformPeg: PlatformPeg; mxPlatformPeg: PlatformPeg;
} }

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

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

View file

@ -17,10 +17,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
'use strict';
import ReplyThread from "./components/views/elements/ReplyThread";
import React from 'react'; import React from 'react';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
import * as linkify from 'linkifyjs'; import * as linkify from 'linkifyjs';
@ -28,12 +24,13 @@ import linkifyMatrix from './linkify-matrix';
import _linkifyElement from 'linkifyjs/element'; import _linkifyElement from 'linkifyjs/element';
import _linkifyString from 'linkifyjs/string'; import _linkifyString from 'linkifyjs/string';
import classNames from 'classnames'; import classNames from 'classnames';
import {MatrixClientPeg} from './MatrixClientPeg'; import EMOJIBASE_REGEX from 'emojibase-regex';
import url from 'url'; import url from 'url';
import EMOJIBASE_REGEX from 'emojibase-regex'; import {MatrixClientPeg} from './MatrixClientPeg';
import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji"; import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
import ReplyThread from "./components/views/elements/ReplyThread";
linkifyMatrix(linkify); linkifyMatrix(linkify);
@ -64,7 +61,7 @@ const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
* need emojification. * need emojification.
* unicodeToImage uses this function. * unicodeToImage uses this function.
*/ */
function mightContainEmoji(str) { function mightContainEmoji(str: string) {
return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str); return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
} }
@ -74,7 +71,7 @@ function mightContainEmoji(str) {
* @param {String} char The emoji character * @param {String} char The emoji character
* @return {String} The shortcode (such as :thumbup:) * @return {String} The shortcode (such as :thumbup:)
*/ */
export function unicodeToShortcode(char) { export function unicodeToShortcode(char: string) {
const data = getEmojiFromUnicode(char); const data = getEmojiFromUnicode(char);
return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : ''); return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : '');
} }
@ -85,7 +82,7 @@ export function unicodeToShortcode(char) {
* @param {String} shortcode The shortcode (such as :thumbup:) * @param {String} shortcode The shortcode (such as :thumbup:)
* @return {String} The emoji character; null if none exists * @return {String} The emoji character; null if none exists
*/ */
export function shortcodeToUnicode(shortcode) { export function shortcodeToUnicode(shortcode: string) {
shortcode = shortcode.slice(1, shortcode.length - 1); shortcode = shortcode.slice(1, shortcode.length - 1);
const data = SHORTCODE_TO_EMOJI.get(shortcode); const data = SHORTCODE_TO_EMOJI.get(shortcode);
return data ? data.unicode : null; return data ? data.unicode : null;
@ -100,7 +97,7 @@ export function processHtmlForSending(html: string): string {
} }
let contentHTML = ""; let contentHTML = "";
for (let i=0; i < contentDiv.children.length; i++) { for (let i = 0; i < contentDiv.children.length; i++) {
const element = contentDiv.children[i]; const element = contentDiv.children[i];
if (element.tagName.toLowerCase() === 'p') { if (element.tagName.toLowerCase() === 'p') {
contentHTML += element.innerHTML; contentHTML += element.innerHTML;
@ -122,12 +119,19 @@ export function processHtmlForSending(html: string): string {
* Given an untrusted HTML string, return a React node with an sanitized version * Given an untrusted HTML string, return a React node with an sanitized version
* of that HTML. * of that HTML.
*/ */
export function sanitizedHtmlNode(insaneHtml) { export function sanitizedHtmlNode(insaneHtml: string) {
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />; return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
} }
export function sanitizedHtmlNodeInnerText(insaneHtml: string) {
const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
const contentDiv = document.createElement("div");
contentDiv.innerHTML = saneHtml;
return contentDiv.innerText;
}
/** /**
* Tests if a URL from an untrusted source may be safely put into the DOM * Tests if a URL from an untrusted source may be safely put into the DOM
* The biggest threat here is javascript: URIs. * The biggest threat here is javascript: URIs.
@ -136,7 +140,7 @@ export function sanitizedHtmlNode(insaneHtml) {
* other places we need to sanitise URLs. * other places we need to sanitise URLs.
* @return true if permitted, otherwise false * @return true if permitted, otherwise false
*/ */
export function isUrlPermitted(inputUrl) { export function isUrlPermitted(inputUrl: string) {
try { try {
const parsed = url.parse(inputUrl); const parsed = url.parse(inputUrl);
if (!parsed.protocol) return false; if (!parsed.protocol) return false;
@ -147,9 +151,9 @@ export function isUrlPermitted(inputUrl) {
} }
} }
const transformTags = { // custom to matrix const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to matrix
// add blank targets to all hyperlinks except vector URLs // add blank targets to all hyperlinks except vector URLs
'a': function(tagName, attribs) { 'a': function(tagName: string, attribs: sanitizeHtml.Attributes) {
if (attribs.href) { if (attribs.href) {
attribs.target = '_blank'; // by default attribs.target = '_blank'; // by default
@ -162,7 +166,7 @@ const transformTags = { // custom to matrix
attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/ attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/
return { tagName, attribs }; return { tagName, attribs };
}, },
'img': function(tagName, attribs) { 'img': function(tagName: string, attribs: sanitizeHtml.Attributes) {
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and // because transformTags is used _before_ we filter by allowedSchemesByTag and
// we don't want to allow images with `https?` `src`s. // we don't want to allow images with `https?` `src`s.
@ -176,7 +180,7 @@ const transformTags = { // custom to matrix
); );
return { tagName, attribs }; return { tagName, attribs };
}, },
'code': function(tagName, attribs) { 'code': function(tagName: string, attribs: sanitizeHtml.Attributes) {
if (typeof attribs.class !== 'undefined') { if (typeof attribs.class !== 'undefined') {
// Filter out all classes other than ones starting with language- for syntax highlighting. // Filter out all classes other than ones starting with language- for syntax highlighting.
const classes = attribs.class.split(/\s/).filter(function(cl) { const classes = attribs.class.split(/\s/).filter(function(cl) {
@ -186,7 +190,7 @@ const transformTags = { // custom to matrix
} }
return { tagName, attribs }; return { tagName, attribs };
}, },
'*': function(tagName, attribs) { '*': function(tagName: string, attribs: sanitizeHtml.Attributes) {
// Delete any style previously assigned, style is an allowedTag for font and span // Delete any style previously assigned, style is an allowedTag for font and span
// because attributes are stripped after transforming // because attributes are stripped after transforming
delete attribs.style; delete attribs.style;
@ -220,7 +224,7 @@ const transformTags = { // custom to matrix
}, },
}; };
const sanitizeHtmlParams = { const sanitizeHtmlParams: sanitizeHtml.IOptions = {
allowedTags: [ allowedTags: [
'font', // custom to matrix for IRC-style font coloring 'font', // custom to matrix for IRC-style font coloring
'del', // for markdown 'del', // for markdown
@ -247,16 +251,16 @@ const sanitizeHtmlParams = {
}; };
// this is the same as the above except with less rewriting // this is the same as the above except with less rewriting
const composerSanitizeHtmlParams = Object.assign({}, sanitizeHtmlParams); const composerSanitizeHtmlParams: sanitizeHtml.IOptions = {
composerSanitizeHtmlParams.transformTags = { ...sanitizeHtmlParams,
'code': transformTags['code'], transformTags: {
'*': transformTags['*'], 'code': transformTags['code'],
'*': transformTags['*'],
},
}; };
class BaseHighlighter { abstract class BaseHighlighter<T extends React.ReactNode> {
constructor(highlightClass, highlightLink) { constructor(public highlightClass: string, public highlightLink: string) {
this.highlightClass = highlightClass;
this.highlightLink = highlightLink;
} }
/** /**
@ -270,47 +274,49 @@ class BaseHighlighter {
* returns a list of results (strings for HtmlHighligher, react nodes for * returns a list of results (strings for HtmlHighligher, react nodes for
* TextHighlighter). * TextHighlighter).
*/ */
applyHighlights(safeSnippet, safeHighlights) { public applyHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
let lastOffset = 0; let lastOffset = 0;
let offset; let offset;
let nodes = []; let nodes: T[] = [];
const safeHighlight = safeHighlights[0]; const safeHighlight = safeHighlights[0];
while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) { while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) {
// handle preamble // handle preamble
if (offset > lastOffset) { if (offset > lastOffset) {
var subSnippet = safeSnippet.substring(lastOffset, offset); const subSnippet = safeSnippet.substring(lastOffset, offset);
nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights)); nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
} }
// do highlight. use the original string rather than safeHighlight // do highlight. use the original string rather than safeHighlight
// to preserve the original casing. // to preserve the original casing.
const endOffset = offset + safeHighlight.length; const endOffset = offset + safeHighlight.length;
nodes.push(this._processSnippet(safeSnippet.substring(offset, endOffset), true)); nodes.push(this.processSnippet(safeSnippet.substring(offset, endOffset), true));
lastOffset = endOffset; lastOffset = endOffset;
} }
// handle postamble // handle postamble
if (lastOffset !== safeSnippet.length) { if (lastOffset !== safeSnippet.length) {
subSnippet = safeSnippet.substring(lastOffset, undefined); const subSnippet = safeSnippet.substring(lastOffset, undefined);
nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights)); nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
} }
return nodes; return nodes;
} }
_applySubHighlights(safeSnippet, safeHighlights) { private applySubHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
if (safeHighlights[1]) { if (safeHighlights[1]) {
// recurse into this range to check for the next set of highlight matches // recurse into this range to check for the next set of highlight matches
return this.applyHighlights(safeSnippet, safeHighlights.slice(1)); return this.applyHighlights(safeSnippet, safeHighlights.slice(1));
} else { } else {
// no more highlights to be found, just return the unhighlighted string // no more highlights to be found, just return the unhighlighted string
return [this._processSnippet(safeSnippet, false)]; return [this.processSnippet(safeSnippet, false)];
} }
} }
protected abstract processSnippet(snippet: string, highlight: boolean): T;
} }
class HtmlHighlighter extends BaseHighlighter { class HtmlHighlighter extends BaseHighlighter<string> {
/* highlight the given snippet if required /* highlight the given snippet if required
* *
* snippet: content of the span; must have been sanitised * snippet: content of the span; must have been sanitised
@ -318,28 +324,23 @@ class HtmlHighlighter extends BaseHighlighter {
* *
* returns an HTML string * returns an HTML string
*/ */
_processSnippet(snippet, highlight) { protected processSnippet(snippet: string, highlight: boolean): string {
if (!highlight) { if (!highlight) {
// nothing required here // nothing required here
return snippet; return snippet;
} }
let span = "<span class=\""+this.highlightClass+"\">" let span = `<span class="${this.highlightClass}">${snippet}</span>`;
+ snippet + "</span>";
if (this.highlightLink) { if (this.highlightLink) {
span = "<a href=\""+encodeURI(this.highlightLink)+"\">" span = `<a href="${encodeURI(this.highlightLink)}">${span}</a>`;
+span+"</a>";
} }
return span; return span;
} }
} }
class TextHighlighter extends BaseHighlighter { class TextHighlighter extends BaseHighlighter<React.ReactNode> {
constructor(highlightClass, highlightLink) { private key = 0;
super(highlightClass, highlightLink);
this._key = 0;
}
/* create a <span> node to hold the given content /* create a <span> node to hold the given content
* *
@ -348,13 +349,12 @@ class TextHighlighter extends BaseHighlighter {
* *
* returns a React node * returns a React node
*/ */
_processSnippet(snippet, highlight) { protected processSnippet(snippet: string, highlight: boolean): React.ReactNode {
const key = this._key++; const key = this.key++;
let node = let node = <span key={key} className={highlight ? this.highlightClass : null}>
<span key={key} className={highlight ? this.highlightClass : null}> { snippet }
{ snippet } </span>;
</span>;
if (highlight && this.highlightLink) { if (highlight && this.highlightLink) {
node = <a key={key} href={this.highlightLink}>{ node }</a>; node = <a key={key} href={this.highlightLink}>{ node }</a>;
@ -364,6 +364,20 @@ class TextHighlighter extends BaseHighlighter {
} }
} }
interface IContent {
format?: string;
formatted_body?: string;
body: string;
}
interface IOpts {
highlightLink?: string;
disableBigEmoji?: boolean;
stripReplyFallback?: boolean;
returnString?: boolean;
forComposerQuote?: boolean;
ref?: React.Ref<any>;
}
/* turn a matrix event body into html /* turn a matrix event body into html
* *
@ -378,7 +392,7 @@ class TextHighlighter extends BaseHighlighter {
* opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer * opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer
* opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString) * opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString)
*/ */
export function bodyToHtml(content, highlights, opts={}) { export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) {
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body; const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
let bodyHasEmoji = false; let bodyHasEmoji = false;
@ -387,9 +401,9 @@ export function bodyToHtml(content, highlights, opts={}) {
sanitizeParams = composerSanitizeHtmlParams; sanitizeParams = composerSanitizeHtmlParams;
} }
let strippedBody; let strippedBody: string;
let safeBody; let safeBody: string;
let isDisplayedWithHtml; let isDisplayedWithHtml: boolean;
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying // XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
// to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which // to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which
// are interrupted by HTML tags (not that we did before) - e.g. foo<span/>bar won't get highlighted // are interrupted by HTML tags (not that we did before) - e.g. foo<span/>bar won't get highlighted
@ -471,7 +485,7 @@ export function bodyToHtml(content, highlights, opts={}) {
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string} Linkified string * @returns {string} Linkified string
*/ */
export function linkifyString(str, options = linkifyMatrix.options) { export function linkifyString(str: string, options = linkifyMatrix.options) {
return _linkifyString(str, options); return _linkifyString(str, options);
} }
@ -482,7 +496,7 @@ export function linkifyString(str, options = linkifyMatrix.options) {
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options * @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options
* @returns {object} * @returns {object}
*/ */
export function linkifyElement(element, options = linkifyMatrix.options) { export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options) {
return _linkifyElement(element, options); return _linkifyElement(element, options);
} }
@ -493,7 +507,7 @@ export function linkifyElement(element, options = linkifyMatrix.options) {
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string} * @returns {string}
*/ */
export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.options) { export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) {
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams); return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
} }
@ -504,7 +518,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.option
* @param {Node} node * @param {Node} node
* @returns {bool} * @returns {bool}
*/ */
export function checkBlockNode(node) { export function checkBlockNode(node: Node) {
switch (node.nodeName) { switch (node.nodeName) {
case "H1": case "H1":
case "H2": case "H2":

View file

@ -0,0 +1,51 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import AccessibleButton, {IProps as IAccessibleButtonProps} from "../../components/views/elements/AccessibleButton";
interface IProps extends IAccessibleButtonProps {
label?: string;
// whether or not the context menu is currently open
isExpanded: boolean;
}
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuButton: React.FC<IProps> = ({
label,
isExpanded,
children,
onClick,
onContextMenu,
...props
}) => {
return (
<AccessibleButton
{...props}
onClick={onClick}
onContextMenu={onContextMenu || onClick}
title={label}
aria-label={label}
aria-haspopup={true}
aria-expanded={isExpanded}
>
{ children }
</AccessibleButton>
);
};

View file

@ -0,0 +1,30 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
interface IProps extends React.HTMLAttributes<HTMLDivElement> {
label: string;
}
// Semantic component for representing a role=group for grouping menu radios/checkboxes
export const MenuGroup: React.FC<IProps> = ({children, label, ...props}) => {
return <div {...props} role="group" aria-label={label}>
{ children }
</div>;
};

View file

@ -0,0 +1,35 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
label?: string;
}
// Semantic component for representing a role=menuitem
export const MenuItem: React.FC<IProps> = ({children, label, ...props}) => {
return (
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={label}>
{ children }
</AccessibleButton>
);
};

View file

@ -0,0 +1,43 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
label?: string;
active: boolean;
}
// Semantic component for representing a role=menuitemcheckbox
export const MenuItemCheckbox: React.FC<IProps> = ({children, label, active, disabled, ...props}) => {
return (
<AccessibleButton
{...props}
role="menuitemcheckbox"
aria-checked={active}
aria-disabled={disabled}
disabled={disabled}
tabIndex={-1}
aria-label={label}
>
{ children }
</AccessibleButton>
);
};

View file

@ -0,0 +1,43 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
label?: string;
active: boolean;
}
// Semantic component for representing a role=menuitemradio
export const MenuItemRadio: React.FC<IProps> = ({children, label, active, disabled, ...props}) => {
return (
<AccessibleButton
{...props}
role="menuitemradio"
aria-checked={active}
aria-disabled={disabled}
disabled={disabled}
tabIndex={-1}
aria-label={label}
>
{ children }
</AccessibleButton>
);
};

View file

@ -0,0 +1,64 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import {Key} from "../../Keyboard";
import StyledCheckbox from "../../components/views/elements/StyledCheckbox";
interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
label?: string;
onChange(); // we handle keyup/down ourselves so lose the ChangeEvent
onClose(): void; // gets called after onChange on Key.ENTER
}
// Semantic component for representing a styled role=menuitemcheckbox
export const StyledMenuItemCheckbox: React.FC<IProps> = ({children, label, onChange, onClose, ...props}) => {
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === Key.ENTER || e.key === Key.SPACE) {
e.stopPropagation();
e.preventDefault();
onChange();
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
if (e.key === Key.ENTER) {
onClose();
}
}
};
const onKeyUp = (e: React.KeyboardEvent) => {
// prevent the input default handler as we handle it on keydown to match
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
if (e.key === Key.SPACE || e.key === Key.ENTER) {
e.stopPropagation();
e.preventDefault();
}
};
return (
<StyledCheckbox
{...props}
role="menuitemcheckbox"
tabIndex={-1}
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
{ children }
</StyledCheckbox>
);
};

View file

@ -0,0 +1,64 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import {Key} from "../../Keyboard";
import StyledRadioButton from "../../components/views/elements/StyledRadioButton";
interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
label?: string;
onChange(); // we handle keyup/down ourselves so lose the ChangeEvent
onClose(): void; // gets called after onChange on Key.ENTER
}
// Semantic component for representing a styled role=menuitemradio
export const StyledMenuItemRadio: React.FC<IProps> = ({children, label, onChange, onClose, ...props}) => {
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === Key.ENTER || e.key === Key.SPACE) {
e.stopPropagation();
e.preventDefault();
onChange();
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
if (e.key === Key.ENTER) {
onClose();
}
}
};
const onKeyUp = (e: React.KeyboardEvent) => {
// prevent the input default handler as we handle it on keydown to match
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
if (e.key === Key.SPACE || e.key === Key.ENTER) {
e.stopPropagation();
e.preventDefault();
}
};
return (
<StyledRadioButton
{...props}
role="menuitemradio"
tabIndex={-1}
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
{ children }
</StyledRadioButton>
);
};

View file

@ -16,15 +16,12 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {useRef, useState} from 'react'; import React, {CSSProperties, useRef, useState} from "react";
import ReactDOM from 'react-dom'; import ReactDOM from "react-dom";
import PropTypes from 'prop-types'; import classNames from "classnames";
import classNames from 'classnames';
import {Key} from "../../Keyboard"; import {Key} from "../../Keyboard";
import * as sdk from "../../index"; import {Writeable} from "../../@types/common";
import AccessibleButton from "../views/elements/AccessibleButton";
import StyledCheckbox from "../views/elements/StyledCheckbox";
import StyledRadioButton from "../views/elements/StyledRadioButton";
// Shamelessly ripped off Modal.js. There's probably a better way // Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and // of doing reusable widgets like dialog boxes & menus where we go and
@ -32,8 +29,8 @@ import StyledRadioButton from "../views/elements/StyledRadioButton";
const ContextualMenuContainerId = "mx_ContextualMenu_Container"; const ContextualMenuContainerId = "mx_ContextualMenu_Container";
function getOrCreateContainer() { function getOrCreateContainer(): HTMLDivElement {
let container = document.getElementById(ContextualMenuContainerId); let container = document.getElementById(ContextualMenuContainerId) as HTMLDivElement;
if (!container) { if (!container) {
container = document.createElement("div"); container = document.createElement("div");
@ -45,50 +42,70 @@ function getOrCreateContainer() {
} }
const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]); const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
interface IPosition {
top?: number;
bottom?: number;
left?: number;
right?: number;
}
export enum ChevronFace {
Top = "top",
Bottom = "bottom",
Left = "left",
Right = "right",
None = "none",
}
interface IProps extends IPosition {
menuWidth?: number;
menuHeight?: number;
chevronOffset?: number;
chevronFace?: ChevronFace;
menuPaddingTop?: number;
menuPaddingBottom?: number;
menuPaddingLeft?: number;
menuPaddingRight?: number;
zIndex?: number;
// If true, insert an invisible screen-sized element behind the menu that when clicked will close it.
hasBackground?: boolean;
// whether this context menu should be focus managed. If false it must handle itself
managed?: boolean;
// Function to be called on menu close
onFinished();
// on resize callback
windowResize?();
}
interface IState {
contextMenuElem: HTMLDivElement;
}
// Generic ContextMenu Portal wrapper // Generic ContextMenu Portal wrapper
// all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1} // all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1}
// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines. // this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
export class ContextMenu extends React.Component { export class ContextMenu extends React.PureComponent<IProps, IState> {
static propTypes = { private initialFocus: HTMLElement;
top: PropTypes.number,
bottom: PropTypes.number,
left: PropTypes.number,
right: PropTypes.number,
menuWidth: PropTypes.number,
menuHeight: PropTypes.number,
chevronOffset: PropTypes.number,
chevronFace: PropTypes.string, // top, bottom, left, right or none
// Function to be called on menu close
onFinished: PropTypes.func.isRequired,
menuPaddingTop: PropTypes.number,
menuPaddingRight: PropTypes.number,
menuPaddingBottom: PropTypes.number,
menuPaddingLeft: PropTypes.number,
zIndex: PropTypes.number,
// If true, insert an invisible screen-sized element behind the
// menu that when clicked will close it.
hasBackground: PropTypes.bool,
// on resize callback
windowResize: PropTypes.func,
managed: PropTypes.bool, // whether this context menu should be focus managed. If false it must handle itself
};
static defaultProps = { static defaultProps = {
hasBackground: true, hasBackground: true,
managed: true, managed: true,
}; };
constructor() { constructor(props, context) {
super(); super(props, context);
this.state = { this.state = {
contextMenuElem: null, contextMenuElem: null,
}; };
// persist what had focus when we got initialized so we can return it after // persist what had focus when we got initialized so we can return it after
this.initialFocus = document.activeElement; this.initialFocus = document.activeElement as HTMLElement;
} }
componentWillUnmount() { componentWillUnmount() {
@ -96,7 +113,7 @@ export class ContextMenu extends React.Component {
this.initialFocus.focus(); this.initialFocus.focus();
} }
collectContextMenuRect = (element) => { private collectContextMenuRect = (element) => {
// We don't need to clean up when unmounting, so ignore // We don't need to clean up when unmounting, so ignore
if (!element) return; if (!element) return;
@ -113,7 +130,7 @@ export class ContextMenu extends React.Component {
}); });
}; };
onContextMenu = (e) => { private onContextMenu = (e) => {
if (this.props.onFinished) { if (this.props.onFinished) {
this.props.onFinished(); this.props.onFinished();
@ -136,20 +153,20 @@ export class ContextMenu extends React.Component {
} }
}; };
onContextMenuPreventBubbling = (e) => { private onContextMenuPreventBubbling = (e) => {
// stop propagation so that any context menu handlers don't leak out of this context menu // stop propagation so that any context menu handlers don't leak out of this context menu
// but do not inhibit the default browser menu // but do not inhibit the default browser menu
e.stopPropagation(); e.stopPropagation();
}; };
// Prevent clicks on the background from going through to the component which opened the menu. // Prevent clicks on the background from going through to the component which opened the menu.
_onFinished = (ev: InputEvent) => { private onFinished = (ev: React.MouseEvent) => {
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
if (this.props.onFinished) this.props.onFinished(); if (this.props.onFinished) this.props.onFinished();
}; };
_onMoveFocus = (element, up) => { private onMoveFocus = (element: Element, up: boolean) => {
let descending = false; // are we currently descending or ascending through the DOM tree? let descending = false; // are we currently descending or ascending through the DOM tree?
do { do {
@ -183,25 +200,25 @@ export class ContextMenu extends React.Component {
} while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role"))); } while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role")));
if (element) { if (element) {
element.focus(); (element as HTMLElement).focus();
} }
}; };
_onMoveFocusHomeEnd = (element, up) => { private onMoveFocusHomeEnd = (element: Element, up: boolean) => {
let results = element.querySelectorAll('[role^="menuitem"]'); let results = element.querySelectorAll('[role^="menuitem"]');
if (!results) { if (!results) {
results = element.querySelectorAll('[tab-index]'); results = element.querySelectorAll('[tab-index]');
} }
if (results && results.length) { if (results && results.length) {
if (up) { if (up) {
results[0].focus(); (results[0] as HTMLElement).focus();
} else { } else {
results[results.length - 1].focus(); (results[results.length - 1] as HTMLElement).focus();
} }
} }
}; };
_onKeyDown = (ev) => { private onKeyDown = (ev: React.KeyboardEvent) => {
if (!this.props.managed) { if (!this.props.managed) {
if (ev.key === Key.ESCAPE) { if (ev.key === Key.ESCAPE) {
this.props.onFinished(); this.props.onFinished();
@ -219,16 +236,16 @@ export class ContextMenu extends React.Component {
this.props.onFinished(); this.props.onFinished();
break; break;
case Key.ARROW_UP: case Key.ARROW_UP:
this._onMoveFocus(ev.target, true); this.onMoveFocus(ev.target as Element, true);
break; break;
case Key.ARROW_DOWN: case Key.ARROW_DOWN:
this._onMoveFocus(ev.target, false); this.onMoveFocus(ev.target as Element, false);
break; break;
case Key.HOME: case Key.HOME:
this._onMoveFocusHomeEnd(this.state.contextMenuElem, true); this.onMoveFocusHomeEnd(this.state.contextMenuElem, true);
break; break;
case Key.END: case Key.END:
this._onMoveFocusHomeEnd(this.state.contextMenuElem, false); this.onMoveFocusHomeEnd(this.state.contextMenuElem, false);
break; break;
default: default:
handled = false; handled = false;
@ -241,9 +258,8 @@ export class ContextMenu extends React.Component {
} }
}; };
renderMenu(hasBackground=this.props.hasBackground) { protected renderMenu(hasBackground = this.props.hasBackground) {
const position = {}; const position: Partial<Writeable<DOMRect>> = {};
let chevronFace = null;
const props = this.props; const props = this.props;
if (props.top) { if (props.top) {
@ -252,23 +268,24 @@ export class ContextMenu extends React.Component {
position.bottom = props.bottom; position.bottom = props.bottom;
} }
let chevronFace: ChevronFace;
if (props.left) { if (props.left) {
position.left = props.left; position.left = props.left;
chevronFace = 'left'; chevronFace = ChevronFace.Left;
} else { } else {
position.right = props.right; position.right = props.right;
chevronFace = 'right'; chevronFace = ChevronFace.Right;
} }
const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null; const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null;
const chevronOffset = {}; const chevronOffset: CSSProperties = {};
if (props.chevronFace) { if (props.chevronFace) {
chevronFace = props.chevronFace; chevronFace = props.chevronFace;
} }
const hasChevron = chevronFace && chevronFace !== "none"; const hasChevron = chevronFace && chevronFace !== ChevronFace.None;
if (chevronFace === 'top' || chevronFace === 'bottom') { if (chevronFace === ChevronFace.Top || chevronFace === ChevronFace.Bottom) {
chevronOffset.left = props.chevronOffset; chevronOffset.left = props.chevronOffset;
} else if (position.top !== undefined) { } else if (position.top !== undefined) {
const target = position.top; const target = position.top;
@ -298,13 +315,13 @@ export class ContextMenu extends React.Component {
'mx_ContextualMenu_right': !hasChevron && position.right, 'mx_ContextualMenu_right': !hasChevron && position.right,
'mx_ContextualMenu_top': !hasChevron && position.top, 'mx_ContextualMenu_top': !hasChevron && position.top,
'mx_ContextualMenu_bottom': !hasChevron && position.bottom, 'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
'mx_ContextualMenu_withChevron_left': chevronFace === 'left', 'mx_ContextualMenu_withChevron_left': chevronFace === ChevronFace.Left,
'mx_ContextualMenu_withChevron_right': chevronFace === 'right', 'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
'mx_ContextualMenu_withChevron_top': chevronFace === 'top', 'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom', 'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom,
}); });
const menuStyle = {}; const menuStyle: CSSProperties = {};
if (props.menuWidth) { if (props.menuWidth) {
menuStyle.width = props.menuWidth; menuStyle.width = props.menuWidth;
} }
@ -335,13 +352,28 @@ export class ContextMenu extends React.Component {
let background; let background;
if (hasBackground) { if (hasBackground) {
background = ( background = (
<div className="mx_ContextualMenu_background" style={wrapperStyle} onClick={this._onFinished} onContextMenu={this.onContextMenu} /> <div
className="mx_ContextualMenu_background"
style={wrapperStyle}
onClick={this.onFinished}
onContextMenu={this.onContextMenu}
/>
); );
} }
return ( return (
<div className="mx_ContextualMenu_wrapper" style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown} onContextMenu={this.onContextMenuPreventBubbling}> <div
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} role={this.props.managed ? "menu" : undefined}> className="mx_ContextualMenu_wrapper"
style={{...position, ...wrapperStyle}}
onKeyDown={this.onKeyDown}
onContextMenu={this.onContextMenuPreventBubbling}
>
<div
className={menuClasses}
style={menuStyle}
ref={this.collectContextMenuRect}
role={this.props.managed ? "menu" : undefined}
>
{ chevron } { chevron }
{ props.children } { props.children }
</div> </div>
@ -350,195 +382,13 @@ export class ContextMenu extends React.Component {
); );
} }
render() { render(): React.ReactChild {
return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer()); return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
} }
} }
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuButton = ({ label, isExpanded, children, onClick, onContextMenu, ...props }) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton
{...props}
onClick={onClick}
onContextMenu={onContextMenu || onClick}
title={label}
aria-label={label}
aria-haspopup={true}
aria-expanded={isExpanded}
>
{ children }
</AccessibleButton>
);
};
ContextMenuButton.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string,
isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open
};
// Semantic component for representing a role=menuitem
export const MenuItem = ({children, label, ...props}) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={label}>
{ children }
</AccessibleButton>
);
};
MenuItem.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string, // optional
className: PropTypes.string, // optional
onClick: PropTypes.func.isRequired,
};
// Semantic component for representing a role=group for grouping menu radios/checkboxes
export const MenuGroup = ({children, label, ...props}) => {
return <div {...props} role="group" aria-label={label}>
{ children }
</div>;
};
MenuGroup.propTypes = {
label: PropTypes.string.isRequired,
className: PropTypes.string, // optional
};
// Semantic component for representing a role=menuitemcheckbox
export const MenuItemCheckbox = ({children, label, active=false, disabled=false, ...props}) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton {...props} role="menuitemcheckbox" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
{ children }
</AccessibleButton>
);
};
MenuItemCheckbox.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string, // optional
active: PropTypes.bool.isRequired,
disabled: PropTypes.bool, // optional
className: PropTypes.string, // optional
onClick: PropTypes.func.isRequired,
};
// Semantic component for representing a styled role=menuitemcheckbox
export const StyledMenuItemCheckbox = ({children, label, onChange, onClose, checked, disabled=false, ...props}) => {
const onKeyDown = (e) => {
if (e.key === Key.ENTER || e.key === Key.SPACE) {
e.stopPropagation();
e.preventDefault();
onChange();
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
if (e.key === Key.ENTER) {
onClose();
}
}
};
const onKeyUp = (e) => {
// prevent the input default handler as we handle it on keydown to match
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
if (e.key === Key.SPACE || e.key === Key.ENTER) {
e.stopPropagation();
e.preventDefault();
}
};
return (
<StyledCheckbox
{...props}
role="menuitemcheckbox"
aria-checked={checked}
checked={checked}
aria-disabled={disabled}
tabIndex={-1}
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
{ children }
</StyledCheckbox>
);
};
StyledMenuItemCheckbox.propTypes = {
...StyledCheckbox.propTypes,
label: PropTypes.string, // optional
checked: PropTypes.bool.isRequired,
disabled: PropTypes.bool, // optional
className: PropTypes.string, // optional
onChange: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, // gets called after onChange on Key.ENTER
};
// Semantic component for representing a role=menuitemradio
export const MenuItemRadio = ({children, label, active=false, disabled=false, ...props}) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return (
<AccessibleButton {...props} role="menuitemradio" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
{ children }
</AccessibleButton>
);
};
MenuItemRadio.propTypes = {
...AccessibleButton.propTypes,
label: PropTypes.string, // optional
active: PropTypes.bool.isRequired,
disabled: PropTypes.bool, // optional
className: PropTypes.string, // optional
onClick: PropTypes.func.isRequired,
};
// Semantic component for representing a styled role=menuitemradio
export const StyledMenuItemRadio = ({children, label, onChange, onClose, checked=false, disabled=false, ...props}) => {
const onKeyDown = (e) => {
if (e.key === Key.ENTER || e.key === Key.SPACE) {
e.stopPropagation();
e.preventDefault();
onChange();
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
if (e.key === Key.ENTER) {
onClose();
}
}
};
const onKeyUp = (e) => {
// prevent the input default handler as we handle it on keydown to match
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
if (e.key === Key.SPACE || e.key === Key.ENTER) {
e.stopPropagation();
e.preventDefault();
}
};
return (
<StyledRadioButton
{...props}
role="menuitemradio"
aria-checked={checked}
checked={checked}
aria-disabled={disabled}
tabIndex={-1}
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
>
{ children }
</StyledRadioButton>
);
};
StyledMenuItemRadio.propTypes = {
...StyledMenuItemRadio.propTypes,
label: PropTypes.string, // optional
checked: PropTypes.bool.isRequired,
disabled: PropTypes.bool, // optional
className: PropTypes.string, // optional
onChange: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, // gets called after onChange on Key.ENTER
};
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset // Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
export const toRightOf = (elementRect, chevronOffset=12) => { export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => {
const left = elementRect.right + window.pageXOffset + 3; const left = elementRect.right + window.pageXOffset + 3;
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset; let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
top -= chevronOffset + 8; // where 8 is half the height of the chevron top -= chevronOffset + 8; // where 8 is half the height of the chevron
@ -546,8 +396,8 @@ export const toRightOf = (elementRect, chevronOffset=12) => {
}; };
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect // Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
export const aboveLeftOf = (elementRect, chevronFace="none") => { export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None) => {
const menuOptions = { chevronFace }; const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.pageXOffset; const buttonRight = elementRect.right + window.pageXOffset;
const buttonBottom = elementRect.bottom + window.pageYOffset; const buttonBottom = elementRect.bottom + window.pageYOffset;
@ -605,3 +455,12 @@ export function createMenu(ElementClass, props) {
return {close: onFinished}; return {close: onFinished};
} }
// re-export the semantic helper components for simplicity
export {ContextMenuButton} from "../../accessibility/context_menu/ContextMenuButton";
export {MenuGroup} from "../../accessibility/context_menu/MenuGroup";
export {MenuItem} from "../../accessibility/context_menu/MenuItem";
export {MenuItemCheckbox} from "../../accessibility/context_menu/MenuItemCheckbox";
export {MenuItemRadio} from "../../accessibility/context_menu/MenuItemRadio";
export {StyledMenuItemCheckbox} from "../../accessibility/context_menu/StyledMenuItemCheckbox";
export {StyledMenuItemRadio} from "../../accessibility/context_menu/StyledMenuItemRadio";

View file

@ -21,6 +21,7 @@ import classNames from "classnames";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler"; import { _t } from "../../languageHandler";
import RoomList2 from "../views/rooms/RoomList2"; import RoomList2 from "../views/rooms/RoomList2";
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist2";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import UserMenu from "./UserMenu"; import UserMenu from "./UserMenu";
import RoomSearch from "./RoomSearch"; import RoomSearch from "./RoomSearch";
@ -114,64 +115,131 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
}; };
private handleStickyHeaders(list: HTMLDivElement) { private handleStickyHeaders(list: HTMLDivElement) {
// TODO: Evaluate if this has any performance benefit or detriment.
// See https://github.com/vector-im/riot-web/issues/14035
if (this.isDoingStickyHeaders) return; if (this.isDoingStickyHeaders) return;
this.isDoingStickyHeaders = true; this.isDoingStickyHeaders = true;
if (window.requestAnimationFrame) { window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
this.doStickyHeaders(list);
this.isDoingStickyHeaders = false;
});
} else {
this.doStickyHeaders(list); this.doStickyHeaders(list);
this.isDoingStickyHeaders = false; this.isDoingStickyHeaders = false;
} });
} }
private doStickyHeaders(list: HTMLDivElement) { private doStickyHeaders(list: HTMLDivElement) {
const rlRect = list.getBoundingClientRect(); const topEdge = list.scrollTop;
const bottom = rlRect.bottom; const bottomEdge = list.offsetHeight + list.scrollTop;
const top = rlRect.top;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist2"); const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist2");
const headerHeight = 32; // Note: must match the CSS!
const headerRightMargin = 24; // calculated from margins and widths to align with non-sticky tiles
const 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<HTMLDivElement, {
stickyTop?: boolean;
stickyBottom?: boolean;
makeInvisible?: boolean;
}>();
let gotBottom = false;
let lastTopHeader; let lastTopHeader;
let firstBottomHeader;
for (const sublist of sublists) { for (const sublist of sublists) {
const slRect = sublist.getBoundingClientRect();
const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist2_stickable"); const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist2_stickable");
header.style.removeProperty("display"); // always clear display:none first
if (slRect.top + headerHeight > bottom && !gotBottom) { // When an element is <=40% off screen, make it take over
header.classList.add("mx_RoomSublist2_headerContainer_sticky"); const offScreenFactor = 0.4;
header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom"); const isOffTop = (sublist.offsetTop + (offScreenFactor * HEADER_HEIGHT)) <= topEdge;
header.style.width = `${headerStickyWidth}px`; const isOffBottom = (sublist.offsetTop + (offScreenFactor * HEADER_HEIGHT)) >= bottomEdge;
header.style.removeProperty("top");
gotBottom = true; if (isOffTop || sublist === sublists[0]) {
} else if ((slRect.top - (headerHeight / 3)) < top) { targetStyles.set(header, { stickyTop: true });
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`;
if (lastTopHeader) { if (lastTopHeader) {
lastTopHeader.style.display = "none"; lastTopHeader.style.display = "none";
targetStyles.set(lastTopHeader, { makeInvisible: true });
} }
// first unset it, if set in last iteration
header.style.removeProperty("display");
lastTopHeader = header; lastTopHeader = header;
} else if (isOffBottom && !firstBottomHeader) {
targetStyles.set(header, { stickyBottom: true });
firstBottomHeader = header;
} else { } else {
header.classList.remove("mx_RoomSublist2_headerContainer_sticky"); targetStyles.set(header, {}); // nothing == clear
header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop");
header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom");
header.style.removeProperty("width");
header.style.removeProperty("top");
} }
} }
// 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 // TODO: Improve header reliability: https://github.com/vector-im/riot-web/issues/14232
@ -325,15 +393,17 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
<aside className="mx_LeftPanel2_roomListContainer"> <aside className="mx_LeftPanel2_roomListContainer">
{this.renderHeader()} {this.renderHeader()}
{this.renderSearchExplore()} {this.renderSearchExplore()}
<div <div className="mx_LeftPanel2_roomListWrapper">
className={roomListClasses} <div
onScroll={this.onScroll} className={roomListClasses}
ref={this.listContainerRef} onScroll={this.onScroll}
// Firefox sometimes makes this element focusable due to ref={this.listContainerRef}
// overflow:scroll;, so force it out of tab order. // Firefox sometimes makes this element focusable due to
tabIndex={-1} // overflow:scroll;, so force it out of tab order.
> tabIndex={-1}
{roomList} >
{roomList}
</div>
</div> </div>
</aside> </aside>
</div> </div>

View file

@ -52,6 +52,7 @@ import {
} from "../../toasts/ServerLimitToast"; } from "../../toasts/ServerLimitToast";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import LeftPanel2 from "./LeftPanel2"; import LeftPanel2 from "./LeftPanel2";
import CallContainer from '../views/voip/CallContainer';
import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload"; import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload";
// We need to fetch each pinned message individually (if we don't already have it) // We need to fetch each pinned message individually (if we don't already have it)
@ -696,6 +697,7 @@ class LoggedInView extends React.Component<IProps, IState> {
</div> </div>
</DragDropContext> </DragDropContext>
</div> </div>
<CallContainer />
</MatrixClientContext.Provider> </MatrixClientContext.Provider>
); );
} }

View file

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

View file

@ -2044,6 +2044,7 @@ export default createReactClass({
if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) { if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) {
const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton'); const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton');
jumpToBottom = (<JumpToBottomButton jumpToBottom = (<JumpToBottomButton
highlight={this.state.room.getUnreadNotificationCount('highlight') > 0}
numUnreadMessages={this.state.numUnreadMessages} numUnreadMessages={this.state.numUnreadMessages}
onScrollToBottomClick={this.jumpToLiveTimeline} onScrollToBottomClick={this.jumpToLiveTimeline}
/>); />);

View file

@ -14,14 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import * as React from "react"; import React, { createRef } from "react";
import { MatrixClientPeg } from "../../MatrixClientPeg"; import { MatrixClientPeg } from "../../MatrixClientPeg";
import defaultDispatcher from "../../dispatcher/dispatcher"; import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads"; import { ActionPayload } from "../../dispatcher/payloads";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import { createRef } from "react";
import { _t } from "../../languageHandler"; import { _t } from "../../languageHandler";
import {ContextMenu, ContextMenuButton, MenuItem} from "./ContextMenu"; import { ChevronFace, ContextMenu, ContextMenuButton, MenuItem } from "./ContextMenu";
import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog"; import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog"; import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog";
@ -122,7 +121,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
} }
}; };
private onOpenMenuClick = (ev: InputEvent) => { private onOpenMenuClick = (ev: React.MouseEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
const target = ev.target as HTMLButtonElement; const target = ev.target as HTMLButtonElement;
@ -235,7 +234,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
return ( return (
<ContextMenu <ContextMenu
chevronFace="none" chevronFace={ChevronFace.None}
// -20 to overlap the context menu by just over the width of the `...` icon and make it look connected // -20 to overlap the context menu by just over the width of the `...` icon and make it look connected
left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 20} left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 20}
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height} top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
@ -281,11 +280,11 @@ export default class UserMenu extends React.Component<IProps, IState> {
label={_t("All settings")} label={_t("All settings")}
onClick={(e) => this.onSettingsOpen(e, null)} onClick={(e) => this.onSettingsOpen(e, null)}
/> />
<MenuButton {/* <MenuButton
iconClassName="mx_UserMenu_iconArchive" iconClassName="mx_UserMenu_iconArchive"
label={_t("Archived rooms")} label={_t("Archived rooms")}
onClick={this.onShowArchived} onClick={this.onShowArchived}
/> /> */}
<MenuButton <MenuButton
iconClassName="mx_UserMenu_iconMessage" iconClassName="mx_UserMenu_iconMessage"
label={_t("Feedback")} label={_t("Feedback")}
@ -348,8 +347,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
{name} {name}
{buttons} {buttons}
</div> </div>
{this.renderContextMenu()}
</ContextMenuButton> </ContextMenuButton>
{this.renderContextMenu()}
</React.Fragment> </React.Fragment>
); );
} }

View file

@ -18,7 +18,7 @@ limitations under the License.
*/ */
import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react'; import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react';
import PropTypes from 'prop-types'; import classNames from 'classnames';
import * as AvatarLogic from '../../../Avatar'; import * as AvatarLogic from '../../../Avatar';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
@ -26,9 +26,25 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useEventEmitter} from "../../../hooks/useEventEmitter"; import {useEventEmitter} from "../../../hooks/useEventEmitter";
import {toPx} from "../../../utils/units"; import {toPx} from "../../../utils/units";
const useImageUrl = ({url, urls}) => { interface IProps {
const [imageUrls, setUrls] = useState([]); name: string; // The name (first initial used as default)
const [urlsIndex, setIndex] = useState(); 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<HTMLImageElement & HTMLSpanElement>;
className?: string;
}
const useImageUrl = ({url, urls}): [string, () => void] => {
const [imageUrls, setUrls] = useState<string[]>([]);
const [urlsIndex, setIndex] = useState<number>();
const onError = useCallback(() => { const onError = useCallback(() => {
setIndex(i => i + 1); // try the next one setIndex(i => i + 1); // try the next one
@ -70,17 +86,17 @@ const useImageUrl = ({url, urls}) => {
return [imageUrl, onError]; return [imageUrl, onError];
}; };
const BaseAvatar = (props) => { const BaseAvatar = (props: IProps) => {
const { const {
name, name,
idName, idName,
title, title,
url, url,
urls, urls,
width=40, width = 40,
height=40, height = 40,
resizeMethod="crop", // eslint-disable-line no-unused-vars resizeMethod = "crop", // eslint-disable-line no-unused-vars
defaultToInitialLetter=true, defaultToInitialLetter = true,
onClick, onClick,
inputRef, inputRef,
...otherProps ...otherProps
@ -117,7 +133,7 @@ const BaseAvatar = (props) => {
aria-hidden="true" /> aria-hidden="true" />
); );
if (onClick != null) { if (onClick !== null) {
return ( return (
<AccessibleButton <AccessibleButton
{...otherProps} {...otherProps}
@ -132,7 +148,12 @@ const BaseAvatar = (props) => {
); );
} else { } else {
return ( return (
<span className="mx_BaseAvatar" ref={inputRef} {...otherProps} role="presentation"> <span
className="mx_BaseAvatar"
ref={inputRef}
{...otherProps}
role="presentation"
>
{ textNode } { textNode }
{ imgNode } { imgNode }
</span> </span>
@ -140,7 +161,7 @@ const BaseAvatar = (props) => {
} }
} }
if (onClick != null) { if (onClick !== null) {
return ( return (
<AccessibleButton <AccessibleButton
className="mx_BaseAvatar mx_BaseAvatar_image" className="mx_BaseAvatar mx_BaseAvatar_image"
@ -173,26 +194,5 @@ const BaseAvatar = (props) => {
} }
}; };
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 default BaseAvatar;
export type BaseAvatarType = React.FC<IProps>;

View file

@ -21,8 +21,8 @@ import { TagID } from '../../../stores/room-list/models';
import RoomAvatar from "./RoomAvatar"; import RoomAvatar from "./RoomAvatar";
import RoomTileIcon from "../rooms/RoomTileIcon"; import RoomTileIcon from "../rooms/RoomTileIcon";
import NotificationBadge from '../rooms/NotificationBadge'; import NotificationBadge from '../rooms/NotificationBadge';
import { INotificationState } from "../../../stores/notifications/INotificationState"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState"; import { NotificationState } from "../../../stores/notifications/NotificationState";
interface IProps { interface IProps {
room: Room; room: Room;
@ -33,7 +33,7 @@ interface IProps {
} }
interface IState { interface IState {
notificationState?: INotificationState; notificationState?: NotificationState;
} }
export default class DecoratedRoomAvatar extends React.PureComponent<IProps, IState> { export default class DecoratedRoomAvatar extends React.PureComponent<IProps, IState> {
@ -42,7 +42,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt
super(props); super(props);
this.state = { this.state = {
notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag), notificationState: RoomNotificationStateStore.instance.getRoomState(this.props.room, this.props.tag),
}; };
} }

View file

@ -15,43 +15,36 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import createReactClass from 'create-react-class';
import * as sdk from '../../../index';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import BaseAvatar from './BaseAvatar';
export default createReactClass({ export interface IProps {
displayName: 'GroupAvatar', groupId?: string;
groupName?: string;
groupAvatarUrl?: string;
width?: number;
height?: number;
resizeMethod?: string;
onClick?: React.MouseEventHandler;
}
propTypes: { export default class GroupAvatar extends React.Component<IProps> {
groupId: PropTypes.string, public static defaultProps = {
groupName: PropTypes.string, width: 36,
groupAvatarUrl: PropTypes.string, height: 36,
width: PropTypes.number, resizeMethod: 'crop',
height: PropTypes.number, };
resizeMethod: PropTypes.string,
onClick: PropTypes.func,
},
getDefaultProps: function() { getGroupAvatarUrl() {
return {
width: 36,
height: 36,
resizeMethod: 'crop',
};
},
getGroupAvatarUrl: function() {
return MatrixClientPeg.get().mxcUrlToHttp( return MatrixClientPeg.get().mxcUrlToHttp(
this.props.groupAvatarUrl, this.props.groupAvatarUrl,
this.props.width, this.props.width,
this.props.height, this.props.height,
this.props.resizeMethod, this.props.resizeMethod,
); );
}, }
render: function() { render() {
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
// extract the props we use from props so we can pass any others through // 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? // should consider adding this as a global rule in js-sdk?
/*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/ /*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
@ -65,5 +58,5 @@ export default createReactClass({
{...otherProps} {...otherProps}
/> />
); );
}, }
}); }

View file

@ -16,48 +16,50 @@ limitations under the License.
*/ */
import React from 'react'; 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 dis from "../../../dispatcher/dispatcher";
import {Action} from "../../../dispatcher/actions"; import {Action} from "../../../dispatcher/actions";
import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../MatrixClientPeg";
import BaseAvatar from "./BaseAvatar";
export default createReactClass({ interface IProps {
displayName: 'MemberAvatar', // 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: { interface IState {
member: PropTypes.object, name: string;
fallbackUserId: PropTypes.string, title: string;
width: PropTypes.number, imageUrl?: string;
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,
},
getDefaultProps: function() { export default class MemberAvatar extends React.Component<IProps, IState> {
return { public static defaultProps = {
width: 40, width: 40,
height: 40, height: 40,
resizeMethod: 'crop', resizeMethod: 'crop',
viewUserOnClick: false, viewUserOnClick: false,
}; };
},
getInitialState: function() { constructor(props: IProps) {
return this._getState(this.props); super(props);
},
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event this.state = MemberAvatar.getState(props);
UNSAFE_componentWillReceiveProps: function(nextProps) { }
this.setState(this._getState(nextProps));
},
_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) { if (props.member && props.member.name) {
return { return {
name: props.member.name, name: props.member.name,
@ -79,11 +81,9 @@ export default createReactClass({
} else { } else {
console.error("MemberAvatar called somehow with null member or fallbackUserId"); 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; let {member, fallbackUserId, onClick, viewUserOnClick, ...otherProps} = this.props;
const userId = member ? member.userId : fallbackUserId; const userId = member ? member.userId : fallbackUserId;
@ -100,5 +100,5 @@ export default createReactClass({
<BaseAvatar {...otherProps} name={this.state.name} title={this.state.title} <BaseAvatar {...otherProps} name={this.state.name} title={this.state.title}
idName={userId} url={this.state.imageUrl} onClick={onClick} /> idName={userId} url={this.state.imageUrl} onClick={onClick} />
); );
}, }
}); }

View file

@ -14,13 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { EventEmitter } from "events"; import React from 'react';
import { NotificationColor } from "./NotificationColor";
export const NOTIFICATION_STATE_UPDATE = "update"; interface IProps {
export interface INotificationState extends EventEmitter {
symbol?: string;
count: number;
color: NotificationColor;
} }
const PulsedAvatar: React.FC<IProps> = (props) => {
return <div className="mx_PulsedAvatar">
{props.children}
</div>;
};
export default PulsedAvatar;

View file

@ -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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React from 'react';
import PropTypes from 'prop-types'; import Room from 'matrix-js-sdk/src/models/room';
import createReactClass from 'create-react-class'; import {getHttpUriForMxc} from 'matrix-js-sdk/src/content-repo';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import BaseAvatar from './BaseAvatar';
import ImageView from '../elements/ImageView';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import * as sdk from "../../../index";
import * as Avatar from '../../../Avatar'; 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, // Room may be left unset here, but if it is,
// oobData.avatarUrl should be set (else there // oobData.avatarUrl should be set (else there
// would be nowhere to get the avatar from) // would be nowhere to get the avatar from)
propTypes: { room?: Room;
room: PropTypes.object, // TODO: type when js-sdk has types
oobData: PropTypes.object, oobData?: any;
width: PropTypes.number, width?: number;
height: PropTypes.number, height?: number;
resizeMethod: PropTypes.string, resizeMethod?: string;
viewAvatarOnClick: PropTypes.bool, viewAvatarOnClick?: boolean;
}, }
getDefaultProps: function() { interface IState {
return { urls: string[];
width: 36, }
height: 36,
resizeMethod: 'crop', export default class RoomAvatar extends React.Component<IProps, IState> {
oobData: {}, public static defaultProps = {
width: 36,
height: 36,
resizeMethod: 'crop',
oobData: {},
};
constructor(props: IProps) {
super(props);
this.state = {
urls: RoomAvatar.getImageUrls(this.props),
}; };
}, }
getInitialState: function() { public componentDidMount() {
return {
urls: this.getImageUrls(this.props),
};
},
componentDidMount: function() {
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents); MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
}, }
componentWillUnmount: function() { public componentWillUnmount() {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (cli) { if (cli) {
cli.removeListener("RoomState.events", this.onRoomStateEvents); cli.removeListener("RoomState.events", this.onRoomStateEvents);
} }
}, }
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event public static getDerivedStateFromProps(nextProps: IProps): IState {
UNSAFE_componentWillReceiveProps: function(newProps) { return {
this.setState({ urls: RoomAvatar.getImageUrls(nextProps),
urls: this.getImageUrls(newProps), };
}); }
},
onRoomStateEvents: function(ev) { // TODO: type when js-sdk has types
private onRoomStateEvents = (ev: any) => {
if (!this.props.room || if (!this.props.room ||
ev.getRoomId() !== this.props.room.roomId || ev.getRoomId() !== this.props.room.roomId ||
ev.getType() !== 'm.room.avatar' ev.getType() !== 'm.room.avatar'
) return; ) return;
this.setState({ this.setState({
urls: this.getImageUrls(this.props), urls: RoomAvatar.getImageUrls(this.props),
}); });
}, };
getImageUrls: function(props) { private static getImageUrls(props: IProps): string[] {
return [ return [
getHttpUriForMxc( getHttpUriForMxc(
MatrixClientPeg.get().getHomeserverUrl(), MatrixClientPeg.get().getHomeserverUrl(),
// Default props don't play nicely with getDerivedStateFromProps
//props.oobData !== undefined ? props.oobData.avatarUrl : {},
props.oobData.avatarUrl, props.oobData.avatarUrl,
Math.floor(props.width * window.devicePixelRatio), Math.floor(props.width * window.devicePixelRatio),
Math.floor(props.height * window.devicePixelRatio), Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod, props.resizeMethod,
), // highest priority ), // highest priority
this.getRoomAvatarUrl(props), RoomAvatar.getRoomAvatarUrl(props),
].filter(function(url) { ].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; if (!props.room) return null;
return Avatar.avatarUrlForRoom( return Avatar.avatarUrlForRoom(
@ -105,24 +111,21 @@ export default createReactClass({
Math.floor(props.height * window.devicePixelRatio), Math.floor(props.height * window.devicePixelRatio),
props.resizeMethod, props.resizeMethod,
); );
}, }
onRoomAvatarClick: function() { private onRoomAvatarClick = () => {
const avatarUrl = this.props.room.getAvatarUrl( const avatarUrl = this.props.room.getAvatarUrl(
MatrixClientPeg.get().getHomeserverUrl(), MatrixClientPeg.get().getHomeserverUrl(),
null, null, null, false); null, null, null, false);
const ImageView = sdk.getComponent("elements.ImageView");
const params = { const params = {
src: avatarUrl, src: avatarUrl,
name: this.props.room.name, name: this.props.room.name,
}; };
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
}, };
render: function() {
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
public render() {
/*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/ /*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props; const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props;
@ -132,8 +135,8 @@ export default createReactClass({
<BaseAvatar {...otherProps} name={roomName} <BaseAvatar {...otherProps} name={roomName}
idName={room ? room.roomId : null} idName={room ? room.roomId : null}
urls={this.state.urls} urls={this.state.urls}
onClick={this.props.viewAvatarOnClick ? this.onRoomAvatarClick : null} onClick={this.props.viewAvatarOnClick && !this.state.urls[0] ? this.onRoomAvatarClick : null}
disabled={!this.state.urls[0]} /> />
); );
}, }
}); }

View file

@ -64,7 +64,6 @@ export default function AccessibleButton({
className, className,
...restProps ...restProps
}: IProps) { }: IProps) {
const newProps: IAccessibleButtonProps = restProps; const newProps: IAccessibleButtonProps = restProps;
if (!disabled) { if (!disabled) {
newProps.onClick = onClick; newProps.onClick = onClick;

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
@ -23,6 +23,7 @@ import { _t } from '../../../languageHandler';
import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
import * as sdk from "../../../index"; import * as sdk from "../../../index";
import {MatrixEvent} from "matrix-js-sdk"; import {MatrixEvent} from "matrix-js-sdk";
import {isValid3pidInvite} from "../../../RoomInvite";
export default createReactClass({ export default createReactClass({
displayName: 'MemberEventListSummary', displayName: 'MemberEventListSummary',
@ -284,6 +285,9 @@ export default createReactClass({
_getTransition: function(e) { _getTransition: function(e) {
if (e.mxEvent.getType() === 'm.room.third_party_invite') { if (e.mxEvent.getType() === 'm.room.third_party_invite') {
// Handle 3pid invites the same as invites so they get bundled together // Handle 3pid invites the same as invites so they get bundled together
if (!isValid3pidInvite(e.mxEvent)) {
return 'invite_withdrawal';
}
return 'invited'; return 'invited';
} }

View file

@ -16,13 +16,18 @@ limitations under the License.
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import classNames from 'classnames';
export default (props) => { export default (props) => {
const className = classNames({
'mx_JumpToBottomButton': true,
'mx_JumpToBottomButton_highlight': props.highlight,
});
let badge; let badge;
if (props.numUnreadMessages) { if (props.numUnreadMessages) {
badge = (<div className="mx_JumpToBottomButton_badge">{props.numUnreadMessages}</div>); badge = (<div className="mx_JumpToBottomButton_badge">{props.numUnreadMessages}</div>);
} }
return (<div className="mx_JumpToBottomButton"> return (<div className={className}>
<AccessibleButton className="mx_JumpToBottomButton_scrollDown" <AccessibleButton className="mx_JumpToBottomButton_scrollDown"
title={_t("Scroll to most recent messages")} title={_t("Scroll to most recent messages")}
onClick={props.onScrollToBottomClick}> onClick={props.onScrollToBottomClick}>

View file

@ -22,11 +22,10 @@ import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { readReceiptChangeIsFor } from "../../../utils/read-receipts"; import { readReceiptChangeIsFor } from "../../../utils/read-receipts";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import { XOR } from "../../../@types/common"; import { XOR } from "../../../@types/common";
import { INotificationState, NOTIFICATION_STATE_UPDATE } from "../../../stores/notifications/INotificationState"; import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
interface IProps { interface IProps {
notification: INotificationState; notification: NotificationState;
/** /**
* If true, the badge will show a count if at all possible. This is typically * 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<XOR<IProps, I
const {notification, forceCount, roomId, onClick, ...props} = this.props; const {notification, forceCount, roomId, onClick, ...props} = this.props;
// Don't show a badge if we don't need to // Don't show a badge if we don't need to
if (notification.color <= NotificationColor.None) return null; if (notification.isIdle) return null;
// TODO: Update these booleans for FTUE Notifications: https://github.com/vector-im/riot-web/issues/14261 // TODO: Update these booleans for FTUE Notifications: https://github.com/vector-im/riot-web/issues/14261
// As of writing, that is "if red, show count always" and "optionally show counts instead of dots". // As of writing, that is "if red, show count always" and "optionally show counts instead of dots".
// See git diff for what that boolean state looks like. // See git diff for what that boolean state looks like.
// XXX: We ignore this.state.showCounts (the setting which controls counts vs dots). // XXX: We ignore this.state.showCounts (the setting which controls counts vs dots).
const hasNotif = notification.color >= NotificationColor.Red;
const hasCount = notification.color >= NotificationColor.Grey;
const hasAnySymbol = notification.symbol || notification.count > 0; const hasAnySymbol = notification.symbol || notification.count > 0;
let isEmptyBadge = !hasAnySymbol || !hasCount; let isEmptyBadge = !hasAnySymbol || !notification.hasUnreadCount;
if (forceCount) { if (forceCount) {
isEmptyBadge = false; 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); let symbol = notification.symbol || formatMinimalBadgeCount(notification.count);
@ -117,8 +114,8 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
const classes = classNames({ const classes = classNames({
'mx_NotificationBadge': true, 'mx_NotificationBadge': true,
'mx_NotificationBadge_visible': isEmptyBadge ? true : hasCount, 'mx_NotificationBadge_visible': isEmptyBadge ? true : notification.hasUnreadCount,
'mx_NotificationBadge_highlighted': hasNotif, 'mx_NotificationBadge_highlighted': notification.hasMentions,
'mx_NotificationBadge_dot': isEmptyBadge, 'mx_NotificationBadge_dot': isEmptyBadge,
'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3, 'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3,
'mx_NotificationBadge_3char': symbol.length > 2, 'mx_NotificationBadge_3char': symbol.length > 2,

View file

@ -16,7 +16,6 @@ limitations under the License.
import React from "react"; import React from "react";
import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore"; import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
import AccessibleButton from "../elements/AccessibleButton";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
@ -92,9 +91,6 @@ export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState
}; };
public render(): React.ReactElement { public render(): React.ReactElement {
// TODO: Decorate crumbs with icons: https://github.com/vector-im/riot-web/issues/14040
// TODO: Scrolling: https://github.com/vector-im/riot-web/issues/14040
// TODO: Tooltips: https://github.com/vector-im/riot-web/issues/14040
const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => { const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => {
const roomTags = RoomListStore.instance.getTagsForRoom(r); const roomTags = RoomListStore.instance.getTagsForRoom(r);
const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0]; const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0];

View file

@ -32,15 +32,14 @@ import defaultDispatcher from "../../../dispatcher/dispatcher";
import RoomSublist2 from "./RoomSublist2"; import RoomSublist2 from "./RoomSublist2";
import { ActionPayload } from "../../../dispatcher/payloads"; import { ActionPayload } from "../../../dispatcher/payloads";
import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition"; import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition";
import { ListLayout } from "../../../stores/room-list/ListLayout";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import GroupAvatar from "../avatars/GroupAvatar"; import GroupAvatar from "../avatars/GroupAvatar";
import TemporaryTile from "./TemporaryTile"; import TemporaryTile from "./TemporaryTile";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload"; 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/14231 // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
@ -66,7 +65,6 @@ interface IProps {
interface IState { interface IState {
sublists: ITagMap; sublists: ITagMap;
layouts: Map<TagID, ListLayout>;
} }
const TAG_ORDER: TagID[] = [ const TAG_ORDER: TagID[] = [
@ -151,7 +149,6 @@ export default class RoomList2 extends React.Component<IProps, IState> {
this.state = { this.state = {
sublists: {}, sublists: {},
layouts: new Map<TagID, ListLayout>(),
}; };
this.dispatcherRef = defaultDispatcher.register(this.onAction); this.dispatcherRef = defaultDispatcher.register(this.onAction);
@ -215,14 +212,11 @@ export default class RoomList2 extends React.Component<IProps, IState> {
let listRooms = lists[t]; let listRooms = lists[t];
if (unread) { 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) // filter to only notification rooms (and our current active room so we can index properly)
listRooms = notificationStates.filter(state => { listRooms = listRooms.filter(r => {
return state.room.roomId === roomId || state.color >= NotificationColor.Bold; const state = RoomNotificationStateStore.instance.getRoomState(r, t);
return state.room.roomId === roomId || state.isUnread;
}); });
notificationStates.forEach(state => state.destroy());
} }
rooms.push(...listRooms); rooms.push(...listRooms);
@ -238,12 +232,7 @@ export default class RoomList2 extends React.Component<IProps, IState> {
const newLists = RoomListStore.instance.orderedLists; const newLists = RoomListStore.instance.orderedLists;
console.log("new lists", newLists); console.log("new lists", newLists);
const layoutMap = new Map<TagID, ListLayout>(); this.setState({sublists: newLists}, () => {
for (const tagId of Object.keys(newLists)) {
layoutMap.set(tagId, new ListLayout(tagId));
}
this.setState({sublists: newLists, layouts: layoutMap}, () => {
this.props.onResize(); this.props.onResize();
}); });
}; };
@ -312,8 +301,6 @@ export default class RoomList2 extends React.Component<IProps, IState> {
label={_t(aesthetics.sectionLabel)} label={_t(aesthetics.sectionLabel)}
onAddRoom={onAddRoomFn} onAddRoom={onAddRoomFn}
addRoomLabel={aesthetics.addRoomLabel} addRoomLabel={aesthetics.addRoomLabel}
isInvite={aesthetics.isInvite}
layout={this.state.layouts.get(orderedTagId)}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
onResize={this.props.onResize} onResize={this.props.onResize}
extraBadTilesThatShouldntExist={extraTiles} extraBadTilesThatShouldntExist={extraTiles}

View file

@ -24,9 +24,9 @@ import {RovingAccessibleButton, RovingTabIndexWrapper} from "../../../accessibil
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import AccessibleButton from "../../views/elements/AccessibleButton"; import AccessibleButton from "../../views/elements/AccessibleButton";
import RoomTile2 from "./RoomTile2"; import RoomTile2 from "./RoomTile2";
import { ResizableBox, ResizeCallbackData } from "react-resizable";
import { ListLayout } from "../../../stores/room-list/ListLayout"; import { ListLayout } from "../../../stores/room-list/ListLayout";
import { import {
ChevronFace,
ContextMenu, ContextMenu,
ContextMenuButton, ContextMenuButton,
StyledMenuItemCheckbox, StyledMenuItemCheckbox,
@ -40,7 +40,13 @@ import NotificationBadge from "./NotificationBadge";
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState"; import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import StyledCheckbox from "../elements/StyledCheckbox"; import defaultDispatcher from "../../../dispatcher/dispatcher";
import {ActionPayload} from "../../../dispatcher/payloads";
import { Enable, Resizable } from "re-resizable";
import { Direction } from "re-resizable/lib/resizer";
import { polyfillTouchEvent } from "../../../@types/polyfill";
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/14231 // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
@ -55,9 +61,13 @@ import StyledCheckbox from "../elements/StyledCheckbox";
const SHOW_N_BUTTON_HEIGHT = 32; // As defined by CSS const SHOW_N_BUTTON_HEIGHT = 32; // As defined by CSS
const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS
export const HEADER_HEIGHT = 32; // As defined by CSS
const MAX_PADDING_HEIGHT = SHOW_N_BUTTON_HEIGHT + RESIZE_HANDLE_HEIGHT; const MAX_PADDING_HEIGHT = SHOW_N_BUTTON_HEIGHT + RESIZE_HANDLE_HEIGHT;
// HACK: We really shouldn't have to do this.
polyfillTouchEvent();
interface IProps { interface IProps {
forRooms: boolean; forRooms: boolean;
rooms?: Room[]; rooms?: Room[];
@ -65,8 +75,6 @@ interface IProps {
label: string; label: string;
onAddRoom?: () => void; onAddRoom?: () => void;
addRoomLabel: string; addRoomLabel: string;
isInvite: boolean;
layout?: ListLayout;
isMinimized: boolean; isMinimized: boolean;
tagId: TagID; tagId: TagID;
onResize: () => void; onResize: () => void;
@ -89,16 +97,21 @@ interface IState {
export default class RoomSublist2 extends React.Component<IProps, IState> { export default class RoomSublist2 extends React.Component<IProps, IState> {
private headerButton = createRef<HTMLDivElement>(); private headerButton = createRef<HTMLDivElement>();
private sublistRef = createRef<HTMLDivElement>(); private sublistRef = createRef<HTMLDivElement>();
private dispatcherRef: string;
private layout: ListLayout;
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
this.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId);
this.state = { this.state = {
notificationState: new ListNotificationState(this.props.isInvite, this.props.tagId), notificationState: RoomNotificationStateStore.instance.getListState(this.props.tagId),
contextMenuPosition: null, contextMenuPosition: null,
isResizing: false, isResizing: false,
}; };
this.state.notificationState.setRooms(this.props.rooms); this.state.notificationState.setRooms(this.props.rooms);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
} }
private get numTiles(): number { private get numTiles(): number {
@ -106,8 +119,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
} }
private get numVisibleTiles(): number { private get numVisibleTiles(): number {
if (!this.props.layout) return 0; const nVisible = Math.floor(this.layout.visibleTiles);
const nVisible = Math.floor(this.props.layout.visibleTiles);
return Math.min(nVisible, this.numTiles); return Math.min(nVisible, this.numTiles);
} }
@ -117,17 +129,53 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
public componentWillUnmount() { public componentWillUnmount() {
this.state.notificationState.destroy(); this.state.notificationState.destroy();
defaultDispatcher.unregister(this.dispatcherRef);
} }
private onAction = (payload: ActionPayload) => {
if (payload.action === "view_room" && payload.show_room_tile && this.props.rooms) {
// XXX: we have to do this a tick later because we have incorrect intermediate props during a room change
// where we lose the room we are changing from temporarily and then it comes back in an update right after.
setImmediate(() => {
const isCollapsed = this.layout.isCollapsed;
const roomIndex = this.props.rooms.findIndex((r) => r.roomId === payload.room_id);
if (isCollapsed && roomIndex > -1) {
this.toggleCollapsed();
}
// extend the visible section to include the room if it is entirely invisible
if (roomIndex >= this.numVisibleTiles) {
this.layout.visibleTiles = this.layout.tilesWithPadding(roomIndex + 1, MAX_PADDING_HEIGHT);
this.forceUpdate(); // because the layout doesn't trigger a re-render
}
});
}
};
private onAddRoom = (e) => { private onAddRoom = (e) => {
e.stopPropagation(); e.stopPropagation();
if (this.props.onAddRoom) this.props.onAddRoom(); if (this.props.onAddRoom) this.props.onAddRoom();
}; };
private onResize = (e: React.MouseEvent, data: ResizeCallbackData) => { private onResize = (
const direction = e.movementY < 0 ? -1 : +1; e: MouseEvent | TouchEvent,
const tileDiff = this.props.layout.pixelsToTiles(Math.abs(e.movementY)) * direction; travelDirection: Direction,
this.props.layout.setVisibleTilesWithin(tileDiff, this.numTiles); refToElement: HTMLDivElement,
delta: { width: number, height: number }, // TODO: Use re-resizer's NumberSize when it is exposed as the type
) => {
// Do some sanity checks, but in reality we shouldn't need these.
if (travelDirection !== "bottom") return;
if (delta.height === 0) return; // something went wrong, so just ignore it.
// NOTE: the movement in the MouseEvent (not present on a TouchEvent) is inaccurate
// for our purposes. The delta provided by the library is also a change *from when
// resizing started*, meaning it is fairly useless for us. This is why we just use
// the client height and run with it.
const heightBefore = this.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 this.forceUpdate(); // because the layout doesn't trigger a re-render
}; };
@ -141,13 +189,13 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
private onShowAllClick = () => { private onShowAllClick = () => {
const numVisibleTiles = this.numVisibleTiles; 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 this.forceUpdate(); // because the layout doesn't trigger a re-render
setImmediate(this.focusRoomTile, numVisibleTiles); // focus the tile after the current bottom one setImmediate(this.focusRoomTile, numVisibleTiles); // focus the tile after the current bottom one
}; };
private onShowLessClick = () => { 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 this.forceUpdate(); // because the layout doesn't trigger a re-render
// focus will flow to the show more button here // focus will flow to the show more button here
}; };
@ -161,7 +209,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
} }
}; };
private onOpenMenuClick = (ev: InputEvent) => { private onOpenMenuClick = (ev: React.MouseEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
const target = ev.target as HTMLButtonElement; const target = ev.target as HTMLButtonElement;
@ -195,7 +243,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
}; };
private onMessagePreviewChanged = () => { 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 this.forceUpdate(); // because the layout doesn't trigger a re-render
}; };
@ -233,7 +281,11 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const possibleSticky = target.parentElement; const possibleSticky = target.parentElement;
const sublist = possibleSticky.parentElement.parentElement; const sublist = possibleSticky.parentElement.parentElement;
if (possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky')) { const list = sublist.parentElement.parentElement;
// the scrollTop is capped at the height of the header in LeftPanel2
const isAtTop = list.scrollTop <= HEADER_HEIGHT;
const isSticky = possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky');
if (isSticky && !isAtTop) {
// is sticky - jump to list // is sticky - jump to list
sublist.scrollIntoView({behavior: 'smooth'}); sublist.scrollIntoView({behavior: 'smooth'});
} else { } else {
@ -243,13 +295,13 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
}; };
private toggleCollapsed = () => { private toggleCollapsed = () => {
this.props.layout.isCollapsed = !this.props.layout.isCollapsed; this.layout.isCollapsed = !this.layout.isCollapsed;
this.forceUpdate(); // because the layout doesn't trigger an update this.forceUpdate(); // because the layout doesn't trigger an update
setImmediate(() => this.props.onResize()); // needs to happen when the DOM is updated setImmediate(() => this.props.onResize()); // needs to happen when the DOM is updated
}; };
private onHeaderKeyDown = (ev: React.KeyboardEvent) => { private onHeaderKeyDown = (ev: React.KeyboardEvent) => {
const isCollapsed = this.props.layout && this.props.layout.isCollapsed; const isCollapsed = this.layout && this.layout.isCollapsed;
switch (ev.key) { switch (ev.key) {
case Key.ARROW_LEFT: case Key.ARROW_LEFT:
ev.stopPropagation(); ev.stopPropagation();
@ -289,7 +341,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
}; };
private renderVisibleTiles(): React.ReactElement[] { private renderVisibleTiles(): React.ReactElement[] {
if (this.props.layout && this.props.layout.isCollapsed) { if (this.layout && this.layout.isCollapsed) {
// don't waste time on rendering // don't waste time on rendering
return []; return [];
} }
@ -303,7 +355,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
<RoomTile2 <RoomTile2
room={room} room={room}
key={`room-${room.roomId}`} key={`room-${room.roomId}`}
showMessagePreview={this.props.layout.showPreviews} showMessagePreview={this.layout.showPreviews}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
tag={this.props.tagId} tag={this.props.tagId}
/> />
@ -354,7 +406,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
<StyledMenuItemCheckbox <StyledMenuItemCheckbox
onClose={this.onCloseMenu} onClose={this.onCloseMenu}
onChange={this.onMessagePreviewChanged} onChange={this.onMessagePreviewChanged}
checked={this.props.layout.showPreviews} checked={this.layout.showPreviews}
> >
{_t("Message preview")} {_t("Message preview")}
</StyledMenuItemCheckbox> </StyledMenuItemCheckbox>
@ -365,7 +417,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
contextMenu = ( contextMenu = (
<ContextMenu <ContextMenu
chevronFace="none" chevronFace={ChevronFace.None}
left={this.state.contextMenuPosition.left} left={this.state.contextMenuPosition.left}
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height} top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
onFinished={this.onCloseMenu} onFinished={this.onCloseMenu}
@ -446,7 +498,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const collapseClasses = classNames({ const collapseClasses = classNames({
'mx_RoomSublist2_collapseBtn': true, 'mx_RoomSublist2_collapseBtn': true,
'mx_RoomSublist2_collapseBtn_collapsed': this.props.layout && this.props.layout.isCollapsed, 'mx_RoomSublist2_collapseBtn_collapsed': this.layout && this.layout.isCollapsed,
}); });
const classes = classNames({ const classes = classNames({
@ -474,7 +526,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
tabIndex={tabIndex} tabIndex={tabIndex}
className="mx_RoomSublist2_headerText" className="mx_RoomSublist2_headerText"
role="treeitem" role="treeitem"
aria-expanded={!this.props.layout || !this.props.layout.isCollapsed} aria-expanded={!this.layout.isCollapsed}
aria-level={1} aria-level={1}
onClick={this.onHeaderClick} onClick={this.onHeaderClick}
onContextMenu={this.onContextMenu} onContextMenu={this.onContextMenu}
@ -508,12 +560,10 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
let content = null; let content = null;
if (visibleTiles.length > 0) { 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({ const showMoreBtnClasses = classNames({
'mx_RoomSublist2_showNButton': true, '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 // If we're hiding rooms, show a 'show more' button to the user. This button
@ -537,7 +587,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
{showMoreText} {showMoreText}
</RovingAccessibleButton> </RovingAccessibleButton>
); );
} 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 // we have all tiles visible - add a button to show less
let showLessText = ( let showLessText = (
<span className='mx_RoomSublist2_showNButtonText'> <span className='mx_RoomSublist2_showNButtonText'>
@ -556,9 +606,19 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
} }
// Figure out if we need a handle // Figure out if we need a handle
let handles = ['s']; const handles: Enable = {
bottom: true, // the only one we need, but the others must be explicitly false
bottomLeft: false,
bottomRight: false,
left: false,
right: false,
top: false,
topLeft: false,
topRight: false,
};
if (layout.visibleTiles >= this.numTiles && this.numTiles <= layout.minVisibleTiles) { if (layout.visibleTiles >= this.numTiles && this.numTiles <= layout.minVisibleTiles) {
handles = []; // no handles, we're at a minimum // we're at a minimum, don't have a bottom handle
handles.bottom = false;
} }
// We have to account for padding so we can accommodate a 'show more' button and // We have to account for padding so we can accommodate a 'show more' button and
@ -582,22 +642,33 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const tilesWithoutPadding = Math.min(relativeTiles, layout.visibleTiles); const tilesWithoutPadding = Math.min(relativeTiles, layout.visibleTiles);
const tilesPx = layout.calculateTilesToPixelsMin(relativeTiles, tilesWithoutPadding, padding); const tilesPx = layout.calculateTilesToPixelsMin(relativeTiles, tilesWithoutPadding, padding);
// 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 = <div className="mx_RoomSublist2_placeholder" key='placeholder' />;
visibleTiles.splice(visibleUnpaddedTiles, 1, placeholder);
}
const dimensions = {
height: tilesPx,
};
content = ( content = (
<ResizableBox <Resizable
width={-1} size={dimensions as any}
height={tilesPx} minHeight={minTilesPx}
axis="y" maxHeight={maxTilesPx}
minConstraints={[-1, minTilesPx]}
maxConstraints={[-1, maxTilesPx]}
resizeHandles={handles}
onResize={this.onResize}
className="mx_RoomSublist2_resizeBox"
onResizeStart={this.onResizeStart} onResizeStart={this.onResizeStart}
onResizeStop={this.onResizeStop} onResizeStop={this.onResizeStop}
onResize={this.onResize}
handleWrapperClass="mx_RoomSublist2_resizerHandles"
handleClasses={{bottom: "mx_RoomSublist2_resizerHandle"}}
className="mx_RoomSublist2_resizeBox"
enable={handles}
> >
{visibleTiles} {visibleTiles}
{showNButton} {showNButton}
</ResizableBox> </Resizable>
); );
} }

View file

@ -17,7 +17,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, {createRef} from "react";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import classNames from "classnames"; import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
@ -27,6 +27,7 @@ import { Key } from "../../../Keyboard";
import ActiveRoomObserver from "../../../ActiveRoomObserver"; import ActiveRoomObserver from "../../../ActiveRoomObserver";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { import {
ChevronFace,
ContextMenu, ContextMenu,
ContextMenuButton, ContextMenuButton,
MenuItemRadio, MenuItemRadio,
@ -45,11 +46,14 @@ import {
MUTE, MUTE,
} from "../../../RoomNotifs"; } from "../../../RoomNotifs";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState";
import { INotificationState } from "../../../stores/notifications/INotificationState";
import NotificationBadge from "./NotificationBadge"; import NotificationBadge from "./NotificationBadge";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { Volume } from "../../../RoomNotifsTypes"; 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/14231 // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
@ -75,7 +79,7 @@ type PartialDOMRect = Pick<DOMRect, "left" | "bottom">;
interface IState { interface IState {
hover: boolean; hover: boolean;
notificationState: INotificationState; notificationState: NotificationState;
selected: boolean; selected: boolean;
notificationsMenuPosition: PartialDOMRect; notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: 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 // align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.pageXOffset - 9; const left = elementRect.left + window.pageXOffset - 9;
const top = elementRect.bottom + window.pageYOffset + 17; const top = elementRect.bottom + window.pageYOffset + 17;
const chevronFace = "none"; const chevronFace = ChevronFace.None;
return {left, top, chevronFace}; return {left, top, chevronFace};
}; };
@ -118,18 +122,23 @@ const NotifOption: React.FC<INotifOptionProps> = ({active, onClick, iconClassNam
}; };
export default class RoomTile2 extends React.Component<IProps, IState> { export default class RoomTile2 extends React.Component<IProps, IState> {
private dispatcherRef: string;
private roomTileRef = createRef<HTMLDivElement>();
// TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
this.state = { this.state = {
hover: false, 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, selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
notificationsMenuPosition: null, notificationsMenuPosition: null,
generalMenuPosition: null, generalMenuPosition: null,
}; };
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate); ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
} }
private get showContextMenu(): boolean { private get showContextMenu(): boolean {
@ -140,12 +149,36 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
return !this.props.isMinimized && this.props.showMessagePreview; return !this.props.isMinimized && this.props.showMessagePreview;
} }
public componentDidMount() {
// when we're first rendered (or our sublist is expanded) make sure we are visible if we're active
if (this.state.selected) {
this.scrollIntoView();
}
}
public componentWillUnmount() { public componentWillUnmount() {
if (this.props.room) { if (this.props.room) {
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate); ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
} }
defaultDispatcher.unregister(this.dispatcherRef);
} }
private onAction = (payload: ActionPayload) => {
if (payload.action === "view_room" && payload.room_id === this.props.room.roomId && payload.show_room_tile) {
setImmediate(() => {
this.scrollIntoView();
});
}
};
private scrollIntoView = () => {
if (!this.roomTileRef.current) return;
this.roomTileRef.current.scrollIntoView({
block: "nearest",
behavior: "auto",
});
};
private onTileMouseEnter = () => { private onTileMouseEnter = () => {
this.setState({hover: true}); this.setState({hover: true});
}; };
@ -159,7 +192,6 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
ev.stopPropagation(); ev.stopPropagation();
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
// TODO: Support show_room_tile in new room list: https://github.com/vector-im/riot-web/issues/14233
show_room_tile: true, // make sure the room is visible in the list show_room_tile: true, // make sure the room is visible in the list
room_id: this.props.room.roomId, room_id: this.props.room.roomId,
clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)), clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)),
@ -170,7 +202,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
this.setState({selected: isActive}); this.setState({selected: isActive});
}; };
private onNotificationsMenuOpenClick = (ev: InputEvent) => { private onNotificationsMenuOpenClick = (ev: React.MouseEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
const target = ev.target as HTMLButtonElement; const target = ev.target as HTMLButtonElement;
@ -181,7 +213,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
this.setState({notificationsMenuPosition: null}); this.setState({notificationsMenuPosition: null});
}; };
private onGeneralMenuOpenClick = (ev: InputEvent) => { private onGeneralMenuOpenClick = (ev: React.MouseEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
const target = ev.target as HTMLButtonElement; const target = ev.target as HTMLButtonElement;
@ -210,8 +242,22 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
// TODO: Support tagging: https://github.com/vector-im/riot-web/issues/14211 if (tagId === DefaultTagID.Favourite) {
// TODO: XOR favourites and low priority: https://github.com/vector-im/riot-web/issues/14210 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) { if ((ev as React.KeyboardEvent).key === Key.ENTER) {
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
@ -343,6 +389,13 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
// TODO: We could do with a proper invite context menu, unlike what showContextMenu suggests // 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; let contextMenu = null;
if (this.state.generalMenuPosition) { if (this.state.generalMenuPosition) {
contextMenu = ( contextMenu = (
@ -350,12 +403,13 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu"> <div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu">
<div className="mx_IconizedContextMenu_optionList"> <div className="mx_IconizedContextMenu_optionList">
<MenuItemCheckbox <MenuItemCheckbox
className={favouriteLabelClassName}
onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)} onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}
active={false} // TODO: https://github.com/vector-im/riot-web/issues/14283 active={isFavorite}
label={_t("Favourite")} label={favouriteLabel}
> >
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" /> <span className={classNames("mx_IconizedContextMenu_icon", favouriteIconClassName)} />
<span className="mx_IconizedContextMenu_label">{_t("Favourite")}</span> <span className="mx_IconizedContextMenu_label">{favouriteLabel}</span>
</MenuItemCheckbox> </MenuItemCheckbox>
<MenuItem onClick={this.onOpenRoomSettings} label={_t("Settings")}> <MenuItem onClick={this.onOpenRoomSettings} label={_t("Settings")}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" /> <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" />
@ -437,11 +491,10 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
} }
} }
const notificationColor = this.state.notificationState.color;
const nameClasses = classNames({ const nameClasses = classNames({
"mx_RoomTile2_name": true, "mx_RoomTile2_name": true,
"mx_RoomTile2_nameWithPreview": !!messagePreview, "mx_RoomTile2_nameWithPreview": !!messagePreview,
"mx_RoomTile2_nameHasUnreadEvents": notificationColor >= NotificationColor.Bold, "mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.isUnread,
}); });
let nameContainer = ( let nameContainer = (
@ -458,15 +511,15 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
// The following labels are written in such a fashion to increase screen reader efficiency (speed). // The following labels are written in such a fashion to increase screen reader efficiency (speed).
if (this.props.tag === DefaultTagID.Invite) { if (this.props.tag === DefaultTagID.Invite) {
// append nothing // append nothing
} else if (notificationColor >= NotificationColor.Red) { } else if (this.state.notificationState.hasMentions) {
ariaLabel += " " + _t("%(count)s unread messages including mentions.", { ariaLabel += " " + _t("%(count)s unread messages including mentions.", {
count: this.state.notificationState.count, count: this.state.notificationState.count,
}); });
} else if (notificationColor >= NotificationColor.Grey) { } else if (this.state.notificationState.hasUnreadCount) {
ariaLabel += " " + _t("%(count)s unread messages.", { ariaLabel += " " + _t("%(count)s unread messages.", {
count: this.state.notificationState.count, count: this.state.notificationState.count,
}); });
} else if (notificationColor >= NotificationColor.Bold) { } else if (this.state.notificationState.isUnread) {
ariaLabel += " " + _t("Unread messages."); ariaLabel += " " + _t("Unread messages.");
} }
@ -477,7 +530,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
return ( return (
<React.Fragment> <React.Fragment>
<RovingTabIndexWrapper> <RovingTabIndexWrapper inputRef={this.roomTileRef}>
{({onFocus, isActive, ref}) => {({onFocus, isActive, ref}) =>
<AccessibleButton <AccessibleButton
onFocus={onFocus} onFocus={onFocus}

View file

@ -18,16 +18,15 @@ import React from "react";
import classNames from "classnames"; import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton from "../../views/elements/AccessibleButton"; import AccessibleButton from "../../views/elements/AccessibleButton";
import { INotificationState } from "../../../stores/notifications/INotificationState";
import NotificationBadge from "./NotificationBadge"; import NotificationBadge from "./NotificationBadge";
import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import { NotificationState } from "../../../stores/notifications/NotificationState";
interface IProps { interface IProps {
isMinimized: boolean; isMinimized: boolean;
isSelected: boolean; isSelected: boolean;
displayName: string; displayName: string;
avatar: React.ReactElement; avatar: React.ReactElement;
notificationState: INotificationState; notificationState: NotificationState;
onClick: () => void; onClick: () => void;
} }
@ -74,7 +73,7 @@ export default class TemporaryTile extends React.Component<IProps, IState> {
const nameClasses = classNames({ const nameClasses = classNames({
"mx_RoomTile2_name": true, "mx_RoomTile2_name": true,
"mx_RoomTile2_nameHasUnreadEvents": this.props.notificationState.color >= NotificationColor.Bold, "mx_RoomTile2_nameHasUnreadEvents": this.props.notificationState.isUnread,
}); });
let nameContainer = ( let nameContainer = (

View file

@ -22,6 +22,10 @@ import * as sdk from "../../../../..";
import AccessibleButton from "../../../elements/AccessibleButton"; import AccessibleButton from "../../../elements/AccessibleButton";
import Modal from "../../../../../Modal"; import Modal from "../../../../../Modal";
import dis from "../../../../../dispatcher/dispatcher"; 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 { export default class AdvancedRoomSettingsTab extends React.Component {
static propTypes = { static propTypes = {
@ -29,12 +33,16 @@ export default class AdvancedRoomSettingsTab extends React.Component {
closeSettingsFn: PropTypes.func.isRequired, closeSettingsFn: PropTypes.func.isRequired,
}; };
constructor() { constructor(props) {
super(); super(props);
const room = MatrixClientPeg.get().getRoom(props.roomId);
const roomTags = RoomListStore.instance.getTagsForRoom(room);
this.state = { this.state = {
// This is eventually set to the value of room.getRecommendedVersion() // This is eventually set to the value of room.getRecommendedVersion()
upgradeRecommendation: null, upgradeRecommendation: null,
isLowPriorityRoom: roomTags.includes(DefaultTagID.LowPriority),
}; };
} }
@ -86,6 +94,25 @@ export default class AdvancedRoomSettingsTab extends React.Component {
this.props.closeSettingsFn(); 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() { render() {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.roomId); const room = client.getRoom(this.props.roomId);
@ -156,6 +183,17 @@ export default class AdvancedRoomSettingsTab extends React.Component {
{_t("Open Devtools")} {_t("Open Devtools")}
</AccessibleButton> </AccessibleButton>
</div> </div>
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<span className='mx_SettingsTab_subheading'>{_t('Make this room low priority')}</span>
<LabelledToggleSwitch
value={this.state.isLowPriorityRoom}
onChange={this._onToggleLowPriorityTag}
label={_t(
"Low priority rooms show up at the bottom of your room list" +
" in a dedicated section at the bottom of your room list",
)}
/>
</div>
</div> </div>
); );
} }

View file

@ -402,6 +402,12 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
useCheckbox={true} useCheckbox={true}
disabled={this.state.useIRCLayout} disabled={this.state.useIRCLayout}
/> />
<SettingsFlag
name="useIRCLayout"
level={SettingLevel.DEVICE}
useCheckbox={true}
onChange={(checked) => this.setState({useIRCLayout: checked})}
/>
<SettingsFlag <SettingsFlag
name="useSystemFont" name="useSystemFont"
level={SettingLevel.DEVICE} level={SettingLevel.DEVICE}
@ -440,7 +446,6 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
</div> </div>
{this.renderThemeSection()} {this.renderThemeSection()}
{SettingsStore.isFeatureEnabled("feature_font_scaling") ? this.renderFontSection() : null} {SettingsStore.isFeatureEnabled("feature_font_scaling") ? this.renderFontSection() : null}
{SettingsStore.isFeatureEnabled("feature_irc_ui") ? this.renderLayoutSection() : null}
{this.renderAdvancedSection()} {this.renderAdvancedSection()}
</div> </div>
); );

View file

@ -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<IProps, IState> {
public render() {
return <div className="mx_CallContainer">
<IncomingCallBox2 />
<CallPreview ConferenceHandler={VectorConferenceHandler} />
</div>;
}
}

View file

@ -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<IProps, IState> {
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 (
<CallView
className="mx_CallPreview" onClick={this.onCallViewClick}
ConferenceHandler={this.props.ConferenceHandler}
showHangup={true}
/>
);
}
return <PersistentApp />;
}
return null;
}
}

View file

@ -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<IProps, IState> {
private videoref: React.RefObject<any>;
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 <audio/> element.
// rather than being rendered by the main remoteVideo <video/> element.
call.setRemoteAudioElement(this.getVideoView().getRemoteAudioElement());
}
if (call && call.type === "video" && call.call_state !== "ended" && call.call_state !== "ringing") {
// if this call is a conf call, don't display local video as the
// conference will have us in it
this.getVideoView().getLocalVideoElement().style.display = (
call.confUserId ? "none" : "block"
);
this.getVideoView().getRemoteVideoElement().style.display = "block";
} else {
this.getVideoView().getLocalVideoElement().style.display = "none";
this.getVideoView().getRemoteVideoElement().style.display = "none";
dis.dispatch({action: 'video_fullscreen', fullscreen: false});
}
if (this.props.onResize) {
this.props.onResize();
}
}
private getVideoView() {
return this.videoref.current;
}
public render() {
let view: React.ReactNode;
if (this.state.call && this.state.call.type === "voice") {
const client = MatrixClientPeg.get();
const callRoom = client.getRoom(this.state.call.roomId);
view = <AccessibleButton className="mx_CallView2_voice" onClick={this.props.onClick}>
<PulsedAvatar>
<RoomAvatar
room={callRoom}
height={35}
width={35}
/>
</PulsedAvatar>
<div>
<h1>{callRoom.name}</h1>
<p>{ _t("Active call") }</p>
</div>
</AccessibleButton>;
} else {
view = <VideoView
ref={this.videoref}
onClick={this.props.onClick}
onResize={this.props.onResize}
maxHeight={this.props.maxVideoHeight}
/>;
}
let hangup: React.ReactNode;
if (this.props.showHangup) {
hangup = <div
className="mx_CallView2_hangup"
onClick={() => {
dis.dispatch({
action: 'hangup',
room_id: this.state.call.roomId,
});
}}
/>;
}
return <div className={this.props.className}>
{view}
{hangup}
</div>;
}
}

View file

@ -0,0 +1,141 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
import React from 'react';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher';
import { _t } from '../../../languageHandler';
import { ActionPayload } from '../../../dispatcher/payloads';
import CallHandler from '../../../CallHandler';
import PulsedAvatar from '../avatars/PulsedAvatar';
import RoomAvatar from '../avatars/RoomAvatar';
import FormButton from '../elements/FormButton';
interface IProps {
}
interface IState {
incomingCall: any;
}
export default class IncomingCallBox2 extends React.Component<IProps, IState> {
private dispatcherRef: string;
constructor(props: IProps) {
super(props);
this.dispatcherRef = dis.register(this.onAction);
this.state = {
incomingCall: null,
};
}
public componentWillUnmount() {
dis.unregister(this.dispatcherRef);
}
private onAction = (payload: ActionPayload) => {
switch (payload.action) {
case 'call_state':
const call = CallHandler.getCall(payload.room_id);
if (call && call.call_state === 'ringing') {
this.setState({
incomingCall: call,
});
} else {
this.setState({
incomingCall: null,
});
}
}
};
private onAnswerClick: React.MouseEventHandler = (e) => {
e.stopPropagation();
dis.dispatch({
action: 'answer',
room_id: this.state.incomingCall.roomId,
});
};
private onRejectClick: React.MouseEventHandler = (e) => {
e.stopPropagation();
dis.dispatch({
action: 'hangup',
room_id: this.state.incomingCall.roomId,
});
};
public render() {
if (!this.state.incomingCall) {
return null;
}
let room = null;
if (this.state.incomingCall) {
room = MatrixClientPeg.get().getRoom(this.state.incomingCall.roomId);
}
const caller = room ? room.name : _t("Unknown caller");
let incomingCallText = null;
if (this.state.incomingCall) {
if (this.state.incomingCall.type === "voice") {
incomingCallText = _t("Incoming voice call");
} else if (this.state.incomingCall.type === "video") {
incomingCallText = _t("Incoming video call");
} else {
incomingCallText = _t("Incoming call");
}
}
return <div className="mx_IncomingCallBox2">
<div className="mx_IncomingCallBox2_CallerInfo">
<PulsedAvatar>
<RoomAvatar
room={room}
height={32}
width={32}
/>
</PulsedAvatar>
<div>
<h1>{caller}</h1>
<p>{incomingCallText}</p>
</div>
</div>
<div className="mx_IncomingCallBox2_buttons">
<FormButton
className={"mx_IncomingCallBox2_decline"}
onClick={this.onRejectClick}
kind="danger"
label={_t("Decline")}
/>
<div className="mx_IncomingCallBox2_spacer" />
<FormButton
className={"mx_IncomingCallBox2_accept"}
onClick={this.onAnswerClick}
kind="primary"
label={_t("Accept")}
/>
</div>
</div>;
}
}

View file

@ -489,7 +489,6 @@
"Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)", "Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)",
"Use the improved room list (will refresh to apply changes)": "Use the improved room list (will refresh to apply changes)", "Use the improved room list (will refresh to apply changes)": "Use the improved room list (will refresh to apply changes)",
"Support adding custom themes": "Support adding custom themes", "Support adding custom themes": "Support adding custom themes",
"Enable IRC layout option in the appearance tab": "Enable IRC layout option in the appearance tab",
"Show info about bridges in room settings": "Show info about bridges in room settings", "Show info about bridges in room settings": "Show info about bridges in room settings",
"Font size": "Font size", "Font size": "Font size",
"Use custom size": "Use custom size", "Use custom size": "Use custom size",
@ -539,7 +538,7 @@
"How fast should messages be downloaded.": "How fast should messages be downloaded.", "How fast should messages be downloaded.": "How fast should messages be downloaded.",
"Manually verify all remote sessions": "Manually verify all remote sessions", "Manually verify all remote sessions": "Manually verify all remote sessions",
"IRC display name width": "IRC display name width", "IRC display name width": "IRC display name width",
"Use IRC layout": "Use IRC layout", "Enable experimental, compact IRC style layout": "Enable experimental, compact IRC style layout",
"Collecting app version information": "Collecting app version information", "Collecting app version information": "Collecting app version information",
"Collecting logs": "Collecting logs", "Collecting logs": "Collecting logs",
"Uploading report": "Uploading report", "Uploading report": "Uploading report",
@ -558,12 +557,17 @@
"My Ban List": "My Ban List", "My Ban List": "My Ban List",
"This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!", "This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!",
"Active call (%(roomName)s)": "Active call (%(roomName)s)", "Active call (%(roomName)s)": "Active call (%(roomName)s)",
"Active call": "Active call",
"unknown caller": "unknown caller", "unknown caller": "unknown caller",
"Incoming voice call from %(name)s": "Incoming voice call from %(name)s", "Incoming voice call from %(name)s": "Incoming voice call from %(name)s",
"Incoming video call from %(name)s": "Incoming video call from %(name)s", "Incoming video call from %(name)s": "Incoming video call from %(name)s",
"Incoming call from %(name)s": "Incoming call from %(name)s", "Incoming call from %(name)s": "Incoming call from %(name)s",
"Decline": "Decline", "Decline": "Decline",
"Accept": "Accept", "Accept": "Accept",
"Unknown caller": "Unknown caller",
"Incoming voice call": "Incoming voice call",
"Incoming video call": "Incoming video call",
"Incoming call": "Incoming call",
"The other party cancelled the verification.": "The other party cancelled the verification.", "The other party cancelled the verification.": "The other party cancelled the verification.",
"Verified!": "Verified!", "Verified!": "Verified!",
"You've successfully verified this user.": "You've successfully verified this user.", "You've successfully verified this user.": "You've successfully verified this user.",
@ -966,6 +970,8 @@
"Room version:": "Room version:", "Room version:": "Room version:",
"Developer options": "Developer options", "Developer options": "Developer options",
"Open Devtools": "Open Devtools", "Open Devtools": "Open Devtools",
"Make this room low priority": "Make this room low priority",
"Low priority rooms show up at the bottom of your room list in a dedicated section at the bottom of your room list": "Low priority rooms show up at the bottom of your room list in a dedicated section at the bottom of your room list",
"This room is bridging messages to the following platforms. <a>Learn more.</a>": "This room is bridging messages to the following platforms. <a>Learn more.</a>", "This room is bridging messages to the following platforms. <a>Learn more.</a>": "This room is bridging messages to the following platforms. <a>Learn more.</a>",
"This room isnt bridging messages to any platforms. <a>Learn more.</a>": "This room isnt bridging messages to any platforms. <a>Learn more.</a>", "This room isnt bridging messages to any platforms. <a>Learn more.</a>": "This room isnt bridging messages to any platforms. <a>Learn more.</a>",
"Bridges": "Bridges", "Bridges": "Bridges",
@ -1224,6 +1230,7 @@
"All messages": "All messages", "All messages": "All messages",
"Mentions & Keywords": "Mentions & Keywords", "Mentions & Keywords": "Mentions & Keywords",
"Notification options": "Notification options", "Notification options": "Notification options",
"Favourited": "Favourited",
"Favourite": "Favourite", "Favourite": "Favourite",
"Leave Room": "Leave Room", "Leave Room": "Leave Room",
"Room options": "Room options", "Room options": "Room options",
@ -2102,7 +2109,6 @@
"%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.|one": "<resendText>Resend message</resendText> or <cancelText>cancel message</cancelText> now.", "%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.|one": "<resendText>Resend message</resendText> or <cancelText>cancel message</cancelText> now.",
"Connectivity to the server has been lost.": "Connectivity to the server has been lost.", "Connectivity to the server has been lost.": "Connectivity to the server has been lost.",
"Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.", "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.",
"Active call": "Active call",
"There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?", "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?",
"You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?", "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?",
"You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?", "You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?",
@ -2130,7 +2136,6 @@
"Switch theme": "Switch theme", "Switch theme": "Switch theme",
"Security & privacy": "Security & privacy", "Security & privacy": "Security & privacy",
"All settings": "All settings", "All settings": "All settings",
"Archived rooms": "Archived rooms",
"Feedback": "Feedback", "Feedback": "Feedback",
"User menu": "User menu", "User menu": "User menu",
"Could not load user profile": "Could not load user profile", "Could not load user profile": "Could not load user profile",

View file

@ -159,12 +159,6 @@ export const SETTINGS = {
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
default: false, default: false,
}, },
"feature_irc_ui": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Enable IRC layout option in the appearance tab'),
default: false,
isFeature: true,
},
"mjolnirRooms": { "mjolnirRooms": {
supportedLevels: ['account'], supportedLevels: ['account'],
default: [], default: [],
@ -574,7 +568,7 @@ export const SETTINGS = {
}, },
"useIRCLayout": { "useIRCLayout": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("Use IRC layout"), displayName: _td("Enable experimental, compact IRC style layout"),
default: false, default: false,
}, },
}; };

View file

@ -125,6 +125,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
} }
private async appendRoom(room: Room) { private async appendRoom(room: Room) {
let updated = false;
const rooms = (this.state.rooms || []).slice(); // cheap clone const rooms = (this.state.rooms || []).slice(); // cheap clone
// If the room is upgraded, use that room instead. We'll also splice out // If the room is upgraded, use that room instead. We'll also splice out
@ -136,30 +137,42 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
// Take out any room that isn't the most recent room // Take out any room that isn't the most recent room
for (let i = 0; i < history.length - 1; i++) { for (let i = 0; i < history.length - 1; i++) {
const idx = rooms.findIndex(r => r.roomId === history[i].roomId); const idx = rooms.findIndex(r => r.roomId === history[i].roomId);
if (idx !== -1) rooms.splice(idx, 1); if (idx !== -1) {
rooms.splice(idx, 1);
updated = true;
}
} }
} }
// Remove the existing room, if it is present // Remove the existing room, if it is present
const existingIdx = rooms.findIndex(r => r.roomId === room.roomId); const existingIdx = rooms.findIndex(r => r.roomId === room.roomId);
if (existingIdx !== -1) {
rooms.splice(existingIdx, 1);
}
// Splice the room to the start of the list // If we're focusing on the first room no-op
rooms.splice(0, 0, room); if (existingIdx !== 0) {
if (existingIdx !== -1) {
rooms.splice(existingIdx, 1);
}
// Splice the room to the start of the list
rooms.splice(0, 0, room);
updated = true;
}
if (rooms.length > MAX_ROOMS) { if (rooms.length > MAX_ROOMS) {
// This looks weird, but it's saying to start at the MAX_ROOMS point in the // This looks weird, but it's saying to start at the MAX_ROOMS point in the
// list and delete everything after it. // list and delete everything after it.
rooms.splice(MAX_ROOMS, rooms.length - MAX_ROOMS); rooms.splice(MAX_ROOMS, rooms.length - MAX_ROOMS);
updated = true;
} }
// Update the breadcrumbs
await this.updateState({rooms}); if (updated) {
const roomIds = rooms.map(r => r.roomId); // Update the breadcrumbs
if (roomIds.length > 0) { await this.updateState({rooms});
await SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds); const roomIds = rooms.map(r => r.roomId);
if (roomIds.length > 0) {
await SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds);
}
} }
} }

View file

@ -14,23 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { EventEmitter } from "events";
import { INotificationState, NOTIFICATION_STATE_UPDATE } from "./INotificationState";
import { NotificationColor } from "./NotificationColor"; import { NotificationColor } from "./NotificationColor";
import { IDestroyable } from "../../utils/IDestroyable";
import { TagID } from "../room-list/models"; import { TagID } from "../room-list/models";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { arrayDiff } from "../../utils/arrays"; import { arrayDiff } from "../../utils/arrays";
import { RoomNotificationState } from "./RoomNotificationState"; import { RoomNotificationState } from "./RoomNotificationState";
import { TagSpecificNotificationState } from "./TagSpecificNotificationState"; import { NOTIFICATION_STATE_UPDATE, NotificationState } from "./NotificationState";
export class ListNotificationState extends EventEmitter implements IDestroyable, INotificationState { export type FetchRoomFn = (room: Room) => RoomNotificationState;
private _count: number;
private _color: NotificationColor; export class ListNotificationState extends NotificationState {
private rooms: Room[] = []; private rooms: Room[] = [];
private states: { [roomId: string]: RoomNotificationState } = {}; private states: { [roomId: string]: RoomNotificationState } = {};
constructor(private byTileCount = false, private tagId: TagID) { constructor(private byTileCount = false, private tagId: TagID, private getRoomFn: FetchRoomFn) {
super(); super();
} }
@ -38,14 +35,6 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
return null; // This notification state doesn't support symbols return null; // This notification state doesn't support symbols
} }
public get count(): number {
return this._count;
}
public get color(): NotificationColor {
return this._color;
}
public setRooms(rooms: Room[]) { public setRooms(rooms: Room[]) {
// If we're only concerned about the tile count, don't bother setting up listeners. // If we're only concerned about the tile count, don't bother setting up listeners.
if (this.byTileCount) { if (this.byTileCount) {
@ -62,10 +51,9 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
if (!state) continue; // We likely just didn't have a badge (race condition) if (!state) continue; // We likely just didn't have a badge (race condition)
delete this.states[oldRoom.roomId]; delete this.states[oldRoom.roomId];
state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
state.destroy();
} }
for (const newRoom of diff.added) { for (const newRoom of diff.added) {
const state = new TagSpecificNotificationState(newRoom, this.tagId); const state = this.getRoomFn(newRoom);
state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
if (this.states[newRoom.roomId]) { if (this.states[newRoom.roomId]) {
// "Should never happen" disclaimer. // "Should never happen" disclaimer.
@ -85,8 +73,9 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
} }
public destroy() { public destroy() {
super.destroy();
for (const state of Object.values(this.states)) { for (const state of Object.values(this.states)) {
state.destroy(); state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
} }
this.states = {}; this.states = {};
} }
@ -96,7 +85,7 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
}; };
private calculateTotalState() { private calculateTotalState() {
const before = {count: this.count, symbol: this.symbol, color: this.color}; const snapshot = this.snapshot();
if (this.byTileCount) { if (this.byTileCount) {
this._color = NotificationColor.Red; this._color = NotificationColor.Red;
@ -111,10 +100,7 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
} }
// finally, publish an update if needed // finally, publish an update if needed
const after = {count: this.count, symbol: this.symbol, color: this.color}; this.emitIfUpdated(snapshot);
if (JSON.stringify(before) !== JSON.stringify(after)) {
this.emit(NOTIFICATION_STATE_UPDATE);
}
} }
} }

View file

@ -0,0 +1,87 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { EventEmitter } from "events";
import { NotificationColor } from "./NotificationColor";
import { IDestroyable } from "../../utils/IDestroyable";
export const NOTIFICATION_STATE_UPDATE = "update";
export abstract class NotificationState extends EventEmitter implements IDestroyable {
protected _symbol: string;
protected _count: number;
protected _color: NotificationColor;
public get symbol(): string {
return this._symbol;
}
public get count(): number {
return this._count;
}
public get color(): NotificationColor {
return this._color;
}
public get isIdle(): boolean {
return this.color <= NotificationColor.None;
}
public get isUnread(): boolean {
return this.color >= NotificationColor.Bold;
}
public get hasUnreadCount(): boolean {
return this.color >= NotificationColor.Grey && (!!this.count || !!this.symbol);
}
public get hasMentions(): boolean {
return this.color >= NotificationColor.Red;
}
protected emitIfUpdated(snapshot: NotificationStateSnapshot) {
if (snapshot.isDifferentFrom(this)) {
this.emit(NOTIFICATION_STATE_UPDATE);
}
}
protected snapshot(): NotificationStateSnapshot {
return new NotificationStateSnapshot(this);
}
public destroy(): void {
this.removeAllListeners(NOTIFICATION_STATE_UPDATE);
}
}
export class NotificationStateSnapshot {
private readonly symbol: string;
private readonly count: number;
private readonly color: NotificationColor;
constructor(state: NotificationState) {
this.symbol = state.symbol;
this.count = state.count;
this.color = state.color;
}
public isDifferentFrom(other: NotificationState): boolean {
const before = {count: this.count, symbol: this.symbol, color: this.color};
const after = {count: other.count, symbol: other.symbol, color: other.color};
return JSON.stringify(before) !== JSON.stringify(after);
}
}

View file

@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { EventEmitter } from "events";
import { INotificationState, NOTIFICATION_STATE_UPDATE } from "./INotificationState";
import { NotificationColor } from "./NotificationColor"; import { NotificationColor } from "./NotificationColor";
import { IDestroyable } from "../../utils/IDestroyable"; import { IDestroyable } from "../../utils/IDestroyable";
import { MatrixClientPeg } from "../../MatrixClientPeg"; import { MatrixClientPeg } from "../../MatrixClientPeg";
@ -25,12 +23,9 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import * as RoomNotifs from '../../RoomNotifs'; import * as RoomNotifs from '../../RoomNotifs';
import * as Unread from '../../Unread'; import * as Unread from '../../Unread';
import { NotificationState } from "./NotificationState";
export class RoomNotificationState extends EventEmitter implements IDestroyable, INotificationState { export class RoomNotificationState extends NotificationState implements IDestroyable {
private _symbol: string;
private _count: number;
private _color: NotificationColor;
constructor(public readonly room: Room) { constructor(public readonly room: Room) {
super(); super();
this.room.on("Room.receipt", this.handleReadReceipt); this.room.on("Room.receipt", this.handleReadReceipt);
@ -41,23 +36,12 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable,
this.updateNotificationState(); this.updateNotificationState();
} }
public get symbol(): string {
return this._symbol;
}
public get count(): number {
return this._count;
}
public get color(): NotificationColor {
return this._color;
}
private get roomIsInvite(): boolean { private get roomIsInvite(): boolean {
return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite; return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite;
} }
public destroy(): void { public destroy(): void {
super.destroy();
this.room.removeListener("Room.receipt", this.handleReadReceipt); this.room.removeListener("Room.receipt", this.handleReadReceipt);
this.room.removeListener("Room.timeline", this.handleRoomEventUpdate); this.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
this.room.removeListener("Room.redaction", this.handleRoomEventUpdate); this.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
@ -87,7 +71,7 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable,
}; };
private updateNotificationState() { private updateNotificationState() {
const before = {count: this.count, symbol: this.symbol, color: this.color}; const snapshot = this.snapshot();
if (RoomNotifs.getRoomNotifsState(this.room.roomId) === RoomNotifs.MUTE) { if (RoomNotifs.getRoomNotifsState(this.room.roomId) === RoomNotifs.MUTE) {
// When muted we suppress all notification states, even if we have context on them. // When muted we suppress all notification states, even if we have context on them.
@ -136,9 +120,6 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable,
} }
// finally, publish an update if needed // finally, publish an update if needed
const after = {count: this.count, symbol: this.symbol, color: this.color}; this.emitIfUpdated(snapshot);
if (JSON.stringify(before) !== JSON.stringify(after)) {
this.emit(NOTIFICATION_STATE_UPDATE);
}
} }
} }

View file

@ -0,0 +1,101 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ActionPayload } from "../../dispatcher/payloads";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { DefaultTagID, TagID } from "../room-list/models";
import { FetchRoomFn, ListNotificationState } from "./ListNotificationState";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomNotificationState } from "./RoomNotificationState";
import { TagSpecificNotificationState } from "./TagSpecificNotificationState";
const INSPECIFIC_TAG = "INSPECIFIC_TAG";
type INSPECIFIC_TAG = "INSPECIFIC_TAG";
interface IState {}
export class RoomNotificationStateStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new RoomNotificationStateStore();
private roomMap = new Map<Room, Map<TagID | INSPECIFIC_TAG, RoomNotificationState>>();
private constructor() {
super(defaultDispatcher, {});
}
/**
* Creates a new list notification state. The consumer is expected to set the rooms
* on the notification state, and destroy the state when it no longer needs it.
* @param tagId The tag to create the notification state for.
* @returns The notification state for the tag.
*/
public getListState(tagId: TagID): ListNotificationState {
// Note: we don't cache these notification states as the consumer is expected to call
// .setRooms() on the returned object, which could confuse other consumers.
// TODO: Update if/when invites move out of the room list.
const useTileCount = tagId === DefaultTagID.Invite;
const getRoomFn: FetchRoomFn = (room: Room) => {
return this.getRoomState(room, tagId);
};
return new ListNotificationState(useTileCount, tagId, getRoomFn);
}
/**
* Gets a copy of the notification state for a room. The consumer should not
* attempt to destroy the returned state as it may be shared with other
* consumers.
* @param room The room to get the notification state for.
* @param inTagId Optional tag ID to scope the notification state to.
* @returns The room's notification state.
*/
public getRoomState(room: Room, inTagId?: TagID): RoomNotificationState {
if (!this.roomMap.has(room)) {
this.roomMap.set(room, new Map<TagID | INSPECIFIC_TAG, RoomNotificationState>());
}
const targetTag = inTagId ? inTagId : INSPECIFIC_TAG;
const forRoomMap = this.roomMap.get(room);
if (!forRoomMap.has(targetTag)) {
if (inTagId) {
forRoomMap.set(inTagId, new TagSpecificNotificationState(room, inTagId));
} else {
forRoomMap.set(INSPECIFIC_TAG, new RoomNotificationState(room));
}
}
return forRoomMap.get(targetTag);
}
public static get instance(): RoomNotificationStateStore {
return RoomNotificationStateStore.internalInstance;
}
protected async onNotReady(): Promise<any> {
for (const roomMap of this.roomMap.values()) {
for (const roomState of roomMap.values()) {
roomState.destroy();
}
}
}
// We don't need this, but our contract says we do.
protected async onAction(payload: ActionPayload) {
return Promise.resolve();
}
}

View file

@ -14,13 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { EventEmitter } from "events";
import { INotificationState } from "./INotificationState";
import { NotificationColor } from "./NotificationColor"; import { NotificationColor } from "./NotificationColor";
import { NotificationState } from "./NotificationState";
export class StaticNotificationState extends EventEmitter implements INotificationState { export class StaticNotificationState extends NotificationState {
constructor(public symbol: string, public count: number, public color: NotificationColor) { constructor(symbol: string, count: number, color: NotificationColor) {
super(); super();
this._symbol = symbol;
this._count = count;
this._color = color;
} }
public static forCount(count: number, color: NotificationColor): StaticNotificationState { public static forCount(count: number, color: NotificationColor): StaticNotificationState {

View file

@ -92,11 +92,12 @@ export class ListLayout {
return 5 + RESIZER_BOX_FACTOR; return 5 + RESIZER_BOX_FACTOR;
} }
public setVisibleTilesWithin(diff: number, maxPossible: number) { public setVisibleTilesWithin(newVal: number, maxPossible: number) {
if (this.visibleTiles > maxPossible) { maxPossible = maxPossible + RESIZER_BOX_FACTOR;
this.visibleTiles = maxPossible + diff; if (newVal > maxPossible) {
this.visibleTiles = maxPossible;
} else { } else {
this.visibleTiles += diff; this.visibleTiles = newVal;
} }
} }
@ -111,10 +112,6 @@ export class ListLayout {
return this.tilesToPixels(Math.min(maxTiles, n)) + padding; return this.tilesToPixels(Math.min(maxTiles, n)) + padding;
} }
public tilesWithResizerBoxFactor(n: number): number {
return n + RESIZER_BOX_FACTOR;
}
public tilesWithPadding(n: number, paddingPx: number): number { public tilesWithPadding(n: number, paddingPx: number): number {
return this.pixelsToTiles(this.tilesToPixelsWithPadding(n, paddingPx)); return this.pixelsToTiles(this.tilesToPixelsWithPadding(n, paddingPx));
} }

View file

@ -0,0 +1,73 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { TagID } from "./models";
import { ListLayout } from "./ListLayout";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
interface IState {}
export default class RoomListLayoutStore extends AsyncStoreWithClient<IState> {
private static internalInstance: RoomListLayoutStore;
private readonly layoutMap = new Map<TagID, ListLayout>();
constructor() {
super(defaultDispatcher);
}
public static get instance(): RoomListLayoutStore {
if (!RoomListLayoutStore.internalInstance) {
RoomListLayoutStore.internalInstance = new RoomListLayoutStore();
}
return RoomListLayoutStore.internalInstance;
}
public ensureLayoutExists(tagId: TagID) {
if (!this.layoutMap.has(tagId)) {
this.layoutMap.set(tagId, new ListLayout(tagId));
}
}
public getLayoutFor(tagId: TagID): ListLayout {
if (!this.layoutMap.has(tagId)) {
this.layoutMap.set(tagId, new ListLayout(tagId));
}
return this.layoutMap.get(tagId);
}
// Note: this primarily exists for debugging, and isn't really intended to be used by anything.
public async resetLayouts() {
console.warn("Resetting layouts for room list");
for (const layout of this.layoutMap.values()) {
layout.reset();
}
}
protected async onNotReady(): Promise<any> {
// On logout, clear the map.
this.layoutMap.clear();
}
// We don't need this function, but our contract says we do
protected async onAction(payload: ActionPayload): Promise<any> {
return Promise.resolve();
}
}
window.mx_RoomListLayoutStore = RoomListLayoutStore.instance;

View file

@ -32,6 +32,7 @@ import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm";
import { EffectiveMembership, getEffectiveMembership } from "./membership"; import { EffectiveMembership, getEffectiveMembership } from "./membership";
import { ListLayout } from "./ListLayout"; import { ListLayout } from "./ListLayout";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import RoomListLayoutStore from "./RoomListLayoutStore";
interface IState { interface IState {
tagsEnabled?: boolean; tagsEnabled?: boolean;
@ -50,6 +51,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
private algorithm = new Algorithm(); private algorithm = new Algorithm();
private filterConditions: IFilterCondition[] = []; private filterConditions: IFilterCondition[] = [];
private tagWatcher = new TagWatcher(this); private tagWatcher = new TagWatcher(this);
private layoutMap: Map<TagID, ListLayout> = new Map<TagID, ListLayout>();
private readonly watchedSettings = [ private readonly watchedSettings = [
'feature_custom_tags', 'feature_custom_tags',
@ -416,6 +418,8 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
for (const tagId of OrderedDefaultTagIDs) { for (const tagId of OrderedDefaultTagIDs) {
sorts[tagId] = this.calculateTagSorting(tagId); sorts[tagId] = this.calculateTagSorting(tagId);
orders[tagId] = this.calculateListOrder(tagId); orders[tagId] = this.calculateListOrder(tagId);
RoomListLayoutStore.instance.ensureLayoutExists(tagId);
} }
if (this.state.tagsEnabled) { if (this.state.tagsEnabled) {
@ -434,15 +438,6 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
this.emit(LISTS_UPDATE_EVENT, this); this.emit(LISTS_UPDATE_EVENT, this);
} }
// Note: this primarily exists for debugging, and isn't really intended to be used by anything.
public async resetLayouts() {
console.warn("Resetting layouts for room list");
for (const tagId of Object.keys(this.orderedLists)) {
new ListLayout(tagId).reset();
}
await this.regenerateAllLists();
}
public addFilter(filter: IFilterCondition): void { public addFilter(filter: IFilterCondition): void {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log("Adding filter condition:", filter); console.log("Adding filter condition:", filter);

View file

@ -655,18 +655,35 @@ export class Algorithm extends EventEmitter {
cause = RoomUpdateCause.PossibleTagChange; cause = RoomUpdateCause.PossibleTagChange;
} }
// If we have tags for a room and don't have the room referenced, the room reference // Check to see if the room is known first
// probably changed. We need to swap out the problematic reference. let knownRoomRef = this.rooms.includes(room);
if (hasTags && !this.rooms.includes(room) && !isSticky) { if (hasTags && !knownRoomRef) {
console.warn(`${room.roomId} is missing from room array but is known - trying to find duplicate`); console.warn(`${room.roomId} might be a reference change - attempting to update reference`);
this.rooms = this.rooms.map(r => r.roomId === room.roomId ? room : r); this.rooms = this.rooms.map(r => r.roomId === room.roomId ? room : r);
knownRoomRef = this.rooms.includes(room);
// Sanity check if (!knownRoomRef) {
if (!this.rooms.includes(room)) { console.warn(`${room.roomId} is still not referenced. It may be sticky.`);
throw new Error(`Failed to replace ${room.roomId} with an updated reference`);
} }
} }
if (hasTags && isForLastSticky && !knownRoomRef) {
// we have a fairly good chance at losing a room right now. Under some circumstances,
// we can end up with a room which transitions references and tag changes, then gets
// lost when the sticky room changes. To counter this, we try and add the room to the
// list manually as the condition below to update the reference will fail.
//
// Other conditions *should* result in the room being sorted into the right place.
console.warn(`${room.roomId} was about to be lost - inserting at end of room list`);
this.rooms.push(room);
knownRoomRef = true;
}
// If we have tags for a room and don't have the room referenced, something went horribly
// wrong - the reference should have been updated above.
if (hasTags && !knownRoomRef && !isSticky) {
throw new Error(`${room.roomId} is missing from room array but is known - trying to find duplicate`);
}
// Like above, update the reference to the sticky room if we need to // Like above, update the reference to the sticky room if we need to
if (hasTags && isSticky) { if (hasTags && isSticky) {
// Go directly in and set the sticky room's new reference, being careful not // Go directly in and set the sticky room's new reference, being careful not

View file

@ -20,6 +20,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import ReplyThread from "../../../components/views/elements/ReplyThread"; import ReplyThread from "../../../components/views/elements/ReplyThread";
import { sanitizedHtmlNodeInnerText } from "../../../HtmlUtils";
export class MessageEventPreview implements IPreview { export class MessageEventPreview implements IPreview {
public getTextFor(event: MatrixEvent, tagId?: TagID): string { public getTextFor(event: MatrixEvent, tagId?: TagID): string {
@ -36,14 +37,27 @@ export class MessageEventPreview implements IPreview {
const msgtype = eventContent['msgtype']; const msgtype = eventContent['msgtype'];
if (!body || !msgtype) return null; // invalid event, no preview if (!body || !msgtype) return null; // invalid event, no preview
const hasHtml = eventContent.format === "org.matrix.custom.html" && eventContent.formatted_body;
if (hasHtml) {
body = eventContent.formatted_body;
}
// XXX: Newer relations have a getRelation() function which is not compatible with replies. // XXX: Newer relations have a getRelation() function which is not compatible with replies.
const mRelatesTo = event.getWireContent()['m.relates_to']; const mRelatesTo = event.getWireContent()['m.relates_to'];
if (mRelatesTo && mRelatesTo['m.in_reply_to']) { if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
// If this is a reply, get the real reply and use that // If this is a reply, get the real reply and use that
body = (ReplyThread.stripPlainReply(body) || '').trim(); if (hasHtml) {
body = (ReplyThread.stripHTMLReply(body) || '').trim();
} else {
body = (ReplyThread.stripPlainReply(body) || '').trim();
}
if (!body) return null; // invalid event, no preview if (!body) return null; // invalid event, no preview
} }
if (hasHtml) {
body = sanitizedHtmlNodeInnerText(body);
}
if (msgtype === 'm.emote') { if (msgtype === 'm.emote') {
return _t("%(senderName)s %(emote)s", {senderName: getSenderName(event), emote: body}); return _t("%(senderName)s %(emote)s", {senderName: getSenderName(event), emote: body});
} }

View file

@ -205,8 +205,9 @@ describe("<TextualBody />", () => {
expect(content.html()).toBe('<span class="mx_EventTile_body markdown-body" dir="auto">' + expect(content.html()).toBe('<span class="mx_EventTile_body markdown-body" dir="auto">' +
'Hey <span>' + 'Hey <span>' +
'<a class="mx_Pill mx_UserPill" title="@user:server">' + '<a class="mx_Pill mx_UserPill" title="@user:server">' +
'<img class="mx_BaseAvatar mx_BaseAvatar_image" src="mxc://avatar.url/image.png" ' + '<img src="mxc://avatar.url/image.png" style="width: 16px; height: 16px;" ' +
'style="width: 16px; height: 16px;" title="@member:domain.bla" alt="" aria-hidden="true">Member</a>' + 'title="@member:domain.bla" alt="" aria-hidden="true" role="button" tabindex="0" ' +
'class="mx_AccessibleButton mx_BaseAvatar mx_BaseAvatar_image">Member</a>' +
'</span></span>'); '</span></span>');
}); });
}); });

View file

@ -1308,6 +1308,13 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339"
integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA== integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==
"@types/linkifyjs@^2.1.3":
version "2.1.3"
resolved "https://registry.yarnpkg.com/@types/linkifyjs/-/linkifyjs-2.1.3.tgz#80195c3c88c5e75d9f660e3046ce4a42be2c2fa4"
integrity sha512-V3Xt9wgaOvDPXcpOy3dC8qXCxy3cs0Lr/Hqgd9Bi6m3sf/vpbpTtfmVR0LJklrqYEjaAmc7e3Xh/INT2rCAKjQ==
dependencies:
"@types/react" "*"
"@types/lodash@^4.14.152": "@types/lodash@^4.14.152":
version "4.14.155" version "4.14.155"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.155.tgz#e2b4514f46a261fd11542e47519c20ebce7bc23a" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.155.tgz#e2b4514f46a261fd11542e47519c20ebce7bc23a"
@ -1372,6 +1379,13 @@
"@types/prop-types" "*" "@types/prop-types" "*"
csstype "^2.2.0" csstype "^2.2.0"
"@types/sanitize-html@^1.23.3":
version "1.23.3"
resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-1.23.3.tgz#26527783aba3bf195ad8a3c3e51bd3713526fc0d"
integrity sha512-Isg8N0ifKdDq6/kaNlIcWfapDXxxquMSk2XC5THsOICRyOIhQGds95XH75/PL/g9mExi4bL8otIqJM/Wo96WxA==
dependencies:
htmlparser2 "^4.1.0"
"@types/stack-utils@^1.0.1": "@types/stack-utils@^1.0.1":
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
@ -2499,7 +2513,7 @@ class-utils@^0.3.5:
isobject "^3.0.0" isobject "^3.0.0"
static-extend "^0.1.1" static-extend "^0.1.1"
classnames@^2.1.2, classnames@^2.2.5: classnames@^2.1.2:
version "2.2.6" version "2.2.6"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==
@ -3779,6 +3793,11 @@ fast-levenshtein@~2.0.6:
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
fast-memoize@^2.5.1:
version "2.5.2"
resolved "https://registry.yarnpkg.com/fast-memoize/-/fast-memoize-2.5.2.tgz#79e3bb6a4ec867ea40ba0e7146816f6cdce9b57e"
integrity sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==
fb-watchman@^2.0.0: fb-watchman@^2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.1.tgz#fc84fb39d2709cf3ff6d743706157bb5708a8a85" resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.1.tgz#fc84fb39d2709cf3ff6d743706157bb5708a8a85"
@ -6882,7 +6901,7 @@ prop-types-exact@^1.2.0:
object.assign "^4.1.0" object.assign "^4.1.0"
reflect.ownkeys "^0.2.0" reflect.ownkeys "^0.2.0"
prop-types@15.x, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
version "15.7.2" version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
@ -7053,6 +7072,13 @@ rc@1.2.8, rc@^1.2.8:
minimist "^1.2.0" minimist "^1.2.0"
strip-json-comments "~2.0.1" strip-json-comments "~2.0.1"
re-resizable@^6.5.2:
version "6.5.2"
resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.5.2.tgz#7eb1928c673285d4dcf654211e47acb9a3801c3e"
integrity sha512-Pjo3ydkr/meTr6j3YZqyv+9fRS5UNOj5SaAI06gHFQ35BnpsZKmwNvupCnbo11gjQ1I62Uy+UzlHLO9xPQEuWQ==
dependencies:
fast-memoize "^2.5.1"
react-beautiful-dnd@^4.0.1: react-beautiful-dnd@^4.0.1:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-4.0.1.tgz#3b0a49bf6be75af351176c904f012611dd292b81" resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-4.0.1.tgz#3b0a49bf6be75af351176c904f012611dd292b81"
@ -7086,14 +7112,6 @@ react-dom@^16.9.0:
prop-types "^15.6.2" prop-types "^15.6.2"
scheduler "^0.19.1" scheduler "^0.19.1"
react-draggable@^4.0.3:
version "4.4.2"
resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.4.2.tgz#f3cefecee25f467f865144cda0d066e5f05f94a0"
integrity sha512-zLQs4R4bnBCGnCVTZiD8hPsHtkiJxgMpGDlRESM+EHQo8ysXhKJ2GKdJ8UxxLJdRVceX1j19jy+hQS2wHislPQ==
dependencies:
classnames "^2.2.5"
prop-types "^15.6.0"
react-focus-lock@^2.2.1: react-focus-lock@^2.2.1:
version "2.3.1" version "2.3.1"
resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.3.1.tgz#9d5d85899773609c7eefa4fc54fff6a0f5f2fc47" resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.3.1.tgz#9d5d85899773609c7eefa4fc54fff6a0f5f2fc47"
@ -7138,14 +7156,6 @@ react-redux@^5.0.6:
react-is "^16.6.0" react-is "^16.6.0"
react-lifecycles-compat "^3.0.0" react-lifecycles-compat "^3.0.0"
react-resizable@^1.10.1:
version "1.10.1"
resolved "https://registry.yarnpkg.com/react-resizable/-/react-resizable-1.10.1.tgz#f0c2cf1d83b3470b87676ce6d6b02bbe3f4d8cd4"
integrity sha512-Jd/bKOKx6+19NwC4/aMLRu/J9/krfxlDnElP41Oc+oLiUWs/zwV1S9yBfBZRnqAwQb6vQ/HRSk3bsSWGSgVbpw==
dependencies:
prop-types "15.x"
react-draggable "^4.0.3"
react-test-renderer@^16.0.0-0, react-test-renderer@^16.9.0: react-test-renderer@^16.0.0-0, react-test-renderer@^16.9.0:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.13.1.tgz#de25ea358d9012606de51e012d9742e7f0deabc1" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.13.1.tgz#de25ea358d9012606de51e012d9742e7f0deabc1"