Merge branch 'develop' into joriks/delabs-font-scaling

This commit is contained in:
Jorik Schellekens 2020-07-13 16:15:52 +01:00 committed by GitHub
commit 59e153e024
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
97 changed files with 3672 additions and 1313 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",
@ -120,7 +120,9 @@
"@babel/register": "^7.7.4", "@babel/register": "^7.7.4",
"@peculiar/webcrypto": "^1.0.22", "@peculiar/webcrypto": "^1.0.22",
"@types/classnames": "^2.2.10", "@types/classnames": "^2.2.10",
"@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",
@ -128,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

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
// 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/14367
$tagPanelWidth: 70px; // only applies in this file, used for calculations $tagPanelWidth: 70px; // only applies in this file, used for calculations
@ -54,7 +54,11 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
flex-direction: column; flex-direction: column;
.mx_LeftPanel2_userHeader { .mx_LeftPanel2_userHeader {
padding: 12px 12px 20px; // 12px top, 12px sides, 20px bottom /* 12px top, 12px sides, 20px bottom (using 13px bottom to account
* for internal whitespace in the breadcrumbs)
*/
padding: 12px 12px 13px;
flex-shrink: 0; // to convince safari's layout engine the flexbox is fine
// Create another flexbox column for the rows to stack within // Create another flexbox column for the rows to stack within
display: flex; display: flex;
@ -72,7 +76,20 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
width: 100%; width: 100%;
overflow-y: hidden; overflow-y: hidden;
overflow-x: scroll; overflow-x: scroll;
margin-top: 8px; margin-top: 20px;
padding-bottom: 2px;
&.mx_IndicatorScrollbar_leftOverflow {
mask-image: linear-gradient(90deg, transparent, black 10%);
}
&.mx_IndicatorScrollbar_rightOverflow {
mask-image: linear-gradient(90deg, black, black 90%, transparent);
}
&.mx_IndicatorScrollbar_rightOverflow.mx_IndicatorScrollbar_leftOverflow {
mask-image: linear-gradient(90deg, transparent, black 10%, black 90%, transparent);
}
} }
} }
@ -80,17 +97,23 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
margin-left: 12px; margin-left: 12px;
margin-right: 12px; margin-right: 12px;
flex-shrink: 0; // to convince safari's layout engine the flexbox is fine
// Create a flexbox to organize the inputs // Create a flexbox to organize the inputs
display: flex; display: flex;
align-items: center; align-items: center;
.mx_RoomSearch_expanded + .mx_LeftPanel2_exploreButton { .mx_RoomSearch_expanded + .mx_LeftPanel2_exploreButton {
// Cheaty way to return the occupied space to the filter input // Cheaty way to return the occupied space to the filter input
flex-basis: 0;
margin: 0; margin: 0;
width: 0; width: 0;
// Don't forget to hide the masked ::before icon // Don't forget to hide the masked ::before icon,
visibility: hidden; // using display:none or visibility:hidden would break accessibility
&::before {
content: none;
}
} }
.mx_LeftPanel2_exploreButton { .mx_LeftPanel2_exploreButton {
@ -117,6 +140,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

@ -24,7 +24,7 @@ limitations under the License.
right: 0; right: 0;
} }
.mx_NotificationBadge { .mx_NotificationBadge, .mx_RoomTile2_badgeContainer {
position: absolute; position: absolute;
top: 0; top: 0;
right: 0; right: 0;

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

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
// 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/14367
.mx_RoomBreadcrumbs2 { .mx_RoomBreadcrumbs2 {
width: 100%; width: 100%;

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
// 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/14367
.mx_RoomSublist2 { .mx_RoomSublist2 {
// The sublist is a column of rows, essentially // The sublist is a column of rows, essentially
@ -24,9 +24,7 @@ limitations under the License.
margin-left: 8px; margin-left: 8px;
width: 100%; width: 100%;
&:first-child { flex-shrink: 0; // to convince safari's layout engine the flexbox is fine
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
@ -49,13 +47,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 +67,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
} }
@ -182,7 +181,6 @@ limitations under the License.
} }
.mx_RoomSublist2_resizeBox { .mx_RoomSublist2_resizeBox {
margin-bottom: 4px; // for the resize handle
position: relative; position: relative;
// Create another flexbox column for the tiles // Create another flexbox column for the tiles
@ -190,93 +188,89 @@ limitations under the License.
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
.mx_RoomSublist2_showNButton { .mx_RoomSublist2_tiles {
cursor: pointer; flex: 1 0 0;
font-size: $font-13px; overflow: hidden;
line-height: $font-18px; // need this to be flex otherwise the overflow hidden from above
color: $roomtile2-preview-color; // sometimes vertically centers the clipped list ... no idea why it would do this
// as the box model should be top aligned. Happens in both FF and Chromium
// This is the same color as the left panel background because it needs
// to occlude the lastmost tile in the list.
background-color: $roomlist2-bg-color;
// Update the render() function for RoomSublist2 if these change
// Update the ListLayout class for minVisibleTiles if these change.
//
// At 24px high and 8px padding on the top this equates to 0.65 of
// a tile due to how the padding calculations work.
height: 24px;
padding-top: 8px;
// We force this to the bottom so it will overlap rooms as needed.
// We account for the space it takes up (24px) in the code through padding.
position: absolute;
bottom: 4px; // the height of the resize handle
left: 0;
right: 0;
// We create a flexbox to cheat at alignment
display: flex; display: flex;
align-items: center; flex-direction: column;
}
.mx_RoomSublist2_showNButtonChevron { .mx_RoomSublist2_resizerHandles_showNButton {
position: relative; flex: 0 0 32px;
width: 16px; }
height: 16px;
margin-left: 12px;
margin-right: 18px;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background: $roomtile2-preview-color;
}
.mx_RoomSublist2_showMoreButtonChevron { .mx_RoomSublist2_resizerHandles {
mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); flex: 0 0 4px;
}
.mx_RoomSublist2_showLessButtonChevron {
mask-image: url('$(res)/img/feather-customised/chevron-up.svg');
}
&.mx_RoomSublist2_isCutting::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
box-shadow: 0px -2px 3px rgba(46, 47, 50, 0.08);
}
} }
// Class name comes from the ResizableBox component // 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;
} }
} }
} }
.mx_RoomSublist2_showNButton {
cursor: pointer;
font-size: $font-13px;
line-height: $font-18px;
color: $roomtile2-preview-color;
// Update the render() function for RoomSublist2 if these change
// Update the ListLayout class for minVisibleTiles if these change.
height: 24px;
padding-bottom: 4px;
// We create a flexbox to cheat at alignment
display: flex;
align-items: center;
.mx_RoomSublist2_showNButtonChevron {
position: relative;
width: 16px;
height: 16px;
margin-left: 12px;
margin-right: 18px;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background: $roomtile2-preview-color;
}
.mx_RoomSublist2_showMoreButtonChevron {
mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
}
.mx_RoomSublist2_showLessButtonChevron {
mask-image: url('$(res)/img/feather-customised/chevron-up.svg');
}
}
&.mx_RoomSublist2_hasMenuOpen, &.mx_RoomSublist2_hasMenuOpen,
&:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:focus-within, &:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:focus-within,
&:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:hover { &:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:hover {
@ -322,13 +316,13 @@ limitations under the License.
.mx_RoomSublist2_resizeBox { .mx_RoomSublist2_resizeBox {
align-items: center; align-items: center;
}
.mx_RoomSublist2_showNButton { .mx_RoomSublist2_showNButton {
flex-direction: column; flex-direction: column;
.mx_RoomSublist2_showNButtonChevron { .mx_RoomSublist2_showNButtonChevron {
margin-right: 12px; // to center margin-right: 12px; // to center
}
} }
} }

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
// 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/14367
// Note: the room tile expects to be in a flexbox column container // Note: the room tile expects to be in a flexbox column container
.mx_RoomTile2 { .mx_RoomTile2 {
@ -77,7 +77,7 @@ limitations under the License.
} }
} }
.mx_RoomTile2_menuButton { .mx_RoomTile2_notificationsButton {
margin-left: 4px; // spacing between buttons margin-left: 4px; // spacing between buttons
} }
@ -85,7 +85,6 @@ limitations under the License.
height: 16px; height: 16px;
// don't set width so that it takes no space when there is no badge to show // don't set width so that it takes no space when there is no badge to show
margin: auto 0; // vertically align margin: auto 0; // vertically align
position: relative; // fixes badge alignment in some scenarios
// Create a flexbox to make aligning dot badges easier // Create a flexbox to make aligning dot badges easier
display: flex; display: flex;
@ -108,7 +107,8 @@ limitations under the License.
width: 20px; width: 20px;
min-width: 20px; // yay flex min-width: 20px; // yay flex
height: 20px; height: 20px;
margin: auto 0; margin-top: auto;
margin-bottom: auto;
position: relative; position: relative;
display: none; display: none;
@ -223,6 +223,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

@ -36,7 +36,7 @@ $focus-bg-color: #dddddd;
$accent-fg-color: #ffffff; $accent-fg-color: #ffffff;
$accent-color-50pct: rgba(3, 179, 129, 0.5); //#03b381 in rgb $accent-color-50pct: rgba(3, 179, 129, 0.5); //#03b381 in rgb
$accent-color-darker: #92caad; $accent-color-darker: #92caad;
$accent-color-alt: #238CF5; $accent-color-alt: #238cf5;
$selection-fg-color: $primary-bg-color; $selection-fg-color: $primary-bg-color;
@ -46,8 +46,8 @@ $focus-brightness: 105%;
$warning-color: $notice-primary-color; // red $warning-color: $notice-primary-color; // red
$orange-warning-color: #ff8d13; // used for true warnings $orange-warning-color: #ff8d13; // used for true warnings
// background colour for warnings // background colour for warnings
$warning-bg-color: #DF2A8B; $warning-bg-color: #df2a8b;
$info-bg-color: #2A9EDF; $info-bg-color: #2a9edf;
$mention-user-pill-bg-color: $warning-color; $mention-user-pill-bg-color: $warning-color;
$other-user-pill-bg-color: rgba(0, 0, 0, 0.1); $other-user-pill-bg-color: rgba(0, 0, 0, 0.1);
@ -71,7 +71,7 @@ $tagpanel-bg-color: #27303a;
$plinth-bg-color: $secondary-accent-color; $plinth-bg-color: $secondary-accent-color;
// used by RoomDropTarget // used by RoomDropTarget
$droptarget-bg-color: rgba(255,255,255,0.5); $droptarget-bg-color: rgba(255, 255, 255, 0.5);
// used by AddressSelector // used by AddressSelector
$selected-color: $secondary-accent-color; $selected-color: $secondary-accent-color;
@ -157,18 +157,18 @@ $rte-group-pill-color: #aaa;
$topleftmenu-color: #212121; $topleftmenu-color: #212121;
$roomheader-color: #45474a; $roomheader-color: #45474a;
$roomheader-addroom-bg-color: #91A1C0; $roomheader-addroom-bg-color: #91a1c0;
$roomheader-addroom-fg-color: $accent-fg-color; $roomheader-addroom-fg-color: $accent-fg-color;
$tagpanel-button-color: #91A1C0; $tagpanel-button-color: #91a1c0;
$roomheader-button-color: #91A1C0; $roomheader-button-color: #91a1c0;
$groupheader-button-color: #91A1C0; $groupheader-button-color: #91a1c0;
$rightpanel-button-color: #91A1C0; $rightpanel-button-color: #91a1c0;
$composer-button-color: #91A1C0; $composer-button-color: #91a1c0;
$roomtopic-color: #9e9e9e; $roomtopic-color: #9e9e9e;
$eventtile-meta-color: $roomtopic-color; $eventtile-meta-color: $roomtopic-color;
$composer-e2e-icon-color: #c9ced6; $composer-e2e-icon-color: #c9ced6;
$header-divider-color: #91A1C0; $header-divider-color: #91a1c0;
// ******************** // ********************
@ -184,11 +184,11 @@ $roomsublist2-divider-color: $primary-fg-color;
$roomtile2-preview-color: #9e9e9e; $roomtile2-preview-color: #9e9e9e;
$roomtile2-default-badge-bg-color: #61708b; $roomtile2-default-badge-bg-color: #61708b;
$roomtile2-selected-bg-color: #FFF; $roomtile2-selected-bg-color: #fff;
$presence-online: $accent-color; $presence-online: $accent-color;
$presence-away: orange; // TODO: Get color $presence-away: #d9b072;
$presence-offline: #E3E8F0; $presence-offline: #e3e8f0;
// ******************** // ********************

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

@ -20,6 +20,8 @@ import { IMatrixClientPeg } from "../MatrixClientPeg";
import ToastStore from "../stores/ToastStore"; 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 RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore";
declare global { declare global {
interface Window { interface Window {
@ -33,6 +35,11 @@ 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;
// TODO: Remove flag before launch: https://github.com/vector-im/riot-web/issues/14231
mx_QuietRoomListLogging: boolean;
} }
// workaround for https://github.com/microsoft/TypeScript/issues/30933 // workaround for https://github.com/microsoft/TypeScript/issues/30933
@ -45,6 +52,10 @@ declare global {
hasStorageAccess?: () => Promise<boolean>; hasStorageAccess?: () => Promise<boolean>;
} }
interface Navigator {
userLanguage?: string;
}
interface StorageEstimate { interface StorageEstimate {
usageDetails?: {[key: string]: number}; usageDetails?: {[key: string]: number};
} }

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

@ -53,6 +53,10 @@ export default abstract class BasePlatform {
this.startUpdateCheck = this.startUpdateCheck.bind(this); this.startUpdateCheck = this.startUpdateCheck.bind(this);
} }
abstract async getConfig(): Promise<{}>;
abstract getDefaultDeviceDisplayName(): string;
protected onAction = (payload: ActionPayload) => { protected onAction = (payload: ActionPayload) => {
switch (payload.action) { switch (payload.action) {
case 'on_client_not_viable': case 'on_client_not_viable':

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

@ -1,5 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,6 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import BasePlatform from "./BasePlatform";
/* /*
* Holds the current Platform object used by the code to do anything * Holds the current Platform object used by the code to do anything
* specific to the platform we're running on (eg. web, electron) * specific to the platform we're running on (eg. web, electron)
@ -21,10 +24,8 @@ limitations under the License.
* This allows the app layer to set a Platform without necessarily * This allows the app layer to set a Platform without necessarily
* having to have a MatrixChat object * having to have a MatrixChat object
*/ */
class PlatformPeg { export class PlatformPeg {
constructor() { platform: BasePlatform = null;
this.platform = null;
}
/** /**
* Returns the current Platform object for the application. * Returns the current Platform object for the application.
@ -39,12 +40,12 @@ class PlatformPeg {
* application. * application.
* This should be an instance of a class extending BasePlatform. * This should be an instance of a class extending BasePlatform.
*/ */
set(plaf) { set(plaf: BasePlatform) {
this.platform = plaf; this.platform = plaf;
} }
} }
if (!global.mxPlatformPeg) { if (!window.mxPlatformPeg) {
global.mxPlatformPeg = new PlatformPeg(); window.mxPlatformPeg = new PlatformPeg();
} }
export default global.mxPlatformPeg; export default window.mxPlatformPeg;

24
src/RoomNotifsTypes.ts Normal file
View file

@ -0,0 +1,24 @@
/*
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 {
ALL_MESSAGES,
ALL_MESSAGES_LOUD,
MENTIONS_ONLY,
MUTE,
} from "./RoomNotifs";
export type Volume = ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE;

View file

@ -660,7 +660,7 @@ export const Commands = [
if (args) { if (args) {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const matches = args.match(/^(\S+)$/); const matches = args.match(/^(@[^:]+:\S+)$/);
if (matches) { if (matches) {
const userId = matches[1]; const userId = matches[1];
const ignoredUsers = cli.getIgnoredUsers(); const ignoredUsers = cli.getIgnoredUsers();
@ -690,7 +690,7 @@ export const Commands = [
if (args) { if (args) {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const matches = args.match(/^(\S+)$/); const matches = args.match(/(^@[^:]+:\S+$)/);
if (matches) { if (matches) {
const userId = matches[1]; const userId = matches[1];
const ignoredUsers = cli.getIgnoredUsers(); const ignoredUsers = cli.getIgnoredUsers();

View file

@ -22,9 +22,13 @@ import React, {
useMemo, useMemo,
useRef, useRef,
useReducer, useReducer,
Reducer,
RefObject,
Dispatch,
} from "react"; } from "react";
import PropTypes from "prop-types";
import {Key} from "../Keyboard"; import {Key} from "../Keyboard";
import AccessibleButton from "../components/views/elements/AccessibleButton";
/** /**
* Module to simplify implementing the Roving TabIndex accessibility technique * Module to simplify implementing the Roving TabIndex accessibility technique
@ -41,7 +45,19 @@ import {Key} from "../Keyboard";
const DOCUMENT_POSITION_PRECEDING = 2; const DOCUMENT_POSITION_PRECEDING = 2;
const RovingTabIndexContext = createContext({ type Ref = RefObject<HTMLElement>;
interface IState {
activeRef: Ref;
refs: Ref[];
}
interface IContext {
state: IState;
dispatch: Dispatch<IAction>;
}
const RovingTabIndexContext = createContext<IContext>({
state: { state: {
activeRef: null, activeRef: null,
refs: [], // list of refs in DOM order refs: [], // list of refs in DOM order
@ -50,16 +66,22 @@ const RovingTabIndexContext = createContext({
}); });
RovingTabIndexContext.displayName = "RovingTabIndexContext"; RovingTabIndexContext.displayName = "RovingTabIndexContext";
// TODO use a TypeScript type here enum Type {
const types = { Register = "REGISTER",
REGISTER: "REGISTER", Unregister = "UNREGISTER",
UNREGISTER: "UNREGISTER", SetFocus = "SET_FOCUS",
SET_FOCUS: "SET_FOCUS", }
};
const reducer = (state, action) => { interface IAction {
type: Type;
payload: {
ref: Ref;
};
}
const reducer = (state: IState, action: IAction) => {
switch (action.type) { switch (action.type) {
case types.REGISTER: { case Type.Register: {
if (state.refs.length === 0) { if (state.refs.length === 0) {
// Our list of refs was empty, set activeRef to this first item // Our list of refs was empty, set activeRef to this first item
return { return {
@ -92,7 +114,7 @@ const reducer = (state, action) => {
], ],
}; };
} }
case types.UNREGISTER: { case Type.Unregister: {
// filter out the ref which we are removing // filter out the ref which we are removing
const refs = state.refs.filter(r => r !== action.payload.ref); const refs = state.refs.filter(r => r !== action.payload.ref);
@ -117,7 +139,7 @@ const reducer = (state, action) => {
refs, refs,
}; };
} }
case types.SET_FOCUS: { case Type.SetFocus: {
// update active ref // update active ref
return { return {
...state, ...state,
@ -129,13 +151,21 @@ const reducer = (state, action) => {
} }
}; };
export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) => { interface IProps {
const [state, dispatch] = useReducer(reducer, { handleHomeEnd?: boolean;
children(renderProps: {
onKeyDownHandler(ev: React.KeyboardEvent);
});
onKeyDown?(ev: React.KeyboardEvent);
}
export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEnd, onKeyDown}) => {
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
activeRef: null, activeRef: null,
refs: [], refs: [],
}); });
const context = useMemo(() => ({state, dispatch}), [state]); const context = useMemo<IContext>(() => ({state, dispatch}), [state]);
const onKeyDownHandler = useCallback((ev) => { const onKeyDownHandler = useCallback((ev) => {
let handled = false; let handled = false;
@ -171,19 +201,17 @@ export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) =>
{ children({onKeyDownHandler}) } { children({onKeyDownHandler}) }
</RovingTabIndexContext.Provider>; </RovingTabIndexContext.Provider>;
}; };
RovingTabIndexProvider.propTypes = {
handleHomeEnd: PropTypes.bool, type FocusHandler = () => void;
onKeyDown: PropTypes.func,
};
// Hook to register a roving tab index // Hook to register a roving tab index
// inputRef parameter specifies the ref to use // inputRef parameter specifies the ref to use
// onFocus should be called when the index gained focus in any manner // onFocus should be called when the index gained focus in any manner
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}` // isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition // ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
export const useRovingTabIndex = (inputRef) => { export const useRovingTabIndex = (inputRef: Ref): [FocusHandler, boolean, Ref] => {
const context = useContext(RovingTabIndexContext); const context = useContext(RovingTabIndexContext);
let ref = useRef(null); let ref = useRef<HTMLElement>(null);
if (inputRef) { if (inputRef) {
// if we are given a ref, use it instead of ours // if we are given a ref, use it instead of ours
@ -193,13 +221,13 @@ export const useRovingTabIndex = (inputRef) => {
// setup (after refs) // setup (after refs)
useLayoutEffect(() => { useLayoutEffect(() => {
context.dispatch({ context.dispatch({
type: types.REGISTER, type: Type.Register,
payload: {ref}, payload: {ref},
}); });
// teardown // teardown
return () => { return () => {
context.dispatch({ context.dispatch({
type: types.UNREGISTER, type: Type.Unregister,
payload: {ref}, payload: {ref},
}); });
}; };
@ -207,7 +235,7 @@ export const useRovingTabIndex = (inputRef) => {
const onFocus = useCallback(() => { const onFocus = useCallback(() => {
context.dispatch({ context.dispatch({
type: types.SET_FOCUS, type: Type.SetFocus,
payload: {ref}, payload: {ref},
}); });
}, [ref, context]); }, [ref, context]);
@ -216,9 +244,28 @@ export const useRovingTabIndex = (inputRef) => {
return [onFocus, isActive, ref]; return [onFocus, isActive, ref];
}; };
interface IRovingTabIndexWrapperProps {
inputRef?: Ref;
children(renderProps: {
onFocus: FocusHandler;
isActive: boolean;
ref: Ref;
});
}
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components. // Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
export const RovingTabIndexWrapper = ({children, inputRef}) => { export const RovingTabIndexWrapper: React.FC<IRovingTabIndexWrapperProps> = ({children, inputRef}) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return children({onFocus, isActive, ref}); return children({onFocus, isActive, ref});
}; };
interface IRovingAccessibleButtonProps extends React.ComponentProps<typeof AccessibleButton> {
inputRef?: Ref;
}
// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
export const RovingAccessibleButton: React.FC<IRovingAccessibleButtonProps> = ({inputRef, ...props}) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return <AccessibleButton {...props} onFocus={onFocus} inputRef={ref} tabIndex={isActive ? 0 : -1} />;
};

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,13 +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";
// 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
@ -30,8 +29,8 @@ import AccessibleButton from "../views/elements/AccessibleButton";
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");
@ -43,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() {
@ -94,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;
@ -111,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();
@ -134,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 {
@ -181,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();
@ -217,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;
@ -239,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) {
@ -250,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;
@ -296,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;
} }
@ -333,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>
@ -348,99 +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 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,
};
// 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
@ -448,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;
@ -507,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";
@ -32,9 +33,10 @@ import ResizeNotifier from "../../utils/ResizeNotifier";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore2"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore2";
import {Key} from "../../Keyboard"; import {Key} from "../../Keyboard";
import IndicatorScrollbar from "../structures/IndicatorScrollbar";
// 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/14367
// 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/14367
/******************************************************************* /*******************************************************************
* CAUTION * * CAUTION *
@ -55,12 +57,20 @@ interface IState {
showTagPanel: boolean; showTagPanel: boolean;
} }
// List of CSS classes which should be included in keyboard navigation within the room list
const cssClasses = [
"mx_RoomSearch_input",
"mx_RoomSearch_icon", // minimized <RoomSearch />
"mx_RoomSublist2_headerText",
"mx_RoomTile2",
"mx_RoomSublist2_showNButton",
];
export default class LeftPanel2 extends React.Component<IProps, IState> { export default class LeftPanel2 extends React.Component<IProps, IState> {
private listContainerRef: React.RefObject<HTMLDivElement> = createRef(); private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
private tagPanelWatcherRef: string; private tagPanelWatcherRef: string;
private focusedElement = null; private focusedElement = null;
private isDoingStickyHeaders = false;
// TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
@ -105,40 +115,131 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
}; };
private handleStickyHeaders(list: HTMLDivElement) { private handleStickyHeaders(list: HTMLDivElement) {
const rlRect = list.getBoundingClientRect(); if (this.isDoingStickyHeaders) return;
const bottom = rlRect.bottom; this.isDoingStickyHeaders = true;
const top = rlRect.top; window.requestAnimationFrame(() => {
this.doStickyHeaders(list);
this.isDoingStickyHeaders = false;
});
}
private doStickyHeaders(list: HTMLDivElement) {
const topEdge = list.scrollTop;
const bottomEdge = list.offsetHeight + list.scrollTop;
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;
let gotBottom = false; // 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 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.top = `unset`;
gotBottom = true; if (isOffTop || sublist === sublists[0]) {
} else if (slRect.top < top) { targetStyles.set(header, { stickyTop: true });
header.classList.add("mx_RoomSublist2_headerContainer_sticky"); if (lastTopHeader) {
header.classList.add("mx_RoomSublist2_headerContainer_stickyTop"); lastTopHeader.style.display = "none";
header.style.width = `${headerStickyWidth}px`; targetStyles.set(lastTopHeader, { makeInvisible: true });
header.style.top = `${rlRect.top}px`; }
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.width = `unset`;
header.style.top = `unset`;
} }
} }
// 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
@ -173,6 +274,14 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
} }
}; };
private onEnter = () => {
const firstRoom = this.listContainerRef.current.querySelector<HTMLDivElement>(".mx_RoomTile2");
if (firstRoom) {
firstRoom.click();
this.onSearch(""); // clear the search field
}
};
private onMoveFocus = (up: boolean) => { private onMoveFocus = (up: boolean) => {
let element = this.focusedElement; let element = this.focusedElement;
@ -204,10 +313,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
if (element) { if (element) {
classes = element.classList; classes = element.classList;
} }
} while (element && !( } while (element && !cssClasses.some(c => classes.contains(c)));
classes.contains("mx_RoomTile2") ||
classes.contains("mx_RoomSublist2_headerText") ||
classes.contains("mx_RoomSearch_input")));
if (element) { if (element) {
element.focus(); element.focus();
@ -217,11 +323,14 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
private renderHeader(): React.ReactNode { private renderHeader(): React.ReactNode {
let breadcrumbs; let breadcrumbs;
if (this.state.showBreadcrumbs) { if (this.state.showBreadcrumbs && !this.props.isMinimized) {
breadcrumbs = ( breadcrumbs = (
<div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer mx_AutoHideScrollbar"> <IndicatorScrollbar
{this.props.isMinimized ? null : <RoomBreadcrumbs2 />} className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer mx_AutoHideScrollbar"
</div> verticalScrollsHorizontally={true}
>
<RoomBreadcrumbs2 />
</IndicatorScrollbar>
); );
} }
@ -235,17 +344,22 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
private renderSearchExplore(): React.ReactNode { private renderSearchExplore(): React.ReactNode {
return ( return (
<div className="mx_LeftPanel2_filterContainer" onFocus={this.onFocus} onBlur={this.onBlur}> <div
className="mx_LeftPanel2_filterContainer"
onFocus={this.onFocus}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
>
<RoomSearch <RoomSearch
onQueryUpdate={this.onSearch} onQueryUpdate={this.onSearch}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
onVerticalArrow={this.onKeyDown} onVerticalArrow={this.onKeyDown}
onEnter={this.onEnter}
/> />
<AccessibleButton <AccessibleButton
// TODO fix the accessibility of this: https://github.com/vector-im/riot-web/issues/14180
className="mx_LeftPanel2_exploreButton" className="mx_LeftPanel2_exploreButton"
onClick={this.onExplore} onClick={this.onExplore}
alt={_t("Explore rooms")} title={_t("Explore rooms")}
/> />
</div> </div>
); );
@ -266,6 +380,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
onFocus={this.onFocus} onFocus={this.onFocus}
onBlur={this.onBlur} onBlur={this.onBlur}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
onResize={this.onResize}
/>; />;
// TODO: Conference handling / calls: https://github.com/vector-im/riot-web/issues/14177 // TODO: Conference handling / calls: https://github.com/vector-im/riot-web/issues/14177
@ -287,15 +402,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

@ -19,7 +19,6 @@ limitations under the License.
import * as React from 'react'; import * as React from 'react';
import * as PropTypes from 'prop-types'; import * as PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk/src/client'; import { MatrixClient } from 'matrix-js-sdk/src/client';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { DragDropContext } from 'react-beautiful-dnd'; import { DragDropContext } from 'react-beautiful-dnd';
import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent} from '../../Keyboard'; import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent} from '../../Keyboard';
@ -53,6 +52,8 @@ 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";
// 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)
// so each pinned message may trigger a request. Limit the number per room for sanity. // so each pinned message may trigger a request. Limit the number per room for sanity.
@ -409,20 +410,6 @@ class LoggedInView extends React.Component<IProps, IState> {
}; };
_onKeyDown = (ev) => { _onKeyDown = (ev) => {
/*
// Remove this for now as ctrl+alt = alt-gr so this breaks keyboards which rely on alt-gr for numbers
// Will need to find a better meta key if anyone actually cares about using this.
if (ev.altKey && ev.ctrlKey && ev.keyCode > 48 && ev.keyCode < 58) {
dis.dispatch({
action: 'view_indexed_room',
roomIndex: ev.keyCode - 49,
});
ev.stopPropagation();
ev.preventDefault();
return;
}
*/
let handled = false; let handled = false;
const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey; const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey;
@ -474,8 +461,8 @@ class LoggedInView extends React.Component<IProps, IState> {
case Key.ARROW_UP: case Key.ARROW_UP:
case Key.ARROW_DOWN: case Key.ARROW_DOWN:
if (ev.altKey && !ev.ctrlKey && !ev.metaKey) { if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
dis.dispatch({ dis.dispatch<ViewRoomDeltaPayload>({
action: 'view_room_delta', action: Action.ViewRoomDelta,
delta: ev.key === Key.ARROW_UP ? -1 : 1, delta: ev.key === Key.ARROW_UP ? -1 : 1,
unread: ev.shiftKey, unread: ev.shiftKey,
}); });
@ -681,8 +668,7 @@ class LoggedInView extends React.Component<IProps, IState> {
disabled={this.props.leftDisabled} disabled={this.props.leftDisabled}
/> />
); );
if (SettingsStore.isFeatureEnabled("feature_new_room_list")) { if (SettingsStore.getValue("feature_new_room_list")) {
// TODO: Supply props like collapsed and disabled to LeftPanel2
leftPanel = ( leftPanel = (
<LeftPanel2 <LeftPanel2
isMinimized={this.props.collapseLhs || false} isMinimized={this.props.collapseLhs || false}
@ -710,6 +696,7 @@ class LoggedInView extends React.Component<IProps, IState> {
</div> </div>
</DragDropContext> </DragDropContext>
</div> </div>
<CallContainer />
</MatrixClientContext.Provider> </MatrixClientContext.Provider>
); );
} }

View file

@ -596,15 +596,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} }
break; break;
} }
case 'view_prev_room':
this.viewNextRoom(-1);
break;
case 'view_next_room': case 'view_next_room':
this.viewNextRoom(1); this.viewNextRoom(1);
break; break;
case 'view_indexed_room':
this.viewIndexedRoom(payload.roomIndex);
break;
case Action.ViewUserSettings: { case Action.ViewUserSettings: {
const tabPayload = payload as OpenToTabPayload; const tabPayload = payload as OpenToTabPayload;
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog"); const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
@ -812,19 +806,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}); });
} }
// TODO: Move to RoomViewStore
private viewIndexedRoom(roomIndex: number) {
const allRooms = RoomListSorter.mostRecentActivityFirst(
MatrixClientPeg.get().getRooms(),
);
if (allRooms[roomIndex]) {
dis.dispatch({
action: 'view_room',
room_id: allRooms[roomIndex].roomId,
});
}
}
// switch view to the given room // switch view to the given room
// //
// @param {Object} roomInfo Object containing data about the room to be joined // @param {Object} roomInfo Object containing data about the room to be joined

View file

@ -25,7 +25,7 @@ import { Key } from "../../Keyboard";
import AccessibleButton from "../views/elements/AccessibleButton"; import AccessibleButton from "../views/elements/AccessibleButton";
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
// 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/14367
/******************************************************************* /*******************************************************************
* CAUTION * * CAUTION *
@ -39,6 +39,7 @@ interface IProps {
onQueryUpdate: (newQuery: string) => void; onQueryUpdate: (newQuery: string) => void;
isMinimized: boolean; isMinimized: boolean;
onVerticalArrow(ev: React.KeyboardEvent); onVerticalArrow(ev: React.KeyboardEvent);
onEnter(ev: React.KeyboardEvent);
} }
interface IState { interface IState {
@ -81,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 = () => {
@ -104,7 +106,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
ev.target.select(); ev.target.select();
}; };
private onBlur = () => { private onBlur = (ev: React.FocusEvent<HTMLInputElement>) => {
this.setState({focused: false}); this.setState({focused: false});
}; };
@ -114,6 +116,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
defaultDispatcher.fire(Action.FocusComposer); defaultDispatcher.fire(Action.FocusComposer);
} else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) { } else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) {
this.props.onVerticalArrow(ev); this.props.onVerticalArrow(ev);
} else if (ev.key === Key.ENTER) {
this.props.onEnter(ev);
} }
}; };
@ -149,7 +153,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
let clearButton = ( let clearButton = (
<AccessibleButton <AccessibleButton
tabIndex={-1} tabIndex={-1}
className='mx_RoomSearch_clearButton' title={_t("Clear filter")}
className="mx_RoomSearch_clearButton"
onClick={this.clearInput} onClick={this.clearInput}
/> />
); );
@ -157,8 +162,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
if (this.props.isMinimized) { if (this.props.isMinimized) {
icon = ( icon = (
<AccessibleButton <AccessibleButton
tabIndex={-1} title={_t("Search rooms")}
className='mx_RoomSearch_icon' className="mx_RoomSearch_icon"
onClick={this.openSearch} onClick={this.openSearch}
/> />
); );

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")}
@ -329,7 +328,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
className={classes} className={classes}
onClick={this.onOpenMenuClick} onClick={this.onOpenMenuClick}
inputRef={this.buttonRef} inputRef={this.buttonRef}
label={_t("Account settings")} label={_t("User menu")}
isExpanded={!!this.state.contextMenuPosition} isExpanded={!!this.state.contextMenuPosition}
onContextMenu={this.onContextMenu} onContextMenu={this.onContextMenu}
> >
@ -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,19 +86,20 @@ 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,
className,
...otherProps ...otherProps
} = props; } = props;
@ -117,12 +134,12 @@ const BaseAvatar = (props) => {
aria-hidden="true" /> aria-hidden="true" />
); );
if (onClick != null) { if (onClick !== null) {
return ( return (
<AccessibleButton <AccessibleButton
{...otherProps} {...otherProps}
element="span" element="span"
className="mx_BaseAvatar" className={classNames("mx_BaseAvatar", className)}
onClick={onClick} onClick={onClick}
inputRef={inputRef} inputRef={inputRef}
> >
@ -132,7 +149,12 @@ const BaseAvatar = (props) => {
); );
} else { } else {
return ( return (
<span className="mx_BaseAvatar" ref={inputRef} {...otherProps}> <span
className={classNames("mx_BaseAvatar", className)}
ref={inputRef}
{...otherProps}
role="presentation"
>
{ textNode } { textNode }
{ imgNode } { imgNode }
</span> </span>
@ -140,10 +162,10 @@ const BaseAvatar = (props) => {
} }
} }
if (onClick != null) { if (onClick !== null) {
return ( return (
<AccessibleButton <AccessibleButton
className="mx_BaseAvatar mx_BaseAvatar_image" className={classNames("mx_BaseAvatar mx_BaseAvatar_image", className)}
element='img' element='img'
src={imageUrl} src={imageUrl}
onClick={onClick} onClick={onClick}
@ -159,7 +181,7 @@ const BaseAvatar = (props) => {
} else { } else {
return ( return (
<img <img
className="mx_BaseAvatar mx_BaseAvatar_image" className={classNames("mx_BaseAvatar mx_BaseAvatar_image", className)}
src={imageUrl} src={imageUrl}
onError={onError} onError={onError}
style={{ style={{
@ -173,26 +195,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,35 +111,32 @@ 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() { public render() {
const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
/*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props; const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props;
const roomName = room ? room.name : oobData.name; const roomName = room ? room.name : oobData.name;
return ( return (
<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={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

@ -16,7 +16,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {_t} from '../../../languageHandler.js'; import {_t} from '../../../languageHandler';
import Field from "./Field"; import Field from "./Field";
import AccessibleButton from "./AccessibleButton"; import AccessibleButton from "./AccessibleButton";

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";
@ -28,8 +27,8 @@ import RoomListStore from "../../../stores/room-list/RoomListStore2";
import { DefaultTagID } from "../../../stores/room-list/models"; import { DefaultTagID } from "../../../stores/room-list/models";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
// 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/14367
// 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/14367
/******************************************************************* /*******************************************************************
* CAUTION * * CAUTION *
@ -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

@ -17,27 +17,32 @@ limitations under the License.
*/ */
import * as React from "react"; import * as React from "react";
import { Dispatcher } from "flux";
import { Room } from "matrix-js-sdk/src/models/room";
import { _t, _td } from "../../../languageHandler"; import { _t, _td } from "../../../languageHandler";
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
import { ResizeNotifier } from "../../../utils/ResizeNotifier"; import { ResizeNotifier } from "../../../utils/ResizeNotifier";
import RoomListStore, { LISTS_UPDATE_EVENT, RoomListStore2 } from "../../../stores/room-list/RoomListStore2"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore2";
import RoomViewStore from "../../../stores/RoomViewStore";
import { ITagMap } from "../../../stores/room-list/algorithms/models"; import { ITagMap } from "../../../stores/room-list/algorithms/models";
import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { Dispatcher } from "flux";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import defaultDispatcher from "../../../dispatcher/dispatcher"; 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 { Action } from "../../../dispatcher/actions";
import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14367
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
/******************************************************************* /*******************************************************************
* CAUTION * * CAUTION *
@ -51,6 +56,7 @@ interface IProps {
onKeyDown: (ev: React.KeyboardEvent) => void; onKeyDown: (ev: React.KeyboardEvent) => void;
onFocus: (ev: React.FocusEvent) => void; onFocus: (ev: React.FocusEvent) => void;
onBlur: (ev: React.FocusEvent) => void; onBlur: (ev: React.FocusEvent) => void;
onResize: () => void;
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
collapsed: boolean; collapsed: boolean;
searchFilter: string; searchFilter: string;
@ -59,12 +65,9 @@ interface IProps {
interface IState { interface IState {
sublists: ITagMap; sublists: ITagMap;
layouts: Map<TagID, ListLayout>;
} }
const TAG_ORDER: TagID[] = [ const TAG_ORDER: TagID[] = [
// -- Community Invites Placeholder --
DefaultTagID.Invite, DefaultTagID.Invite,
DefaultTagID.Favourite, DefaultTagID.Favourite,
DefaultTagID.DM, DefaultTagID.DM,
@ -76,7 +79,6 @@ const TAG_ORDER: TagID[] = [
DefaultTagID.ServerNotice, DefaultTagID.ServerNotice,
DefaultTagID.Archived, DefaultTagID.Archived,
]; ];
const COMMUNITY_TAGS_BEFORE_TAG = DefaultTagID.Invite;
const CUSTOM_TAGS_BEFORE_TAG = DefaultTagID.LowPriority; const CUSTOM_TAGS_BEFORE_TAG = DefaultTagID.LowPriority;
const ALWAYS_VISIBLE_TAGS: TagID[] = [ const ALWAYS_VISIBLE_TAGS: TagID[] = [
DefaultTagID.DM, DefaultTagID.DM,
@ -140,14 +142,16 @@ const TAG_AESTHETICS: {
export default class RoomList2 extends React.Component<IProps, IState> { export default class RoomList2 extends React.Component<IProps, IState> {
private searchFilter: NameFilterCondition = new NameFilterCondition(); private searchFilter: NameFilterCondition = new NameFilterCondition();
private dispatcherRef;
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
this.state = { this.state = {
sublists: {}, sublists: {},
layouts: new Map<TagID, ListLayout>(),
}; };
this.dispatcherRef = defaultDispatcher.register(this.onAction);
} }
public componentDidUpdate(prevProps: Readonly<IProps>): void { public componentDidUpdate(prevProps: Readonly<IProps>): void {
@ -172,25 +176,64 @@ export default class RoomList2 extends React.Component<IProps, IState> {
public componentWillUnmount() { public componentWillUnmount() {
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists);
defaultDispatcher.unregister(this.dispatcherRef);
} }
private onAction = (payload: ActionPayload) => {
if (payload.action === Action.ViewRoomDelta) {
const viewRoomDeltaPayload = payload as ViewRoomDeltaPayload;
const currentRoomId = RoomViewStore.getRoomId();
const room = this.getRoomDelta(currentRoomId, viewRoomDeltaPayload.delta, viewRoomDeltaPayload.unread);
if (room) {
dis.dispatch({
action: 'view_room',
room_id: room.roomId,
show_room_tile: true, // to make sure the room gets scrolled into view
});
}
}
};
private getRoomDelta = (roomId: string, delta: number, unread = false) => {
const lists = RoomListStore.instance.orderedLists;
let rooms: Room = [];
TAG_ORDER.forEach(t => {
let listRooms = lists[t];
if (unread) {
// filter to only notification rooms (and our current active room so we can index properly)
listRooms = listRooms.filter(r => {
const state = RoomNotificationStateStore.instance.getRoomState(r, t);
return state.room.roomId === roomId || state.isUnread;
});
}
rooms.push(...listRooms);
});
const currentIndex = rooms.findIndex(r => r.roomId === roomId);
// use slice to account for looping around the start
const [room] = rooms.slice((currentIndex + delta) % rooms.length);
return room;
};
private updateLists = () => { private updateLists = () => {
const newLists = RoomListStore.instance.orderedLists; const newLists = RoomListStore.instance.orderedLists;
console.log("new lists", newLists); if (!window.mx_QuietRoomListLogging) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
const layoutMap = new Map<TagID, ListLayout>(); console.log("new lists", newLists);
for (const tagId of Object.keys(newLists)) {
layoutMap.set(tagId, new ListLayout(tagId));
} }
this.setState({sublists: newLists, layouts: layoutMap}); this.setState({sublists: newLists}, () => {
this.props.onResize();
});
}; };
private renderCommunityInvites(): React.ReactElement[] { private renderCommunityInvites(): React.ReactElement[] {
// TODO: Put community invites in a more sensible place (not in the room list) // TODO: Put community invites in a more sensible place (not in the room list)
return MatrixClientPeg.get().getGroups().filter(g => { return MatrixClientPeg.get().getGroups().filter(g => {
if (g.myMembership !== 'invite') return false; if (g.myMembership !== 'invite') return false;
return !this.searchFilter || this.searchFilter.matches(g.name); return !this.searchFilter || this.searchFilter.matches(g.name || "");
}).map(g => { }).map(g => {
const avatar = ( const avatar = (
<GroupAvatar <GroupAvatar
@ -224,17 +267,15 @@ export default class RoomList2 extends React.Component<IProps, IState> {
const components: React.ReactElement[] = []; const components: React.ReactElement[] = [];
for (const orderedTagId of TAG_ORDER) { for (const orderedTagId of TAG_ORDER) {
if (COMMUNITY_TAGS_BEFORE_TAG === orderedTagId) {
// Populate community invites if we have the chance
// TODO: Community invites: https://github.com/vector-im/riot-web/issues/14179
}
if (CUSTOM_TAGS_BEFORE_TAG === orderedTagId) { if (CUSTOM_TAGS_BEFORE_TAG === orderedTagId) {
// Populate custom tags if needed // Populate custom tags if needed
// TODO: Custom tags: https://github.com/vector-im/riot-web/issues/14091 // TODO: Custom tags: https://github.com/vector-im/riot-web/issues/14091
} }
const orderedRooms = this.state.sublists[orderedTagId] || []; const orderedRooms = this.state.sublists[orderedTagId] || [];
if (orderedRooms.length === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) { const extraTiles = orderedTagId === DefaultTagID.Invite ? this.renderCommunityInvites() : null;
const totalTiles = orderedRooms.length + (extraTiles ? extraTiles.length : 0);
if (totalTiles === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) {
continue; // skip tag - not needed continue; // skip tag - not needed
} }
@ -242,7 +283,6 @@ export default class RoomList2 extends React.Component<IProps, IState> {
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`); if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null; const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null;
const extraTiles = orderedTagId === DefaultTagID.Invite ? this.renderCommunityInvites() : null;
components.push( components.push(
<RoomSublist2 <RoomSublist2
key={`sublist-${orderedTagId}`} key={`sublist-${orderedTagId}`}
@ -253,10 +293,10 @@ 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}
extraBadTilesThatShouldntExist={extraTiles} extraBadTilesThatShouldntExist={extraTiles}
isFiltered={!!this.searchFilter.search}
/> />
); );
} }
@ -276,9 +316,6 @@ export default class RoomList2 extends React.Component<IProps, IState> {
className="mx_RoomList2" className="mx_RoomList2"
role="tree" role="tree"
aria-label={_t("Rooms")} aria-label={_t("Rooms")}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>{sublists}</div> >{sublists}</div>
)} )}
</RovingTabIndexProvider> </RovingTabIndexProvider>

View file

@ -17,30 +17,39 @@ limitations under the License.
*/ */
import * as React from "react"; import * as React from "react";
import { createRef } from "react"; import {createRef, UIEventHandler} 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 { RovingAccessibleButton, RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
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 { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; import {
import StyledCheckbox from "../elements/StyledCheckbox"; ChevronFace,
import StyledRadioButton from "../elements/StyledRadioButton"; ContextMenu,
ContextMenuButton,
StyledMenuItemCheckbox,
StyledMenuItemRadio,
} from "../../structures/ContextMenu";
import RoomListStore from "../../../stores/room-list/RoomListStore2"; import RoomListStore from "../../../stores/room-list/RoomListStore2";
import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models"; import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models";
import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import NotificationBadge from "./NotificationBadge"; import NotificationBadge from "./NotificationBadge";
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState"; import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
import Tooltip from "../elements/Tooltip";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
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/14367
// 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/14367
/******************************************************************* /*******************************************************************
* CAUTION * * CAUTION *
@ -50,11 +59,15 @@ import { Key } from "../../../Keyboard";
* warning disappears. * * warning disappears. *
*******************************************************************/ *******************************************************************/
const SHOW_N_BUTTON_HEIGHT = 32; // As defined by CSS const SHOW_N_BUTTON_HEIGHT = 28; // 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[];
@ -62,10 +75,10 @@ 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;
isFiltered: boolean;
// TODO: Don't use this. It's for community invites, and community invites shouldn't be here. // TODO: Don't use this. It's for community invites, and community invites shouldn't be here.
// You should feel bad if you use this. // You should feel bad if you use this.
@ -74,78 +87,178 @@ interface IProps {
// TODO: Account for https://github.com/vector-im/riot-web/issues/14179 // TODO: Account for https://github.com/vector-im/riot-web/issues/14179
} }
// TODO: Use re-resizer's NumberSize when it is exposed as the type
interface ResizeDelta {
width: number;
height: number;
}
type PartialDOMRect = Pick<DOMRect, "left" | "top" | "height">; type PartialDOMRect = Pick<DOMRect, "left" | "top" | "height">;
interface IState { interface IState {
notificationState: ListNotificationState; notificationState: ListNotificationState;
contextMenuPosition: PartialDOMRect; contextMenuPosition: PartialDOMRect;
isResizing: boolean; isResizing: boolean;
isExpanded: boolean; // used for the for expand of the sublist when the room list is being filtered
height: number;
} }
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;
private heightAtStart: number;
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
this.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId);
this.heightAtStart = 0;
const height = this.calculateInitialHeight();
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,
isExpanded: this.props.isFiltered ? this.props.isFiltered : !this.layout.isCollapsed,
height,
}; };
this.state.notificationState.setRooms(this.props.rooms); this.state.notificationState.setRooms(this.props.rooms);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
}
private calculateInitialHeight() {
const requestedVisibleTiles = Math.max(Math.floor(this.layout.visibleTiles), this.layout.minVisibleTiles);
const tileCount = Math.min(this.numTiles, requestedVisibleTiles);
return this.layout.tilesToPixelsWithPadding(tileCount, this.padding);
}
private get padding() {
let padding = RESIZE_HANDLE_HEIGHT;
// this is used for calculating the max height of the whole container,
// and takes into account whether there should be room reserved for the show less button
// when fully expanded. Note that the show more button might still be shown when not fully expanded,
// but in this case it will take the space of a tile and we don't need to reserve space for it.
if (this.numTiles > this.layout.defaultVisibleTiles) {
padding += SHOW_N_BUTTON_HEIGHT;
}
return padding;
} }
private get numTiles(): number { private get numTiles(): number {
return (this.props.rooms || []).length + (this.props.extraBadTilesThatShouldntExist || []).length; return RoomSublist2.calcNumTiles(this.props);
}
private static calcNumTiles(props) {
return (props.rooms || []).length + (props.extraBadTilesThatShouldntExist || []).length;
} }
private get numVisibleTiles(): number { private get numVisibleTiles(): number {
if (!this.props.layout) return 0; const nVisible = Math.ceil(this.layout.visibleTiles);
const nVisible = Math.floor(this.props.layout.visibleTiles);
return Math.min(nVisible, this.numTiles); return Math.min(nVisible, this.numTiles);
} }
public componentDidUpdate() { public componentDidUpdate(prevProps: Readonly<IProps>) {
this.state.notificationState.setRooms(this.props.rooms); this.state.notificationState.setRooms(this.props.rooms);
if (prevProps.isFiltered !== this.props.isFiltered) {
if (this.props.isFiltered) {
this.setState({isExpanded: true});
} else {
this.setState({isExpanded: !this.layout.isCollapsed});
}
}
// as the rooms can come in one by one we need to reevaluate
// the amount of available rooms to cap the amount of requested visible rooms by the layout
if (RoomSublist2.calcNumTiles(prevProps) !== this.numTiles) {
this.setState({height: this.calculateInitialHeight()});
}
} }
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 roomIndex = this.props.rooms.findIndex((r) => r.roomId === payload.room_id);
if (!this.state.isExpanded && roomIndex > -1) {
this.toggleCollapsed();
}
// extend the visible section to include the room if it is entirely invisible
if (roomIndex >= this.numVisibleTiles) {
this.layout.visibleTiles = this.layout.tilesWithPadding(roomIndex + 1, MAX_PADDING_HEIGHT);
this.forceUpdate(); // because the layout doesn't trigger a re-render
}
});
}
};
private onAddRoom = (e) => { 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 applyHeightChange(newHeight: number) {
const direction = e.movementY < 0 ? -1 : +1; const heightInTiles = Math.ceil(this.layout.pixelsToTiles(newHeight - this.padding));
const tileDiff = this.props.layout.pixelsToTiles(Math.abs(e.movementY)) * direction; this.layout.visibleTiles = Math.min(this.numTiles, heightInTiles);
this.props.layout.setVisibleTilesWithin(tileDiff, this.numTiles); }
this.forceUpdate(); // because the layout doesn't trigger a re-render
private onResize = (
e: MouseEvent | TouchEvent,
travelDirection: Direction,
refToElement: HTMLDivElement,
delta: ResizeDelta,
) => {
const newHeight = this.heightAtStart + delta.height;
this.applyHeightChange(newHeight);
this.setState({height: newHeight});
}; };
private onResizeStart = () => { private onResizeStart = () => {
this.heightAtStart = this.state.height;
this.setState({isResizing: true}); this.setState({isResizing: true});
}; };
private onResizeStop = () => { private onResizeStop = (
this.setState({isResizing: false}); e: MouseEvent | TouchEvent,
travelDirection: Direction,
refToElement: HTMLDivElement,
delta: ResizeDelta,
) => {
const newHeight = this.heightAtStart + delta.height;
this.applyHeightChange(newHeight);
this.setState({isResizing: false, height: newHeight});
}; };
private onShowAllClick = () => { private onShowAllClick = () => {
this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT); const newHeight = this.layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
this.forceUpdate(); // because the layout doesn't trigger a re-render this.applyHeightChange(newHeight);
this.setState({height: newHeight}, () => {
this.focusRoomTile(this.numTiles - 1);
});
}; };
private onShowLessClick = () => { private onShowLessClick = () => {
this.props.layout.visibleTiles = this.props.layout.defaultVisibleTiles; const newHeight = this.layout.tilesToPixelsWithPadding(this.layout.defaultVisibleTiles, this.padding);
this.forceUpdate(); // because the layout doesn't trigger a re-render this.applyHeightChange(newHeight);
this.setState({height: newHeight});
}; };
private onOpenMenuClick = (ev: InputEvent) => { private focusRoomTile = (index: number) => {
if (!this.sublistRef.current) return;
const elements = this.sublistRef.current.querySelectorAll<HTMLDivElement>(".mx_RoomTile2");
const element = elements && elements[index];
if (element) {
element.focus();
}
};
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;
@ -179,7 +292,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
}; };
@ -203,6 +316,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
room_id: room.roomId, room_id: room.roomId,
show_room_tile: true, // to make sure the room gets scrolled into view
}); });
} }
}; };
@ -216,7 +330,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 {
@ -226,23 +344,23 @@ 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.state.isExpanded;
this.forceUpdate(); // because the layout doesn't trigger an update this.setState({isExpanded: !this.layout.isCollapsed});
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;
switch (ev.key) { switch (ev.key) {
case Key.ARROW_LEFT: case Key.ARROW_LEFT:
ev.stopPropagation(); ev.stopPropagation();
if (!isCollapsed) { if (this.state.isExpanded) {
// On ARROW_LEFT collapse the room sublist if it isn't already // On ARROW_LEFT collapse the room sublist if it isn't already
this.toggleCollapsed(); this.toggleCollapsed();
} }
break; break;
case Key.ARROW_RIGHT: { case Key.ARROW_RIGHT: {
ev.stopPropagation(); ev.stopPropagation();
if (isCollapsed) { if (!this.state.isExpanded) {
// On ARROW_RIGHT expand the room sublist if it isn't already // On ARROW_RIGHT expand the room sublist if it isn't already
this.toggleCollapsed(); this.toggleCollapsed();
} else if (this.sublistRef.current) { } else if (this.sublistRef.current) {
@ -271,17 +389,13 @@ 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.state.isExpanded) {
// don't waste time on rendering // don't waste time on rendering
return []; return [];
} }
const tiles: React.ReactElement[] = []; const tiles: React.ReactElement[] = [];
if (this.props.extraBadTilesThatShouldntExist) {
tiles.push(...this.props.extraBadTilesThatShouldntExist);
}
if (this.props.rooms) { if (this.props.rooms) {
const visibleRooms = this.props.rooms.slice(0, this.numVisibleTiles); const visibleRooms = this.props.rooms.slice(0, this.numVisibleTiles);
for (const room of visibleRooms) { for (const room of visibleRooms) {
@ -289,7 +403,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}
/> />
@ -297,6 +411,10 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
} }
} }
if (this.props.extraBadTilesThatShouldntExist) {
tiles.push(...this.props.extraBadTilesThatShouldntExist);
}
// We only have to do this because of the extra tiles. We do it conditionally // We only have to do this because of the extra tiles. We do it conditionally
// to avoid spending cycles on slicing. It's generally fine to do this though // to avoid spending cycles on slicing. It's generally fine to do this though
// as users are unlikely to have more than a handful of tiles when the extra // as users are unlikely to have more than a handful of tiles when the extra
@ -309,18 +427,45 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
} }
private renderMenu(): React.ReactElement { private renderMenu(): React.ReactElement {
// TODO: Get a proper invite context menu, or take invites out of the room list.
if (this.props.tagId === DefaultTagID.Invite) {
return null;
}
let contextMenu = null; let contextMenu = null;
if (this.state.contextMenuPosition) { if (this.state.contextMenuPosition) {
const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic; const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
// Invites don't get some nonsense options, so only add them if we have to.
let otherSections = null;
if (this.props.tagId !== DefaultTagID.Invite) {
otherSections = (
<React.Fragment>
<hr />
<div>
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Unread rooms")}</div>
<StyledMenuItemCheckbox
onClose={this.onCloseMenu}
onChange={this.onUnreadFirstChanged}
checked={isUnreadFirst}
>
{_t("Always show first")}
</StyledMenuItemCheckbox>
</div>
<hr />
<div>
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Show")}</div>
<StyledMenuItemCheckbox
onClose={this.onCloseMenu}
onChange={this.onMessagePreviewChanged}
checked={this.layout.showPreviews}
>
{_t("Message preview")}
</StyledMenuItemCheckbox>
</div>
</React.Fragment>
);
}
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}
@ -328,41 +473,24 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
<div className="mx_RoomSublist2_contextMenu"> <div className="mx_RoomSublist2_contextMenu">
<div> <div>
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Sort by")}</div> <div className='mx_RoomSublist2_contextMenu_title'>{_t("Sort by")}</div>
<StyledRadioButton <StyledMenuItemRadio
onClose={this.onCloseMenu}
onChange={() => this.onTagSortChanged(SortAlgorithm.Recent)} onChange={() => this.onTagSortChanged(SortAlgorithm.Recent)}
checked={!isAlphabetical} checked={!isAlphabetical}
name={`mx_${this.props.tagId}_sortBy`} name={`mx_${this.props.tagId}_sortBy`}
> >
{_t("Activity")} {_t("Activity")}
</StyledRadioButton> </StyledMenuItemRadio>
<StyledRadioButton <StyledMenuItemRadio
onClose={this.onCloseMenu}
onChange={() => this.onTagSortChanged(SortAlgorithm.Alphabetic)} onChange={() => this.onTagSortChanged(SortAlgorithm.Alphabetic)}
checked={isAlphabetical} checked={isAlphabetical}
name={`mx_${this.props.tagId}_sortBy`} name={`mx_${this.props.tagId}_sortBy`}
> >
{_t("A-Z")} {_t("A-Z")}
</StyledRadioButton> </StyledMenuItemRadio>
</div>
<hr />
<div>
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Unread rooms")}</div>
<StyledCheckbox
onChange={this.onUnreadFirstChanged}
checked={isUnreadFirst}
>
{_t("Always show first")}
</StyledCheckbox>
</div>
<hr />
<div>
<div className='mx_RoomSublist2_contextMenu_title'>{_t("Show")}</div>
<StyledCheckbox
onChange={this.onMessagePreviewChanged}
checked={this.props.layout.showPreviews}
>
{_t("Message preview")}
</StyledCheckbox>
</div> </div>
{otherSections}
</div> </div>
</ContextMenu> </ContextMenu>
); );
@ -383,16 +511,22 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
private renderHeader(): React.ReactElement { private renderHeader(): React.ReactElement {
return ( return (
<RovingTabIndexWrapper> <RovingTabIndexWrapper inputRef={this.headerButton}>
{({onFocus, isActive, ref}) => { {({onFocus, isActive, ref}) => {
const tabIndex = isActive ? 0 : -1; const tabIndex = isActive ? 0 : -1;
let ariaLabel = _t("Jump to first unread room.");
if (this.props.tagId === DefaultTagID.Invite) {
ariaLabel = _t("Jump to first invite.");
}
const badge = ( const badge = (
<NotificationBadge <NotificationBadge
forceCount={true} forceCount={true}
notification={this.state.notificationState} notification={this.state.notificationState}
onClick={this.onBadgeClick} onClick={this.onBadgeClick}
tabIndex={tabIndex} tabIndex={tabIndex}
aria-label={ariaLabel}
/> />
); );
@ -412,7 +546,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.state.isExpanded,
}); });
const classes = classNames({ const classes = classNames({
@ -426,14 +560,13 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
</div> </div>
); );
// TODO: a11y (see old component): https://github.com/vector-im/riot-web/issues/14180
// Note: the addRoomButton conditionally gets moved around // Note: the addRoomButton conditionally gets moved around
// the DOM depending on whether or not the list is minimized. // the DOM depending on whether or not the list is minimized.
// If we're minimized, we want it below the header so it // If we're minimized, we want it below the header so it
// doesn't become sticky. // doesn't become sticky.
// The same applies to the notification badge. // The same applies to the notification badge.
return ( return (
<div className={classes} onKeyDown={this.onHeaderKeyDown} onFocus={onFocus}> <div className={classes} onKeyDown={this.onHeaderKeyDown} onFocus={onFocus} aria-label={this.props.label}>
<div className="mx_RoomSublist2_stickable"> <div className="mx_RoomSublist2_stickable">
<AccessibleButton <AccessibleButton
onFocus={onFocus} onFocus={onFocus}
@ -441,6 +574,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.state.isExpanded}
aria-level={1} aria-level={1}
onClick={this.onHeaderClick} onClick={this.onHeaderClick}
onContextMenu={this.onContextMenu} onContextMenu={this.onContextMenu}
@ -461,11 +595,16 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
); );
} }
private onScrollPrevent(e: React.UIEvent<HTMLDivElement>) {
// the RoomTile calls scrollIntoView and the browser may scroll a div we do not wish to be scrollable
// this fixes https://github.com/vector-im/riot-web/issues/14413
(e.target as HTMLDivElement).scrollTop = 0;
}
public render(): React.ReactElement { public render(): React.ReactElement {
// TODO: Error boundary: https://github.com/vector-im/riot-web/issues/14185 // TODO: Error boundary: https://github.com/vector-im/riot-web/issues/14185
const visibleTiles = this.renderVisibleTiles(); const visibleTiles = this.renderVisibleTiles();
const classes = classNames({ const classes = classNames({
'mx_RoomSublist2': true, 'mx_RoomSublist2': true,
'mx_RoomSublist2_hasMenuOpen': !!this.state.contextMenuPosition, 'mx_RoomSublist2_hasMenuOpen': !!this.state.contextMenuPosition,
@ -474,21 +613,26 @@ 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 minTiles = Math.min(layout.minVisibleTiles, this.numTiles);
const showMoreAtMinHeight = minTiles < this.numTiles;
const minHeightPadding = RESIZE_HANDLE_HEIGHT + (showMoreAtMinHeight ? SHOW_N_BUTTON_HEIGHT : 0);
const minTilesPx = layout.tilesToPixelsWithPadding(minTiles, minHeightPadding);
const maxTilesPx = layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
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
// floats above the resize handle, if we have one present. If the user has all // floats above the resize handle, if we have one present. If the user has all
// tiles visible, it becomes 'show less'. // tiles visible, it becomes 'show less'.
let showNButton = null; let showNButton = null;
if (this.numTiles > visibleTiles.length) {
// we have a cutoff condition - add the button to show all if (maxTilesPx > this.state.height) {
const numMissing = this.numTiles - visibleTiles.length; const nonPaddedHeight = this.state.height - RESIZE_HANDLE_HEIGHT - SHOW_N_BUTTON_HEIGHT;
const amountFullyShown = Math.floor(nonPaddedHeight / this.layout.tileHeight);
const numMissing = this.numTiles - amountFullyShown;
let showMoreText = ( let showMoreText = (
<span className='mx_RoomSublist2_showNButtonText'> <span className='mx_RoomSublist2_showNButtonText'>
{_t("Show %(count)s more", {count: numMissing})} {_t("Show %(count)s more", {count: numMissing})}
@ -496,14 +640,14 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
); );
if (this.props.isMinimized) showMoreText = null; if (this.props.isMinimized) showMoreText = null;
showNButton = ( showNButton = (
<div onClick={this.onShowAllClick} className={showMoreBtnClasses}> <RovingAccessibleButton onClick={this.onShowAllClick} className={showMoreBtnClasses}>
<span className='mx_RoomSublist2_showMoreButtonChevron mx_RoomSublist2_showNButtonChevron'> <span className='mx_RoomSublist2_showMoreButtonChevron mx_RoomSublist2_showNButtonChevron'>
{/* set by CSS masking */} {/* set by CSS masking */}
</span> </span>
{showMoreText} {showMoreText}
</div> </RovingAccessibleButton>
); );
} else if (this.numTiles <= visibleTiles.length && this.numTiles > this.props.layout.defaultVisibleTiles) { } else if (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'>
@ -512,19 +656,29 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
); );
if (this.props.isMinimized) showLessText = null; if (this.props.isMinimized) showLessText = null;
showNButton = ( showNButton = (
<div onClick={this.onShowLessClick} className={showMoreBtnClasses}> <RovingAccessibleButton onClick={this.onShowLessClick} className={showMoreBtnClasses}>
<span className='mx_RoomSublist2_showLessButtonChevron mx_RoomSublist2_showNButtonChevron'> <span className='mx_RoomSublist2_showLessButtonChevron mx_RoomSublist2_showNButtonChevron'>
{/* set by CSS masking */} {/* set by CSS masking */}
</span> </span>
{showLessText} {showLessText}
</div> </RovingAccessibleButton>
); );
} }
// 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
@ -537,33 +691,31 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
// goes backwards and can become wildly incorrect (visibleTiles says 18 when there's // goes backwards and can become wildly incorrect (visibleTiles says 18 when there's
// only mathematically 7 possible). // only mathematically 7 possible).
// The padding is variable though, so figure out what we need padding for. const handleWrapperClasses = classNames({
let padding = 0; 'mx_RoomSublist2_resizerHandles': true,
if (showNButton) padding += SHOW_N_BUTTON_HEIGHT; 'mx_RoomSublist2_resizerHandles_showNButton': !!showNButton,
padding += RESIZE_HANDLE_HEIGHT; // always append the handle height });
const relativeTiles = layout.tilesWithPadding(this.numTiles, padding);
const minTilesPx = layout.calculateTilesToPixelsMin(relativeTiles, layout.minVisibleTiles, padding);
const maxTilesPx = layout.tilesToPixelsWithPadding(this.numTiles, padding);
const tilesWithoutPadding = Math.min(relativeTiles, layout.visibleTiles);
const tilesPx = layout.calculateTilesToPixelsMin(relativeTiles, tilesWithoutPadding, padding);
content = ( content = (
<ResizableBox <React.Fragment>
width={-1} <Resizable
height={tilesPx} size={{height: this.state.height} as any}
axis="y" minHeight={minTilesPx}
minConstraints={[-1, minTilesPx]} maxHeight={maxTilesPx}
maxConstraints={[-1, maxTilesPx]} onResizeStart={this.onResizeStart}
resizeHandles={handles} onResizeStop={this.onResizeStop}
onResize={this.onResize} onResize={this.onResize}
className="mx_RoomSublist2_resizeBox" handleWrapperClass={handleWrapperClasses}
onResizeStart={this.onResizeStart} handleClasses={{bottom: "mx_RoomSublist2_resizerHandle"}}
onResizeStop={this.onResizeStop} className="mx_RoomSublist2_resizeBox"
> enable={handles}
{visibleTiles} >
{showNButton} <div className="mx_RoomSublist2_tiles" onScroll={this.onScrollPrevent}>
</ResizableBox> {visibleTiles}
</div>
{showNButton}
</Resizable>
</React.Fragment>
); );
} }

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";
@ -26,20 +26,37 @@ import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import ActiveRoomObserver from "../../../ActiveRoomObserver"; import ActiveRoomObserver from "../../../ActiveRoomObserver";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { ContextMenu, ContextMenuButton, MenuItemRadio } from "../../structures/ContextMenu"; import {
ChevronFace,
ContextMenu,
ContextMenuButton,
MenuItemRadio,
MenuItemCheckbox,
MenuItem,
} from "../../structures/ContextMenu";
import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { getRoomNotifsState, ALL_MESSAGES, ALL_MESSAGES_LOUD, MENTIONS_ONLY, MUTE } from "../../../RoomNotifs"; import {
getRoomNotifsState,
setRoomNotifsState,
ALL_MESSAGES,
ALL_MESSAGES_LOUD,
MENTIONS_ONLY,
MUTE,
} from "../../../RoomNotifs";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { setRoomNotifsState } from "../../../RoomNotifs";
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 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/14367
// 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/14367
/******************************************************************* /*******************************************************************
* CAUTION * * CAUTION *
@ -62,17 +79,19 @@ 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;
} }
const messagePreviewId = (roomId: string) => `mx_RoomTile2_messagePreview_${roomId}`;
const contextMenuBelow = (elementRect: PartialDOMRect) => { 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};
}; };
@ -103,6 +122,8 @@ 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 // TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
constructor(props: IProps) { constructor(props: IProps) {
@ -110,25 +131,54 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
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 {
return !this.props.isMinimized && this.props.tag !== DefaultTagID.Invite; return !this.props.isMinimized && this.props.tag !== DefaultTagID.Invite;
} }
private get showMessagePreview(): boolean {
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});
}; };
@ -142,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)),
@ -153,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;
@ -164,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;
@ -193,8 +242,27 @@ 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.warn(`Unexpected tag ${tagId} applied to ${this.props.room.room_id}`);
}
if ((ev as React.KeyboardEvent).key === Key.ENTER) {
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
this.setState({generalMenuPosition: null}); // hide the menu
}
}; };
private onLeaveRoomClick = (ev: ButtonEvent) => { private onLeaveRoomClick = (ev: ButtonEvent) => {
@ -219,11 +287,13 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
this.setState({generalMenuPosition: null}); // hide the menu this.setState({generalMenuPosition: null}); // hide the menu
}; };
private async saveNotifState(ev: ButtonEvent, newState: ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE) { private async saveNotifState(ev: ButtonEvent, newState: Volume) {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
if (MatrixClientPeg.get().isGuest()) return; if (MatrixClientPeg.get().isGuest()) return;
// get key before we go async and React discards the nativeEvent
const key = (ev as React.KeyboardEvent).key;
try { try {
// TODO add local echo - https://github.com/vector-im/riot-web/issues/14280 // TODO add local echo - https://github.com/vector-im/riot-web/issues/14280
await setRoomNotifsState(this.props.room.roomId, newState); await setRoomNotifsState(this.props.room.roomId, newState);
@ -233,7 +303,10 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
console.error(error); console.error(error);
} }
this.setState({notificationsMenuPosition: null}); // Close the context menu if (key === Key.ENTER) {
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
this.setState({notificationsMenuPosition: null}); // hide the menu
}
} }
private onClickAllNotifs = ev => this.saveNotifState(ev, ALL_MESSAGES); private onClickAllNotifs = ev => this.saveNotifState(ev, ALL_MESSAGES);
@ -316,26 +389,38 @@ 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 = (
<ContextMenu {...contextMenuBelow(this.state.generalMenuPosition)} onFinished={this.onCloseGeneralMenu}> <ContextMenu {...contextMenuBelow(this.state.generalMenuPosition)} onFinished={this.onCloseGeneralMenu}>
<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">
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}> <MenuItemCheckbox
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" /> className={favouriteLabelClassName}
<span className="mx_IconizedContextMenu_label">{_t("Favourite")}</span> onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}
</AccessibleButton> active={isFavorite}
<AccessibleButton onClick={this.onOpenRoomSettings}> label={favouriteLabel}
>
<span className={classNames("mx_IconizedContextMenu_icon", favouriteIconClassName)} />
<span className="mx_IconizedContextMenu_label">{favouriteLabel}</span>
</MenuItemCheckbox>
<MenuItem onClick={this.onOpenRoomSettings} label={_t("Settings")}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" /> <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" />
<span className="mx_IconizedContextMenu_label">{_t("Settings")}</span> <span className="mx_IconizedContextMenu_label">{_t("Settings")}</span>
</AccessibleButton> </MenuItem>
</div> </div>
<div className="mx_IconizedContextMenu_optionList mx_RoomTile2_contextMenu_redRow"> <div className="mx_IconizedContextMenu_optionList mx_RoomTile2_contextMenu_redRow">
<AccessibleButton onClick={this.onLeaveRoomClick}> <MenuItem onClick={this.onLeaveRoomClick} label={_t("Leave Room")}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" /> <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" />
<span className="mx_IconizedContextMenu_label">{_t("Leave Room")}</span> <span className="mx_IconizedContextMenu_label">{_t("Leave Room")}</span>
</AccessibleButton> </MenuItem>
</div> </div>
</div> </div>
</ContextMenu> </ContextMenu>
@ -357,7 +442,6 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
public render(): React.ReactElement { public render(): React.ReactElement {
// TODO: Invites: https://github.com/vector-im/riot-web/issues/14198 // TODO: Invites: https://github.com/vector-im/riot-web/issues/14198
// TODO: a11y proper: https://github.com/vector-im/riot-web/issues/14180
const classes = classNames({ const classes = classNames({
'mx_RoomTile2': true, 'mx_RoomTile2': true,
@ -375,8 +459,9 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
let badge: React.ReactNode; let badge: React.ReactNode;
if (!this.props.isMinimized) { if (!this.props.isMinimized) {
// aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
badge = ( badge = (
<div className="mx_RoomTile2_badgeContainer"> <div className="mx_RoomTile2_badgeContainer" aria-hidden="true">
<NotificationBadge <NotificationBadge
notification={this.state.notificationState} notification={this.state.notificationState}
forceCount={false} forceCount={false}
@ -392,14 +477,14 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
let messagePreview = null; let messagePreview = null;
if (this.props.showMessagePreview && !this.props.isMinimized) { if (this.showMessagePreview) {
// The preview store heavily caches this info, so should be safe to hammer. // The preview store heavily caches this info, so should be safe to hammer.
const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag); const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag);
// Only show the preview if there is one to show. // Only show the preview if there is one to show.
if (text) { if (text) {
messagePreview = ( messagePreview = (
<div className="mx_RoomTile2_messagePreview"> <div className="mx_RoomTile2_messagePreview" id={messagePreviewId(this.props.room.roomId)}>
{text} {text}
</div> </div>
); );
@ -409,7 +494,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
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": this.state.notificationState.color >= NotificationColor.Bold, "mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.isUnread,
}); });
let nameContainer = ( let nameContainer = (
@ -422,9 +507,30 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
); );
if (this.props.isMinimized) nameContainer = null; if (this.props.isMinimized) nameContainer = null;
let ariaLabel = name;
// The following labels are written in such a fashion to increase screen reader efficiency (speed).
if (this.props.tag === DefaultTagID.Invite) {
// append nothing
} else if (this.state.notificationState.hasMentions) {
ariaLabel += " " + _t("%(count)s unread messages including mentions.", {
count: this.state.notificationState.count,
});
} else if (this.state.notificationState.hasUnreadCount) {
ariaLabel += " " + _t("%(count)s unread messages.", {
count: this.state.notificationState.count,
});
} else if (this.state.notificationState.isUnread) {
ariaLabel += " " + _t("Unread messages.");
}
let ariaDescribedBy: string;
if (this.showMessagePreview) {
ariaDescribedBy = messagePreviewId(this.props.room.roomId);
}
return ( return (
<React.Fragment> <React.Fragment>
<RovingTabIndexWrapper> <RovingTabIndexWrapper inputRef={this.roomTileRef}>
{({onFocus, isActive, ref}) => {({onFocus, isActive, ref}) =>
<AccessibleButton <AccessibleButton
onFocus={onFocus} onFocus={onFocus}
@ -434,14 +540,17 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
onMouseEnter={this.onTileMouseEnter} onMouseEnter={this.onTileMouseEnter}
onMouseLeave={this.onTileMouseLeave} onMouseLeave={this.onTileMouseLeave}
onClick={this.onTileClick} onClick={this.onTileClick}
role="treeitem"
onContextMenu={this.onContextMenu} onContextMenu={this.onContextMenu}
role="treeitem"
aria-label={ariaLabel}
aria-selected={this.state.selected}
aria-describedby={ariaDescribedBy}
> >
{roomAvatar} {roomAvatar}
{nameContainer} {nameContainer}
{badge} {badge}
{this.renderNotificationsMenu(isActive)}
{this.renderGeneralMenu()} {this.renderGeneralMenu()}
{this.renderNotificationsMenu(isActive)}
</AccessibleButton> </AccessibleButton>
} }
</RovingTabIndexWrapper> </RovingTabIndexWrapper>

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()}
{this.renderFontSection()} {this.renderFontSection()}
{SettingsStore.isFeatureEnabled("feature_irc_ui") ? this.renderLayoutSection() : null}
{this.renderAdvancedSection()} {this.renderAdvancedSection()}
</div> </div>
); );

View file

@ -32,12 +32,12 @@ export default class PreferencesUserSettingsTab extends React.Component {
'breadcrumbs', 'breadcrumbs',
]; ];
// TODO: Remove temp structures: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove temp structures: https://github.com/vector-im/riot-web/issues/14367
static ROOM_LIST_2_SETTINGS = [ static ROOM_LIST_2_SETTINGS = [
'breadcrumbs', 'breadcrumbs',
]; ];
// TODO: Remove temp structures: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove temp structures: https://github.com/vector-im/riot-web/issues/14367
static eligibleRoomListSettings = () => { static eligibleRoomListSettings = () => {
if (RoomListStoreTempProxy.isUsingNewStore()) { if (RoomListStoreTempProxy.isUsingNewStore()) {
return PreferencesUserSettingsTab.ROOM_LIST_2_SETTINGS; return PreferencesUserSettingsTab.ROOM_LIST_2_SETTINGS;

View file

@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {ReactChild} from "react"; import React, {ReactNode} from "react";
import FormButton from "../elements/FormButton"; import FormButton from "../elements/FormButton";
import {XOR} from "../../../@types/common"; import {XOR} from "../../../@types/common";
export interface IProps { export interface IProps {
description: ReactChild; description: ReactNode;
acceptLabel: string; acceptLabel: string;
onAccept(); onAccept();

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

@ -15,7 +15,8 @@ limitations under the License.
*/ */
import { createContext } from "react"; import { createContext } from "react";
import { MatrixClient } from "matrix-js-sdk/src/client";
const MatrixClientContext = createContext(undefined); const MatrixClientContext = createContext<MatrixClient>(undefined);
MatrixClientContext.displayName = "MatrixClientContext"; MatrixClientContext.displayName = "MatrixClientContext";
export default MatrixClientContext; export default MatrixClientContext;

View file

@ -15,6 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {MatrixClient} from "matrix-js-sdk/src/client";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClientPeg} from './MatrixClientPeg'; import {MatrixClientPeg} from './MatrixClientPeg';
import Modal from './Modal'; import Modal from './Modal';
import * as sdk from './index'; import * as sdk from './index';
@ -26,6 +29,56 @@ import {getAddressType} from "./UserAddress";
const E2EE_WK_KEY = "im.vector.riot.e2ee"; const E2EE_WK_KEY = "im.vector.riot.e2ee";
// TODO move these interfaces over to js-sdk once it has been typescripted enough to accept them
enum Visibility {
Public = "public",
Private = "private",
}
enum Preset {
PrivateChat = "private_chat",
TrustedPrivateChat = "trusted_private_chat",
PublicChat = "public_chat",
}
interface Invite3PID {
id_server: string;
id_access_token?: string; // this gets injected by the js-sdk
medium: string;
address: string;
}
interface IStateEvent {
type: string;
state_key?: string; // defaults to an empty string
content: object;
}
interface ICreateOpts {
visibility?: Visibility;
room_alias_name?: string;
name?: string;
topic?: string;
invite?: string[];
invite_3pid?: Invite3PID[];
room_version?: string;
creation_content?: object;
initial_state?: IStateEvent[];
preset?: Preset;
is_direct?: boolean;
power_level_content_override?: object;
}
interface IOpts {
dmUserId?: string;
createOpts?: ICreateOpts;
spinner?: boolean;
guestAccess?: boolean;
encryption?: boolean;
inlineErrors?: boolean;
andView?: boolean;
}
/** /**
* Create a new room, and switch to it. * Create a new room, and switch to it.
* *
@ -40,11 +93,12 @@ const E2EE_WK_KEY = "im.vector.riot.e2ee";
* Default: False * Default: False
* @param {bool=} opts.inlineErrors True to raise errors off the promise instead of resolving to null. * @param {bool=} opts.inlineErrors True to raise errors off the promise instead of resolving to null.
* Default: False * Default: False
* @param {bool=} opts.andView True to dispatch an action to view the room once it has been created.
* *
* @returns {Promise} which resolves to the room id, or null if the * @returns {Promise} which resolves to the room id, or null if the
* action was aborted or failed. * action was aborted or failed.
*/ */
export default function createRoom(opts) { export default function createRoom(opts: IOpts): Promise<string | null> {
opts = opts || {}; opts = opts || {};
if (opts.spinner === undefined) opts.spinner = true; if (opts.spinner === undefined) opts.spinner = true;
if (opts.guestAccess === undefined) opts.guestAccess = true; if (opts.guestAccess === undefined) opts.guestAccess = true;
@ -59,12 +113,12 @@ export default function createRoom(opts) {
return Promise.resolve(null); return Promise.resolve(null);
} }
const defaultPreset = opts.dmUserId ? 'trusted_private_chat' : 'private_chat'; const defaultPreset = opts.dmUserId ? Preset.TrustedPrivateChat : Preset.PrivateChat;
// set some defaults for the creation // set some defaults for the creation
const createOpts = opts.createOpts || {}; const createOpts = opts.createOpts || {};
createOpts.preset = createOpts.preset || defaultPreset; createOpts.preset = createOpts.preset || defaultPreset;
createOpts.visibility = createOpts.visibility || 'private'; createOpts.visibility = createOpts.visibility || Visibility.Private;
if (opts.dmUserId && createOpts.invite === undefined) { if (opts.dmUserId && createOpts.invite === undefined) {
switch (getAddressType(opts.dmUserId)) { switch (getAddressType(opts.dmUserId)) {
case 'mx-user-id': case 'mx-user-id':
@ -166,7 +220,7 @@ export default function createRoom(opts) {
}); });
} }
export function findDMForUser(client, userId) { export function findDMForUser(client: MatrixClient, userId: string): Room {
const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId); const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId);
const rooms = roomIds.map(id => client.getRoom(id)); const rooms = roomIds.map(id => client.getRoom(id));
const suitableDMRooms = rooms.filter(r => { const suitableDMRooms = rooms.filter(r => {
@ -189,7 +243,7 @@ export function findDMForUser(client, userId) {
* NOTE: this assumes you've just created the room and there's not been an opportunity * NOTE: this assumes you've just created the room and there's not been an opportunity
* for other code to run, so we shouldn't miss RoomState.newMember when it comes by. * for other code to run, so we shouldn't miss RoomState.newMember when it comes by.
*/ */
export async function _waitForMember(client, roomId, userId, opts = { timeout: 1500 }) { export async function _waitForMember(client: MatrixClient, roomId: string, userId: string, opts = { timeout: 1500 }) {
const { timeout } = opts; const { timeout } = opts;
let handler; let handler;
return new Promise((resolve) => { return new Promise((resolve) => {
@ -212,7 +266,7 @@ export async function _waitForMember(client, roomId, userId, opts = { timeout: 1
* Ensure that for every user in a room, there is at least one device that we * Ensure that for every user in a room, there is at least one device that we
* can encrypt to. * can encrypt to.
*/ */
export async function canEncryptToAllUsers(client, userIds) { export async function canEncryptToAllUsers(client: MatrixClient, userIds: string[]) {
const usersDeviceMap = await client.downloadKeys(userIds); const usersDeviceMap = await client.downloadKeys(userIds);
// { "@user:host": { "DEVICE": {...}, ... }, ... } // { "@user:host": { "DEVICE": {...}, ... }, ... }
return Object.values(usersDeviceMap).every((userDevices) => return Object.values(usersDeviceMap).every((userDevices) =>
@ -221,7 +275,7 @@ export async function canEncryptToAllUsers(client, userIds) {
); );
} }
export async function ensureDMExists(client, userId) { export async function ensureDMExists(client: MatrixClient, userId: string): Promise<string> {
const existingDMRoom = findDMForUser(client, userId); const existingDMRoom = findDMForUser(client, userId);
let roomId; let roomId;
if (existingDMRoom) { if (existingDMRoom) {

View file

@ -79,4 +79,9 @@ export enum Action {
* Sets a system font. Should be used with UpdateSystemFontPayload * Sets a system font. Should be used with UpdateSystemFontPayload
*/ */
UpdateSystemFont = "update_system_font", UpdateSystemFont = "update_system_font",
/**
* Changes room based on room list order and payload parameters. Should be used with ViewRoomDeltaPayload.
*/
ViewRoomDelta = "view_room_delta",
} }

View file

@ -0,0 +1,32 @@
/*
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 "../payloads";
import { Action } from "../actions";
export interface ViewRoomDeltaPayload extends ActionPayload {
action: Action.ViewRoomDelta;
/**
* The delta index of the room to view.
*/
delta: number;
/**
* Optionally, whether or not to filter to unread (Bold/Grey/Red) rooms only. (Default: false)
*/
unread?: boolean;
}

View file

@ -15,7 +15,8 @@ limitations under the License.
*/ */
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { _t } from './languageHandler.js';
import { _t } from './languageHandler';
export const GroupMemberType = PropTypes.shape({ export const GroupMemberType = PropTypes.shape({
userId: PropTypes.string.isRequired, userId: PropTypes.string.isRequired,

View file

@ -488,7 +488,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",
@ -538,7 +537,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",
@ -557,12 +556,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.",
@ -965,6 +969,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",
@ -1199,14 +1205,16 @@
"Securely back up your keys to avoid losing them. <a>Learn more.</a>": "Securely back up your keys to avoid losing them. <a>Learn more.</a>", "Securely back up your keys to avoid losing them. <a>Learn more.</a>": "Securely back up your keys to avoid losing them. <a>Learn more.</a>",
"Not now": "Not now", "Not now": "Not now",
"Don't ask me again": "Don't ask me again", "Don't ask me again": "Don't ask me again",
"Sort by": "Sort by",
"Activity": "Activity",
"A-Z": "A-Z",
"Unread rooms": "Unread rooms", "Unread rooms": "Unread rooms",
"Always show first": "Always show first", "Always show first": "Always show first",
"Show": "Show", "Show": "Show",
"Message preview": "Message preview", "Message preview": "Message preview",
"Sort by": "Sort by",
"Activity": "Activity",
"A-Z": "A-Z",
"List options": "List options", "List options": "List options",
"Jump to first unread room.": "Jump to first unread room.",
"Jump to first invite.": "Jump to first invite.",
"Add room": "Add room", "Add room": "Add room",
"Show %(count)s more|other": "Show %(count)s more", "Show %(count)s more|other": "Show %(count)s more",
"Show %(count)s more|one": "Show %(count)s more", "Show %(count)s more|one": "Show %(count)s more",
@ -1221,6 +1229,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",
@ -2088,6 +2097,8 @@
"Find a room…": "Find a room…", "Find a room…": "Find a room…",
"Find a room… (e.g. %(exampleRoom)s)": "Find a room… (e.g. %(exampleRoom)s)", "Find a room… (e.g. %(exampleRoom)s)": "Find a room… (e.g. %(exampleRoom)s)",
"If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.",
"Clear filter": "Clear filter",
"Search rooms": "Search rooms",
"You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.": "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.", "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.": "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.",
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.", "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.",
"Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.", "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.",
@ -2097,10 +2108,7 @@
"%(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>?",
"Jump to first unread room.": "Jump to first unread room.",
"Jump to first invite.": "Jump to first invite.",
"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?",
"Search failed": "Search failed", "Search failed": "Search failed",
@ -2115,7 +2123,6 @@
"Click to mute video": "Click to mute video", "Click to mute video": "Click to mute video",
"Click to unmute audio": "Click to unmute audio", "Click to unmute audio": "Click to unmute audio",
"Click to mute audio": "Click to mute audio", "Click to mute audio": "Click to mute audio",
"Clear filter": "Clear filter",
"Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.", "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.",
"Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.", "Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.",
"Failed to load timeline position": "Failed to load timeline position", "Failed to load timeline position": "Failed to load timeline position",
@ -2128,9 +2135,8 @@
"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",
"Account settings": "Account settings", "User menu": "User menu",
"Could not load user profile": "Could not load user profile", "Could not load user profile": "Could not load user profile",
"Verify this login": "Verify this login", "Verify this login": "Verify this login",
"Session verified": "Session verified", "Session verified": "Session verified",

View file

@ -1,7 +1,7 @@
/* /*
Copyright 2017 MTRNord and Cooperative EITA Copyright 2017 MTRNord and Cooperative EITA
Copyright 2017 Vector Creations Ltd. Copyright 2017 Vector Creations 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");
@ -20,10 +20,11 @@ limitations under the License.
import request from 'browser-request'; import request from 'browser-request';
import counterpart from 'counterpart'; import counterpart from 'counterpart';
import React from 'react'; import React from 'react';
import SettingsStore, {SettingLevel} from "./settings/SettingsStore"; import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
import PlatformPeg from "./PlatformPeg"; import PlatformPeg from "./PlatformPeg";
// $webapp is a webpack resolve alias pointing to the output directory, see webpack config // @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config
import webpackLangJsonUrl from "$webapp/i18n/languages.json"; import webpackLangJsonUrl from "$webapp/i18n/languages.json";
const i18nFolder = 'i18n/'; const i18nFolder = 'i18n/';
@ -37,27 +38,31 @@ counterpart.setSeparator('|');
// Fall back to English // Fall back to English
counterpart.setFallbackLocale('en'); counterpart.setFallbackLocale('en');
interface ITranslatableError extends Error {
translatedMessage: string;
}
/** /**
* Helper function to create an error which has an English message * Helper function to create an error which has an English message
* with a translatedMessage property for use by the consumer. * with a translatedMessage property for use by the consumer.
* @param {string} message Message to translate. * @param {string} message Message to translate.
* @returns {Error} The constructed error. * @returns {Error} The constructed error.
*/ */
export function newTranslatableError(message) { export function newTranslatableError(message: string) {
const error = new Error(message); const error = new Error(message) as ITranslatableError;
error.translatedMessage = _t(message); error.translatedMessage = _t(message);
return error; return error;
} }
// Function which only purpose is to mark that a string is translatable // Function which only purpose is to mark that a string is translatable
// Does not actually do anything. It's helpful for automatic extraction of translatable strings // Does not actually do anything. It's helpful for automatic extraction of translatable strings
export function _td(s) { export function _td(s: string): string {
return s; return s;
} }
// Wrapper for counterpart's translation function so that it handles nulls and undefineds properly // Wrapper for counterpart's translation function so that it handles nulls and undefineds properly
// Takes the same arguments as counterpart.translate() // Takes the same arguments as counterpart.translate()
function safeCounterpartTranslate(text, options) { function safeCounterpartTranslate(text: string, options?: object) {
// Horrible hack to avoid https://github.com/vector-im/riot-web/issues/4191 // Horrible hack to avoid https://github.com/vector-im/riot-web/issues/4191
// The interpolation library that counterpart uses does not support undefined/null // The interpolation library that counterpart uses does not support undefined/null
// values and instead will throw an error. This is a problem since everywhere else // values and instead will throw an error. This is a problem since everywhere else
@ -89,6 +94,13 @@ function safeCounterpartTranslate(text, options) {
return translated; return translated;
} }
interface IVariables {
count?: number;
[key: string]: number | string;
}
type Tags = Record<string, (sub: string) => React.ReactNode>;
/* /*
* Translates text and optionally also replaces XML-ish elements in the text with e.g. React components * Translates text and optionally also replaces XML-ish elements in the text with e.g. React components
* @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s". * @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
@ -105,7 +117,9 @@ function safeCounterpartTranslate(text, options) {
* *
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string * @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/ */
export function _t(text, variables, tags) { export function _t(text: string, variables?: IVariables): string;
export function _t(text: string, variables: IVariables, tags: Tags): React.ReactNode;
export function _t(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode {
// Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components // Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components
// However, still pass the variables to counterpart so that it can choose the correct plural if count is given // However, still pass the variables to counterpart so that it can choose the correct plural if count is given
// It is enough to pass the count variable, but in the future counterpart might make use of other information too // It is enough to pass the count variable, but in the future counterpart might make use of other information too
@ -141,23 +155,25 @@ export function _t(text, variables, tags) {
* *
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string * @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/ */
export function substitute(text, variables, tags) { export function substitute(text: string, variables?: IVariables): string;
let result = text; export function substitute(text: string, variables: IVariables, tags: Tags): string;
export function substitute(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode {
let result: React.ReactNode | string = text;
if (variables !== undefined) { if (variables !== undefined) {
const regexpMapping = {}; const regexpMapping: IVariables = {};
for (const variable in variables) { for (const variable in variables) {
regexpMapping[`%\\(${variable}\\)s`] = variables[variable]; regexpMapping[`%\\(${variable}\\)s`] = variables[variable];
} }
result = replaceByRegexes(result, regexpMapping); result = replaceByRegexes(result as string, regexpMapping);
} }
if (tags !== undefined) { if (tags !== undefined) {
const regexpMapping = {}; const regexpMapping: Tags = {};
for (const tag in tags) { for (const tag in tags) {
regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag]; regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag];
} }
result = replaceByRegexes(result, regexpMapping); result = replaceByRegexes(result as string, regexpMapping);
} }
return result; return result;
@ -172,7 +188,9 @@ export function substitute(text, variables, tags) {
* *
* @return a React <span> component if any non-strings were used in substitutions, otherwise a string * @return a React <span> component if any non-strings were used in substitutions, otherwise a string
*/ */
export function replaceByRegexes(text, mapping) { export function replaceByRegexes(text: string, mapping: IVariables): string;
export function replaceByRegexes(text: string, mapping: Tags): React.ReactNode;
export function replaceByRegexes(text: string, mapping: IVariables | Tags): string | React.ReactNode {
// We initially store our output as an array of strings and objects (e.g. React components). // We initially store our output as an array of strings and objects (e.g. React components).
// This will then be converted to a string or a <span> at the end // This will then be converted to a string or a <span> at the end
const output = [text]; const output = [text];
@ -189,7 +207,7 @@ export function replaceByRegexes(text, mapping) {
// and everything after the match. Insert all three into the output. We need to do this because we can insert objects. // and everything after the match. Insert all three into the output. We need to do this because we can insert objects.
// Otherwise there would be no need for the splitting and we could do simple replacement. // Otherwise there would be no need for the splitting and we could do simple replacement.
let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it
for (const outputIndex in output) { for (let outputIndex = 0; outputIndex < output.length; outputIndex++) {
const inputText = output[outputIndex]; const inputText = output[outputIndex];
if (typeof inputText !== 'string') { // We might have inserted objects earlier, don't try to replace them if (typeof inputText !== 'string') { // We might have inserted objects earlier, don't try to replace them
continue; continue;
@ -216,7 +234,7 @@ export function replaceByRegexes(text, mapping) {
let replaced; let replaced;
// If substitution is a function, call it // If substitution is a function, call it
if (mapping[regexpString] instanceof Function) { if (mapping[regexpString] instanceof Function) {
replaced = mapping[regexpString].apply(null, capturedGroups); replaced = (mapping as Tags)[regexpString].apply(null, capturedGroups);
} else { } else {
replaced = mapping[regexpString]; replaced = mapping[regexpString];
} }
@ -277,11 +295,11 @@ export function replaceByRegexes(text, mapping) {
// Allow overriding the text displayed when no translation exists // Allow overriding the text displayed when no translation exists
// Currently only used in unit tests to avoid having to load // Currently only used in unit tests to avoid having to load
// the translations in riot-web // the translations in riot-web
export function setMissingEntryGenerator(f) { export function setMissingEntryGenerator(f: (value: string) => void) {
counterpart.setMissingEntryGenerator(f); counterpart.setMissingEntryGenerator(f);
} }
export function setLanguage(preferredLangs) { export function setLanguage(preferredLangs: string | string[]) {
if (!Array.isArray(preferredLangs)) { if (!Array.isArray(preferredLangs)) {
preferredLangs = [preferredLangs]; preferredLangs = [preferredLangs];
} }
@ -358,8 +376,8 @@ export function getLanguageFromBrowser() {
* @param {string} language The input language string * @param {string} language The input language string
* @return {string[]} List of normalised languages * @return {string[]} List of normalised languages
*/ */
export function getNormalizedLanguageKeys(language) { export function getNormalizedLanguageKeys(language: string) {
const languageKeys = []; const languageKeys: string[] = [];
const normalizedLanguage = normalizeLanguageKey(language); const normalizedLanguage = normalizeLanguageKey(language);
const languageParts = normalizedLanguage.split('-'); const languageParts = normalizedLanguage.split('-');
if (languageParts.length === 2 && languageParts[0] === languageParts[1]) { if (languageParts.length === 2 && languageParts[0] === languageParts[1]) {
@ -380,7 +398,7 @@ export function getNormalizedLanguageKeys(language) {
* @param {string} language The language string to be normalized * @param {string} language The language string to be normalized
* @returns {string} The normalized language string * @returns {string} The normalized language string
*/ */
export function normalizeLanguageKey(language) { export function normalizeLanguageKey(language: string) {
return language.toLowerCase().replace("_", "-"); return language.toLowerCase().replace("_", "-");
} }
@ -396,7 +414,7 @@ export function getCurrentLanguage() {
* @param {string[]} langs List of language codes to pick from * @param {string[]} langs List of language codes to pick from
* @returns {string} The most appropriate language code from langs * @returns {string} The most appropriate language code from langs
*/ */
export function pickBestLanguage(langs) { export function pickBestLanguage(langs: string[]): string {
const currentLang = getCurrentLanguage(); const currentLang = getCurrentLanguage();
const normalisedLangs = langs.map(normalizeLanguageKey); const normalisedLangs = langs.map(normalizeLanguageKey);
@ -408,13 +426,13 @@ export function pickBestLanguage(langs) {
{ {
// Failing that, a different dialect of the same language // Failing that, a different dialect of the same language
const closeLangIndex = normalisedLangs.find((l) => l.substr(0, 2) === currentLang.substr(0, 2)); const closeLangIndex = normalisedLangs.findIndex((l) => l.substr(0, 2) === currentLang.substr(0, 2));
if (closeLangIndex > -1) return langs[closeLangIndex]; if (closeLangIndex > -1) return langs[closeLangIndex];
} }
{ {
// Neither of those? Try an english variant. // Neither of those? Try an english variant.
const enIndex = normalisedLangs.find((l) => l.startsWith('en')); const enIndex = normalisedLangs.findIndex((l) => l.startsWith('en'));
if (enIndex > -1) return langs[enIndex]; if (enIndex > -1) return langs[enIndex];
} }
@ -422,7 +440,7 @@ export function pickBestLanguage(langs) {
return langs[0]; return langs[0];
} }
function getLangsJson() { function getLangsJson(): Promise<object> {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
let url; let url;
if (typeof(webpackLangJsonUrl) === 'string') { // in Jest this 'url' isn't a URL, so just fall through if (typeof(webpackLangJsonUrl) === 'string') { // in Jest this 'url' isn't a URL, so just fall through
@ -443,7 +461,7 @@ function getLangsJson() {
}); });
} }
function weblateToCounterpart(inTrs) { function weblateToCounterpart(inTrs: object): object {
const outTrs = {}; const outTrs = {};
for (const key of Object.keys(inTrs)) { for (const key of Object.keys(inTrs)) {
@ -463,7 +481,7 @@ function weblateToCounterpart(inTrs) {
return outTrs; return outTrs;
} }
function getLanguage(langPath) { function getLanguage(langPath: string): object {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
request( request(
{ method: "GET", url: langPath }, { method: "GET", url: langPath },

View file

@ -141,7 +141,8 @@ export const SETTINGS = {
default: false, default: false,
}, },
"feature_new_room_list": { "feature_new_room_list": {
isFeature: true, // TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14367
// XXX: We shouldn't have non-features appear like features.
displayName: _td("Use the improved room list (will refresh to apply changes)"), displayName: _td("Use the improved room list (will refresh to apply changes)"),
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
default: true, default: true,
@ -153,12 +154,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: [],
@ -472,13 +467,13 @@ export const SETTINGS = {
deny: [], deny: [],
}, },
}, },
// TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14373
"RoomList.orderAlphabetically": { "RoomList.orderAlphabetically": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("Order rooms by name"), displayName: _td("Order rooms by name"),
default: false, default: false,
}, },
// TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14373
"RoomList.orderByImportance": { "RoomList.orderByImportance": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("Show rooms with unread notifications first"), displayName: _td("Show rooms with unread notifications first"),
@ -568,7 +563,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

@ -43,11 +43,14 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl
const roomId = event.getRoomId(); const roomId = event.getRoomId();
const room = this.client.getRoom(roomId); const room = this.client.getRoom(roomId);
// Note: the tests often fire setting updates that don't have rooms in the store, so // Note: in tests and during the encryption setup on initial load we might not have
// we fail softly here. We shouldn't assume that the state being fired is current // rooms in the store, so we just quietly ignore the problem. If we log it then we'll
// state, but we also don't need to explode just because we didn't find a room. // just end up spamming the logs a few thousand times. It is perfectly fine for us
if (!room) console.warn(`Unknown room caused setting update: ${roomId}`); // to ignore the problem as the app will not have loaded enough to care yet.
if (room && state !== room.currentState) return; // ignore state updates which are not current if (!room) return;
// ignore state updates which are not current
if (room && state !== room.currentState) return;
if (event.getType() === "org.matrix.room.preview_urls") { if (event.getType() === "org.matrix.room.preview_urls") {
let val = event.getContent()['disable']; let val = event.getContent()['disable'];

View file

@ -57,7 +57,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
protected async onAction(payload: ActionPayload) { protected async onAction(payload: ActionPayload) {
if (!this.matrixClient) return; if (!this.matrixClient) return;
// TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14367
if (!RoomListStoreTempProxy.isUsingNewStore()) return; if (!RoomListStoreTempProxy.isUsingNewStore()) return;
if (payload.action === 'setting_updated') { if (payload.action === 'setting_updated') {
@ -80,7 +80,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
} }
protected async onReady() { protected async onReady() {
// TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14367
if (!RoomListStoreTempProxy.isUsingNewStore()) return; if (!RoomListStoreTempProxy.isUsingNewStore()) return;
await this.updateRooms(); await this.updateRooms();
@ -91,7 +91,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
} }
protected async onNotReady() { protected async onNotReady() {
// TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14367
if (!RoomListStoreTempProxy.isUsingNewStore()) return; if (!RoomListStoreTempProxy.isUsingNewStore()) return;
this.matrixClient.removeListener("Room.myMembership", this.onMyMembership); this.matrixClient.removeListener("Room.myMembership", this.onMyMembership);
@ -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

@ -99,7 +99,7 @@ class RoomListStore extends Store {
} }
_checkDisabled() { _checkDisabled() {
this.disabled = SettingsStore.isFeatureEnabled("feature_new_room_list"); this.disabled = SettingsStore.getValue("feature_new_room_list");
if (this.disabled) { if (this.disabled) {
console.warn("👋 legacy room list store has been disabled"); console.warn("👋 legacy room list store has been disabled");
} }

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,16 +51,10 @@ 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]) {
// "Should never happen" disclaimer.
console.warn("Overwriting notification state for room:", newRoom.roomId);
this.states[newRoom.roomId].destroy();
}
this.states[newRoom.roomId] = state; this.states[newRoom.roomId] = state;
} }
@ -85,8 +68,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 +80,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 +95,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,13 +23,10 @@ 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; constructor(public readonly room: Room) {
private _count: number;
private _color: NotificationColor;
constructor(private room: Room) {
super(); super();
this.room.on("Room.receipt", this.handleReadReceipt); this.room.on("Room.receipt", this.handleReadReceipt);
this.room.on("Room.timeline", this.handleRoomEventUpdate); this.room.on("Room.timeline", this.handleRoomEventUpdate);
@ -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

@ -18,10 +18,6 @@ import { TagID } from "./models";
const TILE_HEIGHT_PX = 44; const TILE_HEIGHT_PX = 44;
// this comes from the CSS where the show more button is
// mathematically this percent of a tile when floating.
const RESIZER_BOX_FACTOR = 0.78;
interface ISerializedListLayout { interface ISerializedListLayout {
numTiles: number; numTiles: number;
showPreviews: boolean; showPreviews: boolean;
@ -81,35 +77,12 @@ export class ListLayout {
} }
public get minVisibleTiles(): number { public get minVisibleTiles(): number {
return 1 + RESIZER_BOX_FACTOR; return 1;
} }
public get defaultVisibleTiles(): number { public get defaultVisibleTiles(): number {
// 10 is what "feels right", and mostly subject to design's opinion. // This number is what "feels right", and mostly subject to design's opinion.
return 10 + RESIZER_BOX_FACTOR; return 5;
}
public setVisibleTilesWithin(diff: number, maxPossible: number) {
if (this.visibleTiles > maxPossible) {
this.visibleTiles = maxPossible + diff;
} else {
this.visibleTiles += diff;
}
}
public calculateTilesToPixelsMin(maxTiles: number, n: number, possiblePadding: number): number {
// Only apply the padding if we're about to use maxTiles as we need to
// plan for the padding. If we're using n, the padding is already accounted
// for by the resizing stuff.
let padding = 0;
if (maxTiles < n) {
padding = possiblePadding;
}
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 {

View file

@ -192,7 +192,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
protected async onAction(payload: ActionPayload) { protected async onAction(payload: ActionPayload) {
if (!this.matrixClient) return; if (!this.matrixClient) return;
// TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14367
if (!RoomListStoreTempProxy.isUsingNewStore()) return; if (!RoomListStoreTempProxy.isUsingNewStore()) return;
if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') { if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') {

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

@ -25,12 +25,15 @@ import { IListOrderingMap, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm
import { ActionPayload } from "../../dispatcher/payloads"; import { ActionPayload } from "../../dispatcher/payloads";
import defaultDispatcher from "../../dispatcher/dispatcher"; import defaultDispatcher from "../../dispatcher/dispatcher";
import { readReceiptChangeIsFor } from "../../utils/read-receipts"; import { readReceiptChangeIsFor } from "../../utils/read-receipts";
import { IFilterCondition } from "./filters/IFilterCondition"; import { FILTER_CHANGED, IFilterCondition } from "./filters/IFilterCondition";
import { TagWatcher } from "./TagWatcher"; import { TagWatcher } from "./TagWatcher";
import RoomViewStore from "../RoomViewStore"; import RoomViewStore from "../RoomViewStore";
import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm"; 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 RoomListLayoutStore from "./RoomListLayoutStore";
import { MarkedExecution } from "../../utils/MarkedExecution";
interface IState { interface IState {
tagsEnabled?: boolean; tagsEnabled?: boolean;
@ -43,12 +46,19 @@ interface IState {
export const LISTS_UPDATE_EVENT = "lists_update"; export const LISTS_UPDATE_EVENT = "lists_update";
export class RoomListStore2 extends AsyncStore<ActionPayload> { export class RoomListStore2 extends AsyncStore<ActionPayload> {
/**
* Set to true if you're running tests on the store. Should not be touched in
* any other environment.
*/
public static TEST_MODE = false;
private _matrixClient: MatrixClient; private _matrixClient: MatrixClient;
private initialListsGenerated = false; private initialListsGenerated = false;
private enabled = false; private enabled = false;
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 updateFn = new MarkedExecution(() => this.emit(LISTS_UPDATE_EVENT));
private readonly watchedSettings = [ private readonly watchedSettings = [
'feature_custom_tags', 'feature_custom_tags',
@ -59,8 +69,9 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
this.checkEnabled(); this.checkEnabled();
for (const settingName of this.watchedSettings) SettingsStore.monitorSetting(settingName, null); for (const settingName of this.watchedSettings) SettingsStore.monitorSetting(settingName, null);
RoomViewStore.addListener(this.onRVSUpdate); RoomViewStore.addListener(() => this.handleRVSUpdate({}));
this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated); this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated);
} }
public get orderedLists(): ITagMap { public get orderedLists(): ITagMap {
@ -72,9 +83,43 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
return this._matrixClient; return this._matrixClient;
} }
// TODO: Remove enabled flag with the old RoomListStore: https://github.com/vector-im/riot-web/issues/14231 // Intended for test usage
public async resetStore() {
await this.reset();
this.tagWatcher = new TagWatcher(this);
this.filterConditions = [];
this.initialListsGenerated = false;
this._matrixClient = null;
this.algorithm.off(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
this.algorithm.off(FILTER_CHANGED, this.onAlgorithmListUpdated);
this.algorithm = new Algorithm();
this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
this.algorithm.on(FILTER_CHANGED, this.onAlgorithmListUpdated);
}
// Public for test usage. Do not call this.
public async makeReady(client: MatrixClient) {
// TODO: Remove with https://github.com/vector-im/riot-web/issues/14367
this.checkEnabled();
if (!this.enabled) return;
this._matrixClient = client;
// Update any settings here, as some may have happened before we were logically ready.
// Update any settings here, as some may have happened before we were logically ready.
console.log("Regenerating room lists: Startup");
await this.readAndCacheSettingsFromStore();
await this.regenerateAllLists({trigger: false});
await this.handleRVSUpdate({trigger: false}); // fake an RVS update to adjust sticky room, if needed
this.updateFn.mark(); // we almost certainly want to trigger an update.
this.updateFn.trigger();
}
// TODO: Remove enabled flag with the old RoomListStore: https://github.com/vector-im/riot-web/issues/14367
private checkEnabled() { private checkEnabled() {
this.enabled = SettingsStore.isFeatureEnabled("feature_new_room_list"); this.enabled = SettingsStore.getValue("feature_new_room_list");
if (this.enabled) { if (this.enabled) {
console.log("⚡ new room list store engaged"); console.log("⚡ new room list store engaged");
} }
@ -88,44 +133,58 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
await this.updateAlgorithmInstances(); await this.updateAlgorithmInstances();
} }
private onRVSUpdate = () => { /**
if (!this.enabled) return; // TODO: Remove with https://github.com/vector-im/riot-web/issues/14231 * Handles suspected RoomViewStore changes.
* @param trigger Set to false to prevent a list update from being sent. Should only
* be used if the calling code will manually trigger the update.
*/
private async handleRVSUpdate({trigger = true}) {
if (!this.enabled) return; // TODO: Remove with https://github.com/vector-im/riot-web/issues/14367
if (!this.matrixClient) return; // We assume there won't be RVS updates without a client if (!this.matrixClient) return; // We assume there won't be RVS updates without a client
const activeRoomId = RoomViewStore.getRoomId(); const activeRoomId = RoomViewStore.getRoomId();
if (!activeRoomId && this.algorithm.stickyRoom) { if (!activeRoomId && this.algorithm.stickyRoom) {
this.algorithm.stickyRoom = null; await this.algorithm.setStickyRoom(null);
} else if (activeRoomId) { } else if (activeRoomId) {
const activeRoom = this.matrixClient.getRoom(activeRoomId); const activeRoom = this.matrixClient.getRoom(activeRoomId);
if (!activeRoom) { if (!activeRoom) {
console.warn(`${activeRoomId} is current in RVS but missing from client - clearing sticky room`); console.warn(`${activeRoomId} is current in RVS but missing from client - clearing sticky room`);
this.algorithm.stickyRoom = null; await this.algorithm.setStickyRoom(null);
} else if (activeRoom !== this.algorithm.stickyRoom) { } else if (activeRoom !== this.algorithm.stickyRoom) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 if (!window.mx_QuietRoomListLogging) {
console.log(`Changing sticky room to ${activeRoomId}`); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
this.algorithm.stickyRoom = activeRoom; console.log(`Changing sticky room to ${activeRoomId}`);
}
await this.algorithm.setStickyRoom(activeRoom);
} }
} }
};
if (trigger) this.updateFn.trigger();
}
protected async onDispatch(payload: ActionPayload) { protected async onDispatch(payload: ActionPayload) {
// When we're running tests we can't reliably use setImmediate out of timing concerns.
// As such, we use a more synchronous model.
if (RoomListStore2.TEST_MODE) {
await this.onDispatchAsync(payload);
return;
}
// We do this to intentionally break out of the current event loop task, allowing
// us to instead wait for a more convenient time to run our updates.
setImmediate(() => this.onDispatchAsync(payload));
}
protected async onDispatchAsync(payload: ActionPayload) {
if (payload.action === 'MatrixActions.sync') { if (payload.action === 'MatrixActions.sync') {
// Filter out anything that isn't the first PREPARED sync. // Filter out anything that isn't the first PREPARED sync.
if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) { if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) {
return; return;
} }
// TODO: Remove with https://github.com/vector-im/riot-web/issues/14231 await this.makeReady(payload.matrixClient);
this.checkEnabled();
if (!this.enabled) return;
this._matrixClient = payload.matrixClient; return; // no point in running the next conditions - they won't match
// Update any settings here, as some may have happened before we were logically ready.
console.log("Regenerating room lists: Startup");
await this.readAndCacheSettingsFromStore();
await this.regenerateAllLists();
this.onRVSUpdate(); // fake an RVS update to adjust sticky room, if needed
} }
// TODO: Remove this once the RoomListStore becomes default // TODO: Remove this once the RoomListStore becomes default
@ -134,7 +193,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
if (payload.action === 'on_client_not_viable' || payload.action === 'on_logged_out') { if (payload.action === 'on_client_not_viable' || payload.action === 'on_logged_out') {
// Reset state without causing updates as the client will have been destroyed // Reset state without causing updates as the client will have been destroyed
// and downstream code will throw NPE errors. // and downstream code will throw NPE errors.
this.reset(null, true); await this.reset(null, true);
this._matrixClient = null; this._matrixClient = null;
this.initialListsGenerated = false; // we'll want to regenerate them this.initialListsGenerated = false; // we'll want to regenerate them
} }
@ -148,7 +207,8 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
console.log("Regenerating room lists: Settings changed"); console.log("Regenerating room lists: Settings changed");
await this.readAndCacheSettingsFromStore(); await this.readAndCacheSettingsFromStore();
await this.regenerateAllLists(); // regenerate the lists now await this.regenerateAllLists({trigger: false}); // regenerate the lists now
this.updateFn.trigger();
} }
} }
@ -166,16 +226,22 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
console.warn(`Own read receipt was in unknown room ${room.roomId}`); console.warn(`Own read receipt was in unknown room ${room.roomId}`);
return; return;
} }
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 if (!window.mx_QuietRoomListLogging) {
console.log(`[RoomListDebug] Got own read receipt in ${room.roomId}`); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Got own read receipt in ${room.roomId}`);
}
await this.handleRoomUpdate(room, RoomUpdateCause.ReadReceipt); await this.handleRoomUpdate(room, RoomUpdateCause.ReadReceipt);
this.updateFn.trigger();
return; return;
} }
} else if (payload.action === 'MatrixActions.Room.tags') { } else if (payload.action === 'MatrixActions.Room.tags') {
const roomPayload = (<any>payload); // TODO: Type out the dispatcher types const roomPayload = (<any>payload); // TODO: Type out the dispatcher types
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 if (!window.mx_QuietRoomListLogging) {
console.log(`[RoomListDebug] Got tag change in ${roomPayload.room.roomId}`); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Got tag change in ${roomPayload.room.roomId}`);
}
await this.handleRoomUpdate(roomPayload.room, RoomUpdateCause.PossibleTagChange); await this.handleRoomUpdate(roomPayload.room, RoomUpdateCause.PossibleTagChange);
this.updateFn.trigger();
} else if (payload.action === 'MatrixActions.Room.timeline') { } else if (payload.action === 'MatrixActions.Room.timeline') {
const eventPayload = (<any>payload); // TODO: Type out the dispatcher types const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
@ -185,12 +251,16 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
const roomId = eventPayload.event.getRoomId(); const roomId = eventPayload.event.getRoomId();
const room = this.matrixClient.getRoom(roomId); const room = this.matrixClient.getRoom(roomId);
const tryUpdate = async (updatedRoom: Room) => { const tryUpdate = async (updatedRoom: Room) => {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 if (!window.mx_QuietRoomListLogging) {
console.log(`[RoomListDebug] Live timeline event ${eventPayload.event.getId()}` +
` in ${updatedRoom.roomId}`);
if (eventPayload.event.getType() === 'm.room.tombstone' && eventPayload.event.getStateKey() === '') {
// 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(`[RoomListDebug] Got tombstone event - trying to remove now-dead room`); console.log(`[RoomListDebug] Live timeline event ${eventPayload.event.getId()}` +
` in ${updatedRoom.roomId}`);
}
if (eventPayload.event.getType() === 'm.room.tombstone' && eventPayload.event.getStateKey() === '') {
if (!window.mx_QuietRoomListLogging) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Got tombstone event - trying to remove now-dead room`);
}
const newRoom = this.matrixClient.getRoom(eventPayload.event.getContent()['replacement_room']); const newRoom = this.matrixClient.getRoom(eventPayload.event.getContent()['replacement_room']);
if (newRoom) { if (newRoom) {
// If we have the new room, then the new room check will have seen the predecessor // If we have the new room, then the new room check will have seen the predecessor
@ -199,6 +269,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
} }
} }
await this.handleRoomUpdate(updatedRoom, RoomUpdateCause.Timeline); await this.handleRoomUpdate(updatedRoom, RoomUpdateCause.Timeline);
this.updateFn.trigger();
}; };
if (!room) { if (!room) {
console.warn(`Live timeline event ${eventPayload.event.getId()} received without associated room`); console.warn(`Live timeline event ${eventPayload.event.getId()} received without associated room`);
@ -219,16 +290,18 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
console.warn(`Event ${eventPayload.event.getId()} was decrypted in an unknown room ${roomId}`); console.warn(`Event ${eventPayload.event.getId()} was decrypted in an unknown room ${roomId}`);
return; return;
} }
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 if (!window.mx_QuietRoomListLogging) {
console.log(`[RoomListDebug] Decrypted timeline event ${eventPayload.event.getId()} in ${roomId}`); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
// TODO: Verify that e2e rooms are handled on init: https://github.com/vector-im/riot-web/issues/14238 console.log(`[RoomListDebug] Decrypted timeline event ${eventPayload.event.getId()} in ${roomId}`);
// It seems like when viewing the room the timeline is decrypted, rather than at startup. This could }
// cause inaccuracies with the list ordering. We may have to decrypt the last N messages of every room :(
await this.handleRoomUpdate(room, RoomUpdateCause.Timeline); await this.handleRoomUpdate(room, RoomUpdateCause.Timeline);
this.updateFn.trigger();
} else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') { } else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') {
const eventPayload = (<any>payload); // TODO: Type out the dispatcher types const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 if (!window.mx_QuietRoomListLogging) {
console.log(`[RoomListDebug] Received updated DM map`); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Received updated DM map`);
}
const dmMap = eventPayload.event.getContent(); const dmMap = eventPayload.event.getContent();
for (const userId of Object.keys(dmMap)) { for (const userId of Object.keys(dmMap)) {
const roomIds = dmMap[userId]; const roomIds = dmMap[userId];
@ -246,51 +319,73 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
await this.handleRoomUpdate(room, RoomUpdateCause.PossibleTagChange); await this.handleRoomUpdate(room, RoomUpdateCause.PossibleTagChange);
} }
} }
this.updateFn.trigger();
} else if (payload.action === 'MatrixActions.Room.myMembership') { } else if (payload.action === 'MatrixActions.Room.myMembership') {
const membershipPayload = (<any>payload); // TODO: Type out the dispatcher types const membershipPayload = (<any>payload); // TODO: Type out the dispatcher types
const oldMembership = getEffectiveMembership(membershipPayload.oldMembership); const oldMembership = getEffectiveMembership(membershipPayload.oldMembership);
const newMembership = getEffectiveMembership(membershipPayload.membership); const newMembership = getEffectiveMembership(membershipPayload.membership);
if (oldMembership !== EffectiveMembership.Join && newMembership === EffectiveMembership.Join) { if (oldMembership !== EffectiveMembership.Join && newMembership === EffectiveMembership.Join) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 if (!window.mx_QuietRoomListLogging) {
console.log(`[RoomListDebug] Handling new room ${membershipPayload.room.roomId}`); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Handling new room ${membershipPayload.room.roomId}`);
}
// If we're joining an upgraded room, we'll want to make sure we don't proliferate // If we're joining an upgraded room, we'll want to make sure we don't proliferate
// the dead room in the list. // the dead room in the list.
const createEvent = membershipPayload.room.currentState.getStateEvents("m.room.create", ""); const createEvent = membershipPayload.room.currentState.getStateEvents("m.room.create", "");
if (createEvent && createEvent.getContent()['predecessor']) { if (createEvent && createEvent.getContent()['predecessor']) {
console.log(`[RoomListDebug] Room has a predecessor`); if (!window.mx_QuietRoomListLogging) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Room has a predecessor`);
}
const prevRoom = this.matrixClient.getRoom(createEvent.getContent()['predecessor']['room_id']); const prevRoom = this.matrixClient.getRoom(createEvent.getContent()['predecessor']['room_id']);
if (prevRoom) { if (prevRoom) {
const isSticky = this.algorithm.stickyRoom === prevRoom; const isSticky = this.algorithm.stickyRoom === prevRoom;
if (isSticky) { if (isSticky) {
console.log(`[RoomListDebug] Clearing sticky room due to room upgrade`); if (!window.mx_QuietRoomListLogging) {
await this.algorithm.setStickyRoomAsync(null); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Clearing sticky room due to room upgrade`);
}
await this.algorithm.setStickyRoom(null);
} }
// Note: we hit the algorithm instead of our handleRoomUpdate() function to // Note: we hit the algorithm instead of our handleRoomUpdate() function to
// avoid redundant updates. // avoid redundant updates.
console.log(`[RoomListDebug] Removing previous room from room list`); if (!window.mx_QuietRoomListLogging) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Removing previous room from room list`);
}
await this.algorithm.handleRoomUpdate(prevRoom, RoomUpdateCause.RoomRemoved); await this.algorithm.handleRoomUpdate(prevRoom, RoomUpdateCause.RoomRemoved);
} }
} }
console.log(`[RoomListDebug] Adding new room to room list`); if (!window.mx_QuietRoomListLogging) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Adding new room to room list`);
}
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom); await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
this.updateFn.trigger();
return; return;
} }
if (oldMembership !== EffectiveMembership.Invite && newMembership === EffectiveMembership.Invite) { if (oldMembership !== EffectiveMembership.Invite && newMembership === EffectiveMembership.Invite) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 if (!window.mx_QuietRoomListLogging) {
console.log(`[RoomListDebug] Handling invite to ${membershipPayload.room.roomId}`); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Handling invite to ${membershipPayload.room.roomId}`);
}
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom); await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
this.updateFn.trigger();
return; return;
} }
// If it's not a join, it's transitioning into a different list (possibly historical) // If it's not a join, it's transitioning into a different list (possibly historical)
if (oldMembership !== newMembership) { if (oldMembership !== newMembership) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 if (!window.mx_QuietRoomListLogging) {
console.log(`[RoomListDebug] Handling membership change in ${membershipPayload.room.roomId}`); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Handling membership change in ${membershipPayload.room.roomId}`);
}
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.PossibleTagChange); await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.PossibleTagChange);
this.updateFn.trigger();
return; return;
} }
} }
@ -299,13 +394,20 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
private async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<any> { private async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<any> {
const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause); const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause);
if (shouldUpdate) { if (shouldUpdate) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 if (!window.mx_QuietRoomListLogging) {
console.log(`[DEBUG] Room "${room.name}" (${room.roomId}) triggered by ${cause} requires list update`); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
this.emit(LISTS_UPDATE_EVENT, this); console.log(`[DEBUG] Room "${room.name}" (${room.roomId}) triggered by ${cause} requires list update`);
}
this.updateFn.mark();
} }
} }
public async setTagSorting(tagId: TagID, sort: SortAlgorithm) { public async setTagSorting(tagId: TagID, sort: SortAlgorithm) {
await this.setAndPersistTagSorting(tagId, sort);
this.updateFn.trigger();
}
private async setAndPersistTagSorting(tagId: TagID, sort: SortAlgorithm) {
await this.algorithm.setTagSorting(tagId, sort); await this.algorithm.setTagSorting(tagId, sort);
// TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114 // TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114
localStorage.setItem(`mx_tagSort_${tagId}`, sort); localStorage.setItem(`mx_tagSort_${tagId}`, sort);
@ -321,7 +423,34 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
return <SortAlgorithm>localStorage.getItem(`mx_tagSort_${tagId}`); return <SortAlgorithm>localStorage.getItem(`mx_tagSort_${tagId}`);
} }
// logic must match calculateListOrder
private calculateTagSorting(tagId: TagID): SortAlgorithm {
const defaultSort = SortAlgorithm.Alphabetic;
const settingAlphabetical = SettingsStore.getValue("RoomList.orderAlphabetically", null, true);
const definedSort = this.getTagSorting(tagId);
const storedSort = this.getStoredTagSorting(tagId);
// We use the following order to determine which of the 4 flags to use:
// Stored > Settings > Defined > Default
let tagSort = defaultSort;
if (storedSort) {
tagSort = storedSort;
} else if (!isNullOrUndefined(settingAlphabetical)) {
tagSort = settingAlphabetical ? SortAlgorithm.Alphabetic : SortAlgorithm.Recent;
} else if (definedSort) {
tagSort = definedSort;
} // else default (already set)
return tagSort;
}
public async setListOrder(tagId: TagID, order: ListAlgorithm) { public async setListOrder(tagId: TagID, order: ListAlgorithm) {
await this.setAndPersistListOrder(tagId, order);
this.updateFn.trigger();
}
private async setAndPersistListOrder(tagId: TagID, order: ListAlgorithm) {
await this.algorithm.setListOrdering(tagId, order); await this.algorithm.setListOrdering(tagId, order);
// TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114 // TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114
localStorage.setItem(`mx_listOrder_${tagId}`, order); localStorage.setItem(`mx_listOrder_${tagId}`, order);
@ -337,25 +466,45 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
return <ListAlgorithm>localStorage.getItem(`mx_listOrder_${tagId}`); return <ListAlgorithm>localStorage.getItem(`mx_listOrder_${tagId}`);
} }
private async updateAlgorithmInstances() { // logic must match calculateTagSorting
const defaultSort = SortAlgorithm.Alphabetic; private calculateListOrder(tagId: TagID): ListAlgorithm {
const defaultOrder = ListAlgorithm.Natural; const defaultOrder = ListAlgorithm.Natural;
const settingImportance = SettingsStore.getValue("RoomList.orderByImportance", null, true);
const definedOrder = this.getListOrder(tagId);
const storedOrder = this.getStoredListOrder(tagId);
// We use the following order to determine which of the 4 flags to use:
// Stored > Settings > Defined > Default
let listOrder = defaultOrder;
if (storedOrder) {
listOrder = storedOrder;
} else if (!isNullOrUndefined(settingImportance)) {
listOrder = settingImportance ? ListAlgorithm.Importance : ListAlgorithm.Natural;
} else if (definedOrder) {
listOrder = definedOrder;
} // else default (already set)
return listOrder;
}
private async updateAlgorithmInstances() {
// We'll require an update, so mark for one. Marking now also prevents the calls
// to setTagSorting and setListOrder from causing triggers.
this.updateFn.mark();
for (const tag of Object.keys(this.orderedLists)) { for (const tag of Object.keys(this.orderedLists)) {
const definedSort = this.getTagSorting(tag); const definedSort = this.getTagSorting(tag);
const definedOrder = this.getListOrder(tag); const definedOrder = this.getListOrder(tag);
const storedSort = this.getStoredTagSorting(tag); const tagSort = this.calculateTagSorting(tag);
const storedOrder = this.getStoredListOrder(tag); const listOrder = this.calculateListOrder(tag);
const tagSort = storedSort ? storedSort : (definedSort ? definedSort : defaultSort);
const listOrder = storedOrder ? storedOrder : (definedOrder ? definedOrder : defaultOrder);
if (tagSort !== definedSort) { if (tagSort !== definedSort) {
await this.setTagSorting(tag, tagSort); await this.setAndPersistTagSorting(tag, tagSort);
} }
if (listOrder !== definedOrder) { if (listOrder !== definedOrder) {
await this.setListOrder(tag, listOrder); await this.setAndPersistListOrder(tag, listOrder);
} }
} }
} }
@ -367,19 +516,37 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
} }
private onAlgorithmListUpdated = () => { private onAlgorithmListUpdated = () => {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 if (!window.mx_QuietRoomListLogging) {
console.log("Underlying algorithm has triggered a list update - refiring"); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
this.emit(LISTS_UPDATE_EVENT, this); console.log("Underlying algorithm has triggered a list update - marking");
}
this.updateFn.mark();
}; };
private async regenerateAllLists() { private onAlgorithmFilterUpdated = () => {
// The filter can happen off-cycle, so trigger an update. The filter will have
// already caused a mark.
this.updateFn.trigger();
};
/**
* Regenerates the room whole room list, discarding any previous results.
*
* Note: This is only exposed externally for the tests. Do not call this from within
* the app.
* @param trigger Set to false to prevent a list update from being sent. Should only
* be used if the calling code will manually trigger the update.
*/
public async regenerateAllLists({trigger = true}) {
console.warn("Regenerating all room lists"); console.warn("Regenerating all room lists");
const sorts: ITagSortingMap = {}; const sorts: ITagSortingMap = {};
const orders: IListOrderingMap = {}; const orders: IListOrderingMap = {};
for (const tagId of OrderedDefaultTagIDs) { for (const tagId of OrderedDefaultTagIDs) {
sorts[tagId] = this.getStoredTagSorting(tagId) || SortAlgorithm.Alphabetic; sorts[tagId] = this.calculateTagSorting(tagId);
orders[tagId] = this.getStoredListOrder(tagId) || ListAlgorithm.Natural; orders[tagId] = this.calculateListOrder(tagId);
RoomListLayoutStore.instance.ensureLayoutExists(tagId);
} }
if (this.state.tagsEnabled) { if (this.state.tagsEnabled) {
@ -395,30 +562,26 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
this.initialListsGenerated = true; this.initialListsGenerated = true;
this.emit(LISTS_UPDATE_EVENT, this); if (trigger) this.updateFn.trigger();
}
// 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 if (!window.mx_QuietRoomListLogging) {
console.log("Adding filter condition:", filter); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log("Adding filter condition:", filter);
}
this.filterConditions.push(filter); this.filterConditions.push(filter);
if (this.algorithm) { if (this.algorithm) {
this.algorithm.addFilterCondition(filter); this.algorithm.addFilterCondition(filter);
} }
this.updateFn.trigger();
} }
public removeFilter(filter: IFilterCondition): void { public removeFilter(filter: IFilterCondition): void {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 if (!window.mx_QuietRoomListLogging) {
console.log("Removing filter condition:", filter); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log("Removing filter condition:", filter);
}
const idx = this.filterConditions.indexOf(filter); const idx = this.filterConditions.indexOf(filter);
if (idx >= 0) { if (idx >= 0) {
this.filterConditions.splice(idx, 1); this.filterConditions.splice(idx, 1);
@ -427,6 +590,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
this.algorithm.removeFilterCondition(filter); this.algorithm.removeFilterCondition(filter);
} }
} }
this.updateFn.trigger();
} }
/** /**

View file

@ -24,11 +24,11 @@ import { ITagMap } from "./algorithms/models";
* Temporary RoomListStore proxy. Should be replaced with RoomListStore2 when * Temporary RoomListStore proxy. Should be replaced with RoomListStore2 when
* it is available to everyone. * it is available to everyone.
* *
* TODO: Delete this: https://github.com/vector-im/riot-web/issues/14231 * TODO: Delete this: https://github.com/vector-im/riot-web/issues/14367
*/ */
export class RoomListStoreTempProxy { export class RoomListStoreTempProxy {
public static isUsingNewStore(): boolean { public static isUsingNewStore(): boolean {
return SettingsStore.isFeatureEnabled("feature_new_room_list"); return SettingsStore.getValue("feature_new_room_list");
} }
public static addListener(handler: () => void): RoomListStoreTempToken { public static addListener(handler: () => void): RoomListStoreTempToken {

View file

@ -18,7 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import DMRoomMap from "../../../utils/DMRoomMap"; import DMRoomMap from "../../../utils/DMRoomMap";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { arrayHasDiff, ArrayUtil } from "../../../utils/arrays"; import { arrayDiff, arrayHasDiff, ArrayUtil } from "../../../utils/arrays";
import { getEnumValues } from "../../../utils/enums"; import { getEnumValues } from "../../../utils/enums";
import { DefaultTagID, RoomUpdateCause, TagID } from "../models"; import { DefaultTagID, RoomUpdateCause, TagID } from "../models";
import { import {
@ -41,6 +41,17 @@ import { getListAlgorithmInstance } from "./list-ordering";
*/ */
export const LIST_UPDATED_EVENT = "list_updated_event"; export const LIST_UPDATED_EVENT = "list_updated_event";
// These are the causes which require a room to be known in order for us to handle them. If
// a cause in this list is raised and we don't know about the room, we don't handle the update.
//
// Note: these typically happen when a new room is coming in, such as the user creating or
// joining the room. For these cases, we need to know about the room prior to handling it otherwise
// we'll make bad assumptions.
const CAUSES_REQUIRING_ROOM = [
RoomUpdateCause.Timeline,
RoomUpdateCause.ReadReceipt,
];
interface IStickyRoom { interface IStickyRoom {
room: Room; room: Room;
position: number; position: number;
@ -57,6 +68,7 @@ export class Algorithm extends EventEmitter {
private _cachedStickyRooms: ITagMap = {}; // a clone of the _cachedRooms, with the sticky room private _cachedStickyRooms: ITagMap = {}; // a clone of the _cachedRooms, with the sticky room
private filteredRooms: ITagMap = {}; private filteredRooms: ITagMap = {};
private _stickyRoom: IStickyRoom = null; private _stickyRoom: IStickyRoom = null;
private _lastStickyRoom: IStickyRoom = null; // only not-null when changing the sticky room
private sortAlgorithms: ITagSortingMap; private sortAlgorithms: ITagSortingMap;
private listAlgorithms: IListOrderingMap; private listAlgorithms: IListOrderingMap;
private algorithms: IOrderingAlgorithmMap; private algorithms: IOrderingAlgorithmMap;
@ -75,12 +87,6 @@ export class Algorithm extends EventEmitter {
return this._stickyRoom ? this._stickyRoom.room : null; return this._stickyRoom ? this._stickyRoom.room : null;
} }
public set stickyRoom(val: Room) {
// setters can't be async, so we call a private function to do the work
// noinspection JSIgnoredPromiseFromCall
this.updateStickyRoom(val);
}
protected get hasFilters(): boolean { protected get hasFilters(): boolean {
return this.allowedByFilter.size > 0; return this.allowedByFilter.size > 0;
} }
@ -103,11 +109,12 @@ export class Algorithm extends EventEmitter {
* Awaitable version of the sticky room setter. * Awaitable version of the sticky room setter.
* @param val The new room to sticky. * @param val The new room to sticky.
*/ */
public async setStickyRoomAsync(val: Room) { public async setStickyRoom(val: Room) {
await this.updateStickyRoom(val); await this.updateStickyRoom(val);
} }
public getTagSorting(tagId: TagID): SortAlgorithm { public getTagSorting(tagId: TagID): SortAlgorithm {
if (!this.sortAlgorithms) return null;
return this.sortAlgorithms[tagId]; return this.sortAlgorithms[tagId];
} }
@ -124,6 +131,7 @@ export class Algorithm extends EventEmitter {
} }
public getListOrdering(tagId: TagID): ListAlgorithm { public getListOrdering(tagId: TagID): ListAlgorithm {
if (!this.listAlgorithms) return null;
return this.listAlgorithms[tagId]; return this.listAlgorithms[tagId];
} }
@ -145,11 +153,11 @@ export class Algorithm extends EventEmitter {
// Populate the cache of the new filter // Populate the cache of the new filter
this.allowedByFilter.set(filterCondition, this.rooms.filter(r => filterCondition.isVisible(r))); this.allowedByFilter.set(filterCondition, this.rooms.filter(r => filterCondition.isVisible(r)));
this.recalculateFilteredRooms(); this.recalculateFilteredRooms();
filterCondition.on(FILTER_CHANGED, this.recalculateFilteredRooms.bind(this)); filterCondition.on(FILTER_CHANGED, this.handleFilterChange.bind(this));
} }
public removeFilterCondition(filterCondition: IFilterCondition): void { public removeFilterCondition(filterCondition: IFilterCondition): void {
filterCondition.off(FILTER_CHANGED, this.recalculateFilteredRooms.bind(this)); filterCondition.off(FILTER_CHANGED, this.handleFilterChange.bind(this));
if (this.allowedByFilter.has(filterCondition)) { if (this.allowedByFilter.has(filterCondition)) {
this.allowedByFilter.delete(filterCondition); this.allowedByFilter.delete(filterCondition);
@ -161,10 +169,29 @@ export class Algorithm extends EventEmitter {
} }
} }
private async handleFilterChange() {
await this.recalculateFilteredRooms();
// re-emit the update so the list store can fire an off-cycle update if needed
this.emit(FILTER_CHANGED);
}
private async updateStickyRoom(val: Room) { private async updateStickyRoom(val: Room) {
try {
return await this.doUpdateStickyRoom(val);
} finally {
this._lastStickyRoom = null; // clear to indicate we're done changing
}
}
private async doUpdateStickyRoom(val: Room) {
// Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing, // Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing,
// otherwise we risk duplicating rooms. // otherwise we risk duplicating rooms.
// Set the last sticky room to indicate that we're in a change. The code throughout the
// class can safely handle a null room, so this should be safe to do as a backup.
this._lastStickyRoom = this._stickyRoom || <IStickyRoom>{};
// It's possible to have no selected room. In that case, clear the sticky room // It's possible to have no selected room. In that case, clear the sticky room
if (!val) { if (!val) {
if (this._stickyRoom) { if (this._stickyRoom) {
@ -179,7 +206,7 @@ export class Algorithm extends EventEmitter {
} }
// When we do have a room though, we expect to be able to find it // When we do have a room though, we expect to be able to find it
const tag = this.roomIdsToTags[val.roomId][0]; let tag = this.roomIdsToTags[val.roomId][0];
if (!tag) throw new Error(`${val.roomId} does not belong to a tag and cannot be sticky`); if (!tag) throw new Error(`${val.roomId} does not belong to a tag and cannot be sticky`);
// We specifically do NOT use the ordered rooms set as it contains the sticky room, which // We specifically do NOT use the ordered rooms set as it contains the sticky room, which
@ -196,19 +223,41 @@ export class Algorithm extends EventEmitter {
// the same thing it no-ops. After we're done calling the algorithm, we'll issue // the same thing it no-ops. After we're done calling the algorithm, we'll issue
// a new update for ourselves. // a new update for ourselves.
const lastStickyRoom = this._stickyRoom; const lastStickyRoom = this._stickyRoom;
this._stickyRoom = null; this._stickyRoom = null; // clear before we update the algorithm
this.recalculateStickyRoom(); this.recalculateStickyRoom();
// When we do have the room, re-add the old room (if needed) to the algorithm // When we do have the room, re-add the old room (if needed) to the algorithm
// and remove the sticky room from the algorithm. This is so the underlying // and remove the sticky room from the algorithm. This is so the underlying
// algorithm doesn't try and confuse itself with the sticky room concept. // algorithm doesn't try and confuse itself with the sticky room concept.
if (lastStickyRoom) { // We don't add the new room if the sticky room isn't changing because that's
// an easy way to cause duplication. We have to do room ID checks instead of
// referential checks as the references can differ through the lifecycle.
if (lastStickyRoom && lastStickyRoom.room && lastStickyRoom.room.roomId !== val.roomId) {
// Lie to the algorithm and re-add the room to the algorithm // Lie to the algorithm and re-add the room to the algorithm
await this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom); await this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom);
} }
// Lie to the algorithm and remove the room from it's field of view // Lie to the algorithm and remove the room from it's field of view
await this.handleRoomUpdate(val, RoomUpdateCause.RoomRemoved); await this.handleRoomUpdate(val, RoomUpdateCause.RoomRemoved);
// Check for tag & position changes while we're here. We also check the room to ensure
// it is still the same room.
if (this._stickyRoom) {
if (this._stickyRoom.room !== val) {
// Check the room IDs just in case
if (this._stickyRoom.room.roomId === val.roomId) {
console.warn("Sticky room changed references");
} else {
throw new Error("Sticky room changed while the sticky room was changing");
}
}
console.warn(`Sticky room changed tag & position from ${tag} / ${position} `
+ `to ${this._stickyRoom.tag} / ${this._stickyRoom.position}`);
tag = this._stickyRoom.tag;
position = this._stickyRoom.position;
}
// Now that we're done lying to the algorithm, we need to update our position // Now that we're done lying to the algorithm, we need to update our position
// marker only if the user is moving further down the same list. If they're switching // marker only if the user is moving further down the same list. If they're switching
// lists, or moving upwards, the position marker will splice in just fine but if // lists, or moving upwards, the position marker will splice in just fine but if
@ -273,8 +322,10 @@ export class Algorithm extends EventEmitter {
} }
newMap[tagId] = allowedRoomsInThisTag; newMap[tagId] = allowedRoomsInThisTag;
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 if (!window.mx_QuietRoomListLogging) {
console.log(`[DEBUG] ${newMap[tagId].length}/${rooms.length} rooms filtered into ${tagId}`); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[DEBUG] ${newMap[tagId].length}/${rooms.length} rooms filtered into ${tagId}`);
}
} }
const allowedRooms = Object.values(newMap).reduce((rv, v) => { rv.push(...v); return rv; }, <Room[]>[]); const allowedRooms = Object.values(newMap).reduce((rv, v) => { rv.push(...v); return rv; }, <Room[]>[]);
@ -283,26 +334,13 @@ export class Algorithm extends EventEmitter {
this.emit(LIST_UPDATED_EVENT); this.emit(LIST_UPDATED_EVENT);
} }
// TODO: Remove or use.
protected addPossiblyFilteredRoomsToTag(tagId: TagID, added: Room[]): void {
const filters = this.allowedByFilter.keys();
for (const room of added) {
for (const filter of filters) {
if (filter.isVisible(room)) {
this.allowedRoomsByFilters.add(room);
break;
}
}
}
// Now that we've updated the allowed rooms, recalculate the tag
this.recalculateFilteredRoomsForTag(tagId);
}
protected recalculateFilteredRoomsForTag(tagId: TagID): void { protected recalculateFilteredRoomsForTag(tagId: TagID): void {
if (!this.hasFilters) return; // don't bother doing work if there's nothing to do if (!this.hasFilters) return; // don't bother doing work if there's nothing to do
console.log(`Recalculating filtered rooms for ${tagId}`); if (!window.mx_QuietRoomListLogging) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Recalculating filtered rooms for ${tagId}`);
}
delete this.filteredRooms[tagId]; delete this.filteredRooms[tagId];
const rooms = this.cachedRooms[tagId].map(r => r); // cheap clone const rooms = this.cachedRooms[tagId].map(r => r); // cheap clone
this.tryInsertStickyRoomToFilterSet(rooms, tagId); this.tryInsertStickyRoomToFilterSet(rooms, tagId);
@ -311,8 +349,10 @@ export class Algorithm extends EventEmitter {
this.filteredRooms[tagId] = filteredRooms; this.filteredRooms[tagId] = filteredRooms;
} }
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 if (!window.mx_QuietRoomListLogging) {
console.log(`[DEBUG] ${filteredRooms.length}/${rooms.length} rooms filtered into ${tagId}`); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[DEBUG] ${filteredRooms.length}/${rooms.length} rooms filtered into ${tagId}`);
}
} }
protected tryInsertStickyRoomToFilterSet(rooms: Room[], tagId: TagID) { protected tryInsertStickyRoomToFilterSet(rooms: Room[], tagId: TagID) {
@ -351,8 +391,10 @@ export class Algorithm extends EventEmitter {
} }
if (!this._cachedStickyRooms || !updatedTag) { if (!this._cachedStickyRooms || !updatedTag) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 if (!window.mx_QuietRoomListLogging) {
console.log(`Generating clone of cached rooms for sticky room handling`); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Generating clone of cached rooms for sticky room handling`);
}
const stickiedTagMap: ITagMap = {}; const stickiedTagMap: ITagMap = {};
for (const tagId of Object.keys(this.cachedRooms)) { for (const tagId of Object.keys(this.cachedRooms)) {
stickiedTagMap[tagId] = this.cachedRooms[tagId].map(r => r); // shallow clone stickiedTagMap[tagId] = this.cachedRooms[tagId].map(r => r); // shallow clone
@ -363,8 +405,10 @@ export class Algorithm extends EventEmitter {
if (updatedTag) { if (updatedTag) {
// Update the tag indicated by the caller, if possible. This is mostly to ensure // Update the tag indicated by the caller, if possible. This is mostly to ensure
// our cache is up to date. // our cache is up to date.
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 if (!window.mx_QuietRoomListLogging) {
console.log(`Replacing cached sticky rooms for ${updatedTag}`); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Replacing cached sticky rooms for ${updatedTag}`);
}
this._cachedStickyRooms[updatedTag] = this.cachedRooms[updatedTag].map(r => r); // shallow clone this._cachedStickyRooms[updatedTag] = this.cachedRooms[updatedTag].map(r => r); // shallow clone
} }
@ -373,8 +417,10 @@ export class Algorithm extends EventEmitter {
// we might have updated from the cache is also our sticky room. // we might have updated from the cache is also our sticky room.
const sticky = this._stickyRoom; const sticky = this._stickyRoom;
if (!updatedTag || updatedTag === sticky.tag) { if (!updatedTag || updatedTag === sticky.tag) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 if (!window.mx_QuietRoomListLogging) {
console.log(`Inserting sticky room ${sticky.room.roomId} at position ${sticky.position} in ${sticky.tag}`); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Inserting sticky room ${sticky.room.roomId} at position ${sticky.position} in ${sticky.tag}`);
}
this._cachedStickyRooms[sticky.tag].splice(sticky.position, 0, sticky.room); this._cachedStickyRooms[sticky.tag].splice(sticky.position, 0, sticky.room);
} }
@ -466,13 +512,9 @@ export class Algorithm extends EventEmitter {
// Split out the easy rooms first (leave and invite) // Split out the easy rooms first (leave and invite)
const memberships = splitRoomsByMembership(rooms); const memberships = splitRoomsByMembership(rooms);
for (const room of memberships[EffectiveMembership.Invite]) { for (const room of memberships[EffectiveMembership.Invite]) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is an Invite`);
newTags[DefaultTagID.Invite].push(room); newTags[DefaultTagID.Invite].push(room);
} }
for (const room of memberships[EffectiveMembership.Leave]) { for (const room of memberships[EffectiveMembership.Leave]) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Historical`);
newTags[DefaultTagID.Archived].push(room); newTags[DefaultTagID.Archived].push(room);
} }
@ -483,11 +525,7 @@ export class Algorithm extends EventEmitter {
let inTag = false; let inTag = false;
if (tags.length > 0) { if (tags.length > 0) {
for (const tag of tags) { for (const tag of tags) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged as ${tag}`);
if (!isNullOrUndefined(newTags[tag])) { if (!isNullOrUndefined(newTags[tag])) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged with VALID tag ${tag}`);
newTags[tag].push(room); newTags[tag].push(room);
inTag = true; inTag = true;
} }
@ -495,11 +533,11 @@ export class Algorithm extends EventEmitter {
} }
if (!inTag) { if (!inTag) {
// TODO: Determine if DM and push there instead: https://github.com/vector-im/riot-web/issues/14236 if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
newTags[DefaultTagID.Untagged].push(room); newTags[DefaultTagID.DM].push(room);
} else {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 newTags[DefaultTagID.Untagged].push(room);
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Untagged`); }
} }
} }
@ -560,7 +598,7 @@ export class Algorithm extends EventEmitter {
/** /**
* Updates the roomsToTags map * Updates the roomsToTags map
*/ */
protected updateTagsFromCache() { private updateTagsFromCache() {
const newMap = {}; const newMap = {};
const tags = Object.keys(this.cachedRooms); const tags = Object.keys(this.cachedRooms);
@ -607,21 +645,118 @@ export class Algorithm extends EventEmitter {
* processing. * processing.
*/ */
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> { public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
if (!window.mx_QuietRoomListLogging) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Handle room update for ${room.roomId} called with cause ${cause}`);
}
if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from"); if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
// Note: check the isSticky against the room ID just in case the reference is wrong
const isSticky = this._stickyRoom && this._stickyRoom.room && this._stickyRoom.room.roomId === room.roomId;
if (cause === RoomUpdateCause.NewRoom) { if (cause === RoomUpdateCause.NewRoom) {
const isForLastSticky = this._lastStickyRoom && this._lastStickyRoom.room === room;
const roomTags = this.roomIdsToTags[room.roomId]; const roomTags = this.roomIdsToTags[room.roomId];
if (roomTags && roomTags.length > 0) { const hasTags = roomTags && roomTags.length > 0;
// Don't change the cause if the last sticky room is being re-added. If we fail to
// pass the cause through as NewRoom, we'll fail to lie to the algorithm and thus
// lose the room.
if (hasTags && !isForLastSticky) {
console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`); console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`);
cause = RoomUpdateCause.PossibleTagChange; cause = RoomUpdateCause.PossibleTagChange;
} }
// Check to see if the room is known first
let knownRoomRef = this.rooms.includes(room);
if (hasTags && !knownRoomRef) {
console.warn(`${room.roomId} might be a reference change - attempting to update reference`);
this.rooms = this.rooms.map(r => r.roomId === room.roomId ? room : r);
knownRoomRef = this.rooms.includes(room);
if (!knownRoomRef) {
console.warn(`${room.roomId} is still not referenced. It may be sticky.`);
}
}
// If we have tags for a room and don't have the room referenced, something went horribly
// wrong - the reference should have been updated above.
if (hasTags && !knownRoomRef && !isSticky) {
throw new Error(`${room.roomId} is missing from room array but is known - trying to find duplicate`);
}
// Like above, update the reference to the sticky room if we need to
if (hasTags && isSticky) {
// Go directly in and set the sticky room's new reference, being careful not
// to trigger a sticky room update ourselves.
this._stickyRoom.room = room;
}
// If after all that we're still a NewRoom update, add the room if applicable.
// We don't do this for the sticky room (because it causes duplication issues)
// or if we know about the reference (as it should be replaced).
if (cause === RoomUpdateCause.NewRoom && !isSticky && !knownRoomRef) {
this.rooms.push(room);
}
} }
if (cause === RoomUpdateCause.PossibleTagChange) { if (cause === RoomUpdateCause.PossibleTagChange) {
// TODO: Be smarter and splice rather than regen the planet. https://github.com/vector-im/riot-web/issues/14035 let didTagChange = false;
// TODO: No-op if no change. https://github.com/vector-im/riot-web/issues/14035 const oldTags = this.roomIdsToTags[room.roomId] || [];
await this.setKnownRooms(this.rooms); const newTags = this.getTagsForRoom(room);
return true; const diff = arrayDiff(oldTags, newTags);
if (diff.removed.length > 0 || diff.added.length > 0) {
for (const rmTag of diff.removed) {
if (!window.mx_QuietRoomListLogging) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Removing ${room.roomId} from ${rmTag}`);
}
const algorithm: OrderingAlgorithm = this.algorithms[rmTag];
if (!algorithm) throw new Error(`No algorithm for ${rmTag}`);
await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
this.cachedRooms[rmTag] = algorithm.orderedRooms;
}
for (const addTag of diff.added) {
if (!window.mx_QuietRoomListLogging) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Adding ${room.roomId} to ${addTag}`);
}
const algorithm: OrderingAlgorithm = this.algorithms[addTag];
if (!algorithm) throw new Error(`No algorithm for ${addTag}`);
await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom);
this.cachedRooms[addTag] = algorithm.orderedRooms;
}
// Update the tag map so we don't regen it in a moment
this.roomIdsToTags[room.roomId] = newTags;
if (!window.mx_QuietRoomListLogging) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Changing update cause for ${room.roomId} to Timeline to sort rooms`);
}
cause = RoomUpdateCause.Timeline;
didTagChange = true;
} else {
if (!window.mx_QuietRoomListLogging) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`Received no-op update for ${room.roomId} - changing to Timeline update`);
}
cause = RoomUpdateCause.Timeline;
}
if (didTagChange && isSticky) {
// Manually update the tag for the sticky room without triggering a sticky room
// update. The update will be handled implicitly by the sticky room handling and
// requires no changes on our part, if we're in the middle of a sticky room change.
if (this._lastStickyRoom) {
this._stickyRoom = {
room,
tag: this.roomIdsToTags[room.roomId][0],
position: 0, // right at the top as it changed tags
};
} else {
// We have to clear the lock as the sticky room change will trigger updates.
await this.setStickyRoom(room);
}
}
} }
// If the update is for a room change which might be the sticky room, prevent it. We // If the update is for a room change which might be the sticky room, prevent it. We
@ -629,14 +764,27 @@ export class Algorithm extends EventEmitter {
// as the sticky room relies on this. // as the sticky room relies on this.
if (cause !== RoomUpdateCause.NewRoom && cause !== RoomUpdateCause.RoomRemoved) { if (cause !== RoomUpdateCause.NewRoom && cause !== RoomUpdateCause.RoomRemoved) {
if (this.stickyRoom === room) { if (this.stickyRoom === room) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 if (!window.mx_QuietRoomListLogging) {
console.warn(`[RoomListDebug] Received ${cause} update for sticky room ${room.roomId} - ignoring`); // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.warn(`[RoomListDebug] Received ${cause} update for sticky room ${room.roomId} - ignoring`);
}
return false; return false;
} }
} }
if (cause === RoomUpdateCause.NewRoom && !this.roomIdsToTags[room.roomId]) { if (!this.roomIdsToTags[room.roomId]) {
console.log(`[RoomListDebug] Updating tags for new room ${room.roomId} (${room.name})`); if (CAUSES_REQUIRING_ROOM.includes(cause)) {
if (!window.mx_QuietRoomListLogging) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.warn(`Skipping tag update for ${room.roomId} because we don't know about the room`);
}
return false;
}
if (!window.mx_QuietRoomListLogging) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Updating tags for room ${room.roomId} (${room.name})`);
}
// Get the tags for the room and populate the cache // Get the tags for the room and populate the cache
const roomTags = this.getTagsForRoom(room).filter(t => !isNullOrUndefined(this.cachedRooms[t])); const roomTags = this.getTagsForRoom(room).filter(t => !isNullOrUndefined(this.cachedRooms[t]));
@ -646,9 +794,19 @@ export class Algorithm extends EventEmitter {
if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`); if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`);
this.roomIdsToTags[room.roomId] = roomTags; this.roomIdsToTags[room.roomId] = roomTags;
if (!window.mx_QuietRoomListLogging) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Updated tags for ${room.roomId}:`, roomTags);
}
} }
let tags = this.roomIdsToTags[room.roomId]; if (!window.mx_QuietRoomListLogging) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Reached algorithmic handling for ${room.roomId} and cause ${cause}`);
}
const tags = this.roomIdsToTags[room.roomId];
if (!tags) { if (!tags) {
console.warn(`No tags known for "${room.name}" (${room.roomId})`); console.warn(`No tags known for "${room.name}" (${room.roomId})`);
return false; return false;
@ -668,6 +826,10 @@ export class Algorithm extends EventEmitter {
changed = true; changed = true;
} }
return true; if (!window.mx_QuietRoomListLogging) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Finished handling ${room.roomId} with cause ${cause} (changed=${changed})`);
}
return changed;
} }
} }

View file

@ -19,47 +19,29 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { RoomUpdateCause, TagID } from "../../models"; import { RoomUpdateCause, TagID } from "../../models";
import { SortAlgorithm } from "../models"; import { SortAlgorithm } from "../models";
import { sortRoomsWithAlgorithm } from "../tag-sorting"; import { sortRoomsWithAlgorithm } from "../tag-sorting";
import * as Unread from '../../../../Unread';
import { OrderingAlgorithm } from "./OrderingAlgorithm"; import { OrderingAlgorithm } from "./OrderingAlgorithm";
import { NotificationColor } from "../../../notifications/NotificationColor";
/** import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore";
* The determined category of a room.
*/
export enum Category {
/**
* The room has unread mentions within.
*/
Red = "RED",
/**
* The room has unread notifications within. Note that these are not unread
* mentions - they are simply messages which the user has asked to cause a
* badge count update or push notification.
*/
Grey = "GREY",
/**
* The room has unread messages within (grey without the badge).
*/
Bold = "BOLD",
/**
* The room has no relevant unread messages within.
*/
Idle = "IDLE",
}
interface ICategorizedRoomMap { interface ICategorizedRoomMap {
// @ts-ignore - TS wants this to be a string, but we know better // @ts-ignore - TS wants this to be a string, but we know better
[category: Category]: Room[]; [category: NotificationColor]: Room[];
} }
interface ICategoryIndex { interface ICategoryIndex {
// @ts-ignore - TS wants this to be a string, but we know better // @ts-ignore - TS wants this to be a string, but we know better
[category: Category]: number; // integer [category: NotificationColor]: number; // integer
} }
// Caution: changing this means you'll need to update a bunch of assumptions and // Caution: changing this means you'll need to update a bunch of assumptions and
// comments! Check the usage of Category carefully to figure out what needs changing // comments! Check the usage of Category carefully to figure out what needs changing
// if you're going to change this array's order. // if you're going to change this array's order.
const CATEGORY_ORDER = [Category.Red, Category.Grey, Category.Bold, Category.Idle]; const CATEGORY_ORDER = [
NotificationColor.Red,
NotificationColor.Grey,
NotificationColor.Bold,
NotificationColor.None, // idle
];
/** /**
* An implementation of the "importance" algorithm for room list sorting. Where * An implementation of the "importance" algorithm for room list sorting. Where
@ -87,18 +69,15 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) { public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
super(tagId, initialSortingAlgorithm); super(tagId, initialSortingAlgorithm);
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Constructed an ImportanceAlgorithm for ${tagId}`);
} }
// noinspection JSMethodCanBeStatic // noinspection JSMethodCanBeStatic
private categorizeRooms(rooms: Room[]): ICategorizedRoomMap { private categorizeRooms(rooms: Room[]): ICategorizedRoomMap {
const map: ICategorizedRoomMap = { const map: ICategorizedRoomMap = {
[Category.Red]: [], [NotificationColor.Red]: [],
[Category.Grey]: [], [NotificationColor.Grey]: [],
[Category.Bold]: [], [NotificationColor.Bold]: [],
[Category.Idle]: [], [NotificationColor.None]: [],
}; };
for (const room of rooms) { for (const room of rooms) {
const category = this.getRoomCategory(room); const category = this.getRoomCategory(room);
@ -108,25 +87,11 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
} }
// noinspection JSMethodCanBeStatic // noinspection JSMethodCanBeStatic
private getRoomCategory(room: Room): Category { private getRoomCategory(room: Room): NotificationColor {
// Function implementation borrowed from old RoomListStore // It's fine for us to call this a lot because it's cached, and we shouldn't be
// wasting anything by doing so as the store holds single references
const mentions = room.getUnreadNotificationCount('highlight') > 0; const state = RoomNotificationStateStore.instance.getRoomState(room, this.tagId);
if (mentions) { return state.color;
return Category.Red;
}
let unread = room.getUnreadNotificationCount() > 0;
if (unread) {
return Category.Grey;
}
unread = Unread.doesRoomHaveUnreadMessages(room);
if (unread) {
return Category.Bold;
}
return Category.Idle;
} }
public async setRooms(rooms: Room[]): Promise<any> { public async setRooms(rooms: Room[]): Promise<any> {
@ -160,7 +125,10 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
this.cachedOrderedRooms.splice(this.indices[category], 0, room); // splice in the new room (pre-adjusted) this.cachedOrderedRooms.splice(this.indices[category], 0, room); // splice in the new room (pre-adjusted)
} else if (cause === RoomUpdateCause.RoomRemoved) { } else if (cause === RoomUpdateCause.RoomRemoved) {
const roomIdx = this.getRoomIndex(room); const roomIdx = this.getRoomIndex(room);
if (roomIdx === -1) return false; // no change if (roomIdx === -1) {
console.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`);
return false; // no change
}
const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices); const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices);
this.alterCategoryPositionBy(oldCategory, -1, this.indices); this.alterCategoryPositionBy(oldCategory, -1, this.indices);
this.cachedOrderedRooms.splice(roomIdx, 1); // remove the room this.cachedOrderedRooms.splice(roomIdx, 1); // remove the room
@ -169,15 +137,6 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
} }
} }
private getRoomIndex(room: Room): number {
let roomIdx = this.cachedOrderedRooms.indexOf(room);
if (roomIdx === -1) { // can only happen if the js-sdk's store goes sideways.
console.warn(`Degrading performance to find missing room in "${this.tagId}": ${room.roomId}`);
roomIdx = this.cachedOrderedRooms.findIndex(r => r.roomId === room.roomId);
}
return roomIdx;
}
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> { public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
try { try {
await this.updateLock.acquireAsync(); await this.updateLock.acquireAsync();
@ -226,7 +185,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
} }
} }
private async sortCategory(category: Category) { private async sortCategory(category: NotificationColor) {
// This should be relatively quick because the room is usually inserted at the top of the // This should be relatively quick because the room is usually inserted at the top of the
// category, and most popular sorting algorithms will deal with trying to keep the active // category, and most popular sorting algorithms will deal with trying to keep the active
// room at the top/start of the category. For the few algorithms that will have to move the // room at the top/start of the category. For the few algorithms that will have to move the
@ -243,7 +202,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
} }
// noinspection JSMethodCanBeStatic // noinspection JSMethodCanBeStatic
private getCategoryFromIndices(index: number, indices: ICategoryIndex): Category { private getCategoryFromIndices(index: number, indices: ICategoryIndex): NotificationColor {
for (let i = 0; i < CATEGORY_ORDER.length; i++) { for (let i = 0; i < CATEGORY_ORDER.length; i++) {
const category = CATEGORY_ORDER[i]; const category = CATEGORY_ORDER[i];
const isLast = i === (CATEGORY_ORDER.length - 1); const isLast = i === (CATEGORY_ORDER.length - 1);
@ -259,7 +218,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
} }
// noinspection JSMethodCanBeStatic // noinspection JSMethodCanBeStatic
private moveRoomIndexes(nRooms: number, fromCategory: Category, toCategory: Category, indices: ICategoryIndex) { private moveRoomIndexes(nRooms: number, fromCategory: NotificationColor, toCategory: NotificationColor, indices: ICategoryIndex) {
// We have to update the index of the category *after* the from/toCategory variables // We have to update the index of the category *after* the from/toCategory variables
// in order to update the indices correctly. Because the room is moving from/to those // in order to update the indices correctly. Because the room is moving from/to those
// categories, the next category's index will change - not the category we're modifying. // categories, the next category's index will change - not the category we're modifying.
@ -270,7 +229,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
this.alterCategoryPositionBy(toCategory, +nRooms, indices); this.alterCategoryPositionBy(toCategory, +nRooms, indices);
} }
private alterCategoryPositionBy(category: Category, n: number, indices: ICategoryIndex) { private alterCategoryPositionBy(category: NotificationColor, n: number, indices: ICategoryIndex) {
// Note: when we alter a category's index, we actually have to modify the ones following // Note: when we alter a category's index, we actually have to modify the ones following
// the target and not the target itself. // the target and not the target itself.

View file

@ -28,9 +28,6 @@ export class NaturalAlgorithm extends OrderingAlgorithm {
public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) { public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
super(tagId, initialSortingAlgorithm); super(tagId, initialSortingAlgorithm);
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Constructed a NaturalAlgorithm for ${tagId}`);
} }
public async setRooms(rooms: Room[]): Promise<any> { public async setRooms(rooms: Room[]): Promise<any> {
@ -50,8 +47,12 @@ export class NaturalAlgorithm extends OrderingAlgorithm {
if (cause === RoomUpdateCause.NewRoom) { if (cause === RoomUpdateCause.NewRoom) {
this.cachedOrderedRooms.push(room); this.cachedOrderedRooms.push(room);
} else if (cause === RoomUpdateCause.RoomRemoved) { } else if (cause === RoomUpdateCause.RoomRemoved) {
const idx = this.cachedOrderedRooms.indexOf(room); const idx = this.getRoomIndex(room);
if (idx >= 0) this.cachedOrderedRooms.splice(idx, 1); if (idx >= 0) {
this.cachedOrderedRooms.splice(idx, 1);
} else {
console.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`);
}
} }
// TODO: Optimize this to avoid useless operations: https://github.com/vector-im/riot-web/issues/14035 // TODO: Optimize this to avoid useless operations: https://github.com/vector-im/riot-web/issues/14035

View file

@ -70,4 +70,13 @@ export abstract class OrderingAlgorithm {
* @returns True if the update requires the Algorithm to update the presentation layers. * @returns True if the update requires the Algorithm to update the presentation layers.
*/ */
public abstract handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean>; public abstract handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean>;
protected getRoomIndex(room: Room): number {
let roomIdx = this.cachedOrderedRooms.indexOf(room);
if (roomIdx === -1) { // can only happen if the js-sdk's store goes sideways.
console.warn(`Degrading performance to find missing room in "${this.tagId}": ${room.roomId}`);
roomIdx = this.cachedOrderedRooms.findIndex(r => r.roomId === room.roomId);
}
return roomIdx;
}
} }

View file

@ -19,6 +19,7 @@ import { TagID } from "../../models";
import { IAlgorithm } from "./IAlgorithm"; import { IAlgorithm } from "./IAlgorithm";
import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import * as Unread from "../../../../Unread"; import * as Unread from "../../../../Unread";
import { EffectiveMembership, getEffectiveMembership } from "../../membership";
/** /**
* Sorts rooms according to the last event's timestamp in each room that seems * Sorts rooms according to the last event's timestamp in each room that seems
@ -37,6 +38,8 @@ export class RecentAlgorithm implements IAlgorithm {
// actually changed (probably needs to be done higher up?) then we could do an // actually changed (probably needs to be done higher up?) then we could do an
// insertion sort or similar on the limited set of changes. // insertion sort or similar on the limited set of changes.
const myUserId = MatrixClientPeg.get().getUserId();
const tsCache: { [roomId: string]: number } = {}; const tsCache: { [roomId: string]: number } = {};
const getLastTs = (r: Room) => { const getLastTs = (r: Room) => {
if (tsCache[r.roomId]) { if (tsCache[r.roomId]) {
@ -50,13 +53,23 @@ export class RecentAlgorithm implements IAlgorithm {
return Number.MAX_SAFE_INTEGER; return Number.MAX_SAFE_INTEGER;
} }
// If the room hasn't been joined yet, it probably won't have a timeline to
// parse. We'll still fall back to the timeline if this fails, but chances
// are we'll at least have our own membership event to go off of.
const effectiveMembership = getEffectiveMembership(r.getMyMembership());
if (effectiveMembership !== EffectiveMembership.Join) {
const membershipEvent = r.currentState.getStateEvents("m.room.member", myUserId);
if (membershipEvent && !Array.isArray(membershipEvent)) {
return membershipEvent.getTs();
}
}
for (let i = r.timeline.length - 1; i >= 0; --i) { for (let i = r.timeline.length - 1; i >= 0; --i) {
const ev = r.timeline[i]; const ev = r.timeline[i];
if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?) if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?)
// TODO: Don't assume we're using the same client as the peg // TODO: Don't assume we're using the same client as the peg
if (ev.getSender() === MatrixClientPeg.get().getUserId() if (ev.getSender() === myUserId || Unread.eventTriggersUnreadCount(ev)) {
|| Unread.eventTriggersUnreadCount(ev)) {
return ev.getTs(); return ev.getTs();
} }
} }

View file

@ -52,8 +52,6 @@ export class CommunityFilterCondition extends EventEmitter implements IFilterCon
const beforeRoomIds = this.roomIds; const beforeRoomIds = this.roomIds;
this.roomIds = (await GroupStore.getGroupRooms(this.community.groupId)).map(r => r.roomId); this.roomIds = (await GroupStore.getGroupRooms(this.community.groupId)).map(r => r.roomId);
if (arrayHasDiff(beforeRoomIds, this.roomIds)) { if (arrayHasDiff(beforeRoomIds, this.roomIds)) {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log("Updating filter for group: ", this.community.groupId);
this.emit(FILTER_CHANGED); this.emit(FILTER_CHANGED);
} }
}; };

View file

@ -41,8 +41,6 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio
public set search(val: string) { public set search(val: string) {
this._search = val; this._search = val;
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log("Updating filter for room name search:", this._search);
this.emit(FILTER_CHANGED); this.emit(FILTER_CHANGED);
} }

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

@ -0,0 +1,56 @@
/*
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.
*/
/**
* A utility to ensure that a function is only called once triggered with
* a mark applied. Multiple marks can be applied to the function, however
* the function will only be called once upon trigger().
*
* The function starts unmarked.
*/
export class MarkedExecution {
private marked = false;
/**
* Creates a MarkedExecution for the provided function.
* @param fn The function to be called upon trigger if marked.
*/
constructor(private fn: () => void) {
}
/**
* Resets the mark without calling the function.
*/
public reset() {
this.marked = false;
}
/**
* Marks the function to be called upon trigger().
*/
public mark() {
this.marked = true;
}
/**
* If marked, the function will be called, otherwise this does nothing.
*/
public trigger() {
if (!this.marked) return;
this.reset(); // reset first just in case the fn() causes a trigger()
this.fn();
}
}

View file

@ -111,7 +111,7 @@ export function pillifyLinks(nodes, mxEvent, pills) {
type={Pill.TYPE_AT_ROOM_MENTION} type={Pill.TYPE_AT_ROOM_MENTION}
inMessage={true} inMessage={true}
room={room} room={room}
shouldShowPillAvatar={true} shouldShowPillAvatar={shouldShowPillAvatar}
/>; />;
ReactDOM.render(pill, pillContainer); ReactDOM.render(pill, pillContainer);

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
// Returns a promise which resolves with a given value after the given number of ms // Returns a promise which resolves with a given value after the given number of ms
export function sleep<T>(ms: number, value: T): Promise<T> { export function sleep<T>(ms: number, value?: T): Promise<T> {
return new Promise((resolve => { setTimeout(resolve, ms, value); })); return new Promise((resolve => { setTimeout(resolve, ms, value); }));
} }

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

@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import ReactTestUtils from 'react-dom/test-utils'; import ReactTestUtils from 'react-dom/test-utils';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import lolex from 'lolex';
import * as TestUtils from '../../../test-utils'; import * as TestUtils from '../../../test-utils';
@ -15,11 +14,18 @@ import GroupStore from '../../../../src/stores/GroupStore.js';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk'; import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import {DefaultTagID} from "../../../../src/stores/room-list/models"; import {DefaultTagID} from "../../../../src/stores/room-list/models";
import RoomListStore, {LISTS_UPDATE_EVENT, RoomListStore2} from "../../../../src/stores/room-list/RoomListStore2";
import RoomListLayoutStore from "../../../../src/stores/room-list/RoomListLayoutStore";
function generateRoomId() { function generateRoomId() {
return '!' + Math.random().toString().slice(2, 10) + ':domain'; return '!' + Math.random().toString().slice(2, 10) + ':domain';
} }
function waitForRoomListStoreUpdate() {
return new Promise((resolve) => {
RoomListStore.instance.once(LISTS_UPDATE_EVENT, () => resolve());
});
}
describe('RoomList', () => { describe('RoomList', () => {
function createRoom(opts) { function createRoom(opts) {
@ -34,7 +40,6 @@ describe('RoomList', () => {
let client = null; let client = null;
let root = null; let root = null;
const myUserId = '@me:domain'; const myUserId = '@me:domain';
let clock = null;
const movingRoomId = '!someroomid'; const movingRoomId = '!someroomid';
let movingRoom; let movingRoom;
@ -43,25 +48,25 @@ describe('RoomList', () => {
let myMember; let myMember;
let myOtherMember; let myOtherMember;
beforeEach(function() { beforeEach(async function(done) {
RoomListStore2.TEST_MODE = true;
TestUtils.stubClient(); TestUtils.stubClient();
client = MatrixClientPeg.get(); client = MatrixClientPeg.get();
client.credentials = {userId: myUserId}; client.credentials = {userId: myUserId};
//revert this to prototype method as the test-utils monkey-patches this to return a hardcoded value //revert this to prototype method as the test-utils monkey-patches this to return a hardcoded value
client.getUserId = MatrixClient.prototype.getUserId; client.getUserId = MatrixClient.prototype.getUserId;
clock = lolex.install();
DMRoomMap.makeShared(); DMRoomMap.makeShared();
parentDiv = document.createElement('div'); parentDiv = document.createElement('div');
document.body.appendChild(parentDiv); document.body.appendChild(parentDiv);
const RoomList = sdk.getComponent('views.rooms.RoomList'); const RoomList = sdk.getComponent('views.rooms.RoomList2');
const WrappedRoomList = TestUtils.wrapInMatrixClientContext(RoomList); const WrappedRoomList = TestUtils.wrapInMatrixClientContext(RoomList);
root = ReactDOM.render( root = ReactDOM.render(
<DragDropContext> <DragDropContext>
<WrappedRoomList searchFilter="" /> <WrappedRoomList searchFilter="" onResize={() => {}} />
</DragDropContext> </DragDropContext>
, parentDiv); , parentDiv);
ReactTestUtils.findRenderedComponentWithType(root, RoomList); ReactTestUtils.findRenderedComponentWithType(root, RoomList);
@ -102,23 +107,29 @@ describe('RoomList', () => {
}); });
client.getRoom = (roomId) => roomMap[roomId]; client.getRoom = (roomId) => roomMap[roomId];
// Now that everything has been set up, prepare and update the store
await RoomListStore.instance.makeReady(client);
done();
}); });
afterEach((done) => { afterEach(async (done) => {
if (parentDiv) { if (parentDiv) {
ReactDOM.unmountComponentAtNode(parentDiv); ReactDOM.unmountComponentAtNode(parentDiv);
parentDiv.remove(); parentDiv.remove();
parentDiv = null; parentDiv = null;
} }
clock.uninstall(); await RoomListLayoutStore.instance.resetLayouts();
await RoomListStore.instance.resetStore();
done(); done();
}); });
function expectRoomInSubList(room, subListTest) { function expectRoomInSubList(room, subListTest) {
const RoomSubList = sdk.getComponent('structures.RoomSubList'); const RoomSubList = sdk.getComponent('views.rooms.RoomSublist2');
const RoomTile = sdk.getComponent('views.rooms.RoomTile'); const RoomTile = sdk.getComponent('views.rooms.RoomTile2');
const subLists = ReactTestUtils.scryRenderedComponentsWithType(root, RoomSubList); const subLists = ReactTestUtils.scryRenderedComponentsWithType(root, RoomSubList);
const containingSubList = subLists.find(subListTest); const containingSubList = subLists.find(subListTest);
@ -140,20 +151,20 @@ describe('RoomList', () => {
expect(expectedRoomTile.props.room).toBe(room); expect(expectedRoomTile.props.room).toBe(room);
} }
function expectCorrectMove(oldTag, newTag) { function expectCorrectMove(oldTagId, newTagId) {
const getTagSubListTest = (tag) => { const getTagSubListTest = (tagId) => {
if (tag === undefined) return (s) => s.props.label.endsWith('Rooms'); return (s) => s.props.tagId === tagId;
return (s) => s.props.tagName === tag;
}; };
// Default to finding the destination sublist with newTag // Default to finding the destination sublist with newTag
const destSubListTest = getTagSubListTest(newTag); const destSubListTest = getTagSubListTest(newTagId);
const srcSubListTest = getTagSubListTest(oldTag); const srcSubListTest = getTagSubListTest(oldTagId);
// Set up the room that will be moved such that it has the correct state for a room in // Set up the room that will be moved such that it has the correct state for a room in
// the section for oldTag // the section for oldTagId
if (['m.favourite', 'm.lowpriority'].includes(oldTag)) movingRoom.tags = {[oldTag]: {}}; if (oldTagId === DefaultTagID.Favourite || oldTagId === DefaultTagID.LowPriority) {
if (oldTag === DefaultTagID.DM) { movingRoom.tags = {[oldTagId]: {}};
} else if (oldTagId === DefaultTagID.DM) {
// Mock inverse m.direct // Mock inverse m.direct
DMRoomMap.shared().roomToUser = { DMRoomMap.shared().roomToUser = {
[movingRoom.roomId]: '@someotheruser:domain', [movingRoom.roomId]: '@someotheruser:domain',
@ -162,17 +173,12 @@ describe('RoomList', () => {
dis.dispatch({action: 'MatrixActions.sync', prevState: null, state: 'PREPARED', matrixClient: client}); dis.dispatch({action: 'MatrixActions.sync', prevState: null, state: 'PREPARED', matrixClient: client});
clock.runAll();
expectRoomInSubList(movingRoom, srcSubListTest); expectRoomInSubList(movingRoom, srcSubListTest);
dis.dispatch({action: 'RoomListActions.tagRoom.pending', request: { dis.dispatch({action: 'RoomListActions.tagRoom.pending', request: {
oldTag, newTag, room: movingRoom, oldTagId, newTagId, room: movingRoom,
}}); }});
// Run all setTimeouts for dispatches and room list rate limiting
clock.runAll();
expectRoomInSubList(movingRoom, destSubListTest); expectRoomInSubList(movingRoom, destSubListTest);
} }
@ -269,6 +275,12 @@ describe('RoomList', () => {
}; };
GroupStore._notifyListeners(); GroupStore._notifyListeners();
// We also have to mock the client's getGroup function for the room list to filter it.
// It's not smart enough to tell the difference between a real group and a template though.
client.getGroup = (groupId) => {
return {groupId};
};
// Select tag // Select tag
dis.dispatch({action: 'select_tag', tag: '+group:domain'}, true); dis.dispatch({action: 'select_tag', tag: '+group:domain'}, true);
} }
@ -277,17 +289,14 @@ describe('RoomList', () => {
setupSelectedTag(); setupSelectedTag();
}); });
it('displays the correct rooms when the groups rooms are changed', () => { it('displays the correct rooms when the groups rooms are changed', async () => {
GroupStore.getGroupRooms = (groupId) => { GroupStore.getGroupRooms = (groupId) => {
return [movingRoom, otherRoom]; return [movingRoom, otherRoom];
}; };
GroupStore._notifyListeners(); GroupStore._notifyListeners();
// Run through RoomList debouncing await waitForRoomListStoreUpdate();
clock.runAll(); expectRoomInSubList(otherRoom, (s) => s.props.tagId === DefaultTagID.Untagged);
// By default, the test will
expectRoomInSubList(otherRoom, (s) => s.props.label.endsWith('Rooms'));
}); });
itDoesCorrectOptimisticUpdatesForDraggedRoomTiles(); itDoesCorrectOptimisticUpdatesForDraggedRoomTiles();

View file

@ -15,10 +15,12 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
const {findSublist} = require("./create-room");
module.exports = async function acceptInvite(session, name) { module.exports = async function acceptInvite(session, name) {
session.log.step(`accepts "${name}" invite`); session.log.step(`accepts "${name}" invite`);
//TODO: brittle selector const inviteSublist = await findSublist(session, "invites");
const invitesHandles = await session.queryAll('.mx_RoomTile_name.mx_RoomTile_invite'); const invitesHandles = await inviteSublist.$$(".mx_RoomTile2_name");
const invitesWithText = await Promise.all(invitesHandles.map(async (inviteHandle) => { const invitesWithText = await Promise.all(invitesHandles.map(async (inviteHandle) => {
const text = await session.innerText(inviteHandle); const text = await session.innerText(inviteHandle);
return {inviteHandle, text}; return {inviteHandle, text};

View file

@ -16,21 +16,27 @@ limitations under the License.
*/ */
async function openRoomDirectory(session) { async function openRoomDirectory(session) {
const roomDirectoryButton = await session.query('.mx_LeftPanel_explore .mx_AccessibleButton'); const roomDirectoryButton = await session.query('.mx_LeftPanel2_exploreButton');
await roomDirectoryButton.click(); await roomDirectoryButton.click();
} }
async function findSublist(session, name) {
const sublists = await session.queryAll('.mx_RoomSublist2');
for (const sublist of sublists) {
const header = await sublist.$('.mx_RoomSublist2_headerText');
const headerText = await session.innerText(header);
if (headerText.toLowerCase().includes(name.toLowerCase())) {
return sublist;
}
}
throw new Error(`could not find room list section that contains '${name}' in header`);
}
async function createRoom(session, roomName, encrypted=false) { async function createRoom(session, roomName, encrypted=false) {
session.log.step(`creates room "${roomName}"`); session.log.step(`creates room "${roomName}"`);
const roomListHeaders = await session.queryAll('.mx_RoomSubList_labelContainer'); const roomsSublist = await findSublist(session, "rooms");
const roomListHeaderLabels = await Promise.all(roomListHeaders.map(h => session.innerText(h))); const addRoomButton = await roomsSublist.$(".mx_RoomSublist2_auxButton");
const roomsIndex = roomListHeaderLabels.findIndex(l => l.toLowerCase().includes("rooms"));
if (roomsIndex === -1) {
throw new Error("could not find room list section that contains 'rooms' in header");
}
const roomsHeader = roomListHeaders[roomsIndex];
const addRoomButton = await roomsHeader.$(".mx_RoomSubList_addRoom");
await addRoomButton.click(); await addRoomButton.click();
const roomNameInput = await session.query('.mx_CreateRoomDialog_name input'); const roomNameInput = await session.query('.mx_CreateRoomDialog_name input');
@ -51,14 +57,8 @@ async function createRoom(session, roomName, encrypted=false) {
async function createDm(session, invitees) { async function createDm(session, invitees) {
session.log.step(`creates DM with ${JSON.stringify(invitees)}`); session.log.step(`creates DM with ${JSON.stringify(invitees)}`);
const roomListHeaders = await session.queryAll('.mx_RoomSubList_labelContainer'); const dmsSublist = await findSublist(session, "people");
const roomListHeaderLabels = await Promise.all(roomListHeaders.map(h => session.innerText(h))); const startChatButton = await dmsSublist.$(".mx_RoomSublist2_auxButton");
const dmsIndex = roomListHeaderLabels.findIndex(l => l.toLowerCase().includes('direct messages'));
if (dmsIndex === -1) {
throw new Error("could not find room list section that contains 'direct messages' in header");
}
const dmsHeader = roomListHeaders[dmsIndex];
const startChatButton = await dmsHeader.$(".mx_RoomSubList_addRoom");
await startChatButton.click(); await startChatButton.click();
const inviteesEditor = await session.query('.mx_InviteDialog_editor textarea'); const inviteesEditor = await session.query('.mx_InviteDialog_editor textarea');
@ -83,4 +83,4 @@ async function createDm(session, invitees) {
session.log.done(); session.log.done();
} }
module.exports = {openRoomDirectory, createRoom, createDm}; module.exports = {openRoomDirectory, findSublist, createRoom, createDm};

View file

@ -1257,6 +1257,11 @@
resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.10.tgz#cc658ca319b6355399efc1f5b9e818f1a24bf999" resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.10.tgz#cc658ca319b6355399efc1f5b9e818f1a24bf999"
integrity sha512-1UzDldn9GfYYEsWWnn/P4wkTlkZDH7lDb0wBMGbtIQc9zXEQq7FlKBdZUn6OBqD8sKZZ2RQO2mAjGpXiDGoRmQ== integrity sha512-1UzDldn9GfYYEsWWnn/P4wkTlkZDH7lDb0wBMGbtIQc9zXEQq7FlKBdZUn6OBqD8sKZZ2RQO2mAjGpXiDGoRmQ==
"@types/counterpart@^0.18.1":
version "0.18.1"
resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.1.tgz#b1b784d9e54d9879f0a8cb12f2caedab65430fe8"
integrity sha512-PRuFlBBkvdDOtxlIASzTmkEFar+S66Ek48NVVTWMUjtJAdn5vyMSN8y6IZIoIymGpR36q2nZbIYazBWyFxL+IQ==
"@types/fbemitter@*": "@types/fbemitter@*":
version "2.0.32" version "2.0.32"
resolved "https://registry.yarnpkg.com/@types/fbemitter/-/fbemitter-2.0.32.tgz#8ed204da0f54e9c8eaec31b1eec91e25132d082c" resolved "https://registry.yarnpkg.com/@types/fbemitter/-/fbemitter-2.0.32.tgz#8ed204da0f54e9c8eaec31b1eec91e25132d082c"
@ -1303,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"
@ -1367,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"
@ -2494,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==
@ -3774,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"
@ -6877,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==
@ -7048,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"
@ -7081,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"
@ -7133,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"