mirror of
https://github.com/element-hq/element-web
synced 2024-11-24 10:15:43 +03:00
Merge branch 'develop' into joriks/delabs-font-scaling
This commit is contained in:
commit
59e153e024
97 changed files with 3672 additions and 1313 deletions
|
@ -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",
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
30
res/css/views/avatars/_PulsedAvatar.scss
Normal file
30
res/css/views/avatars/_PulsedAvatar.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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%;
|
||||||
|
|
|
@ -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
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
89
res/css/views/voip/_CallContainer.scss
Normal file
89
res/css/views/voip/_CallContainer.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
96
res/css/views/voip/_CallView2.scss
Normal file
96
res/css/views/voip/_CallView2.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
3
res/img/feather-customised/favourites.svg
Normal file
3
res/img/feather-customised/favourites.svg
Normal 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 |
|
@ -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;
|
||||||
|
|
||||||
// ********************
|
// ********************
|
||||||
|
|
||||||
|
|
|
@ -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] };
|
||||||
|
|
11
src/@types/global.d.ts
vendored
11
src/@types/global.d.ts
vendored
|
@ -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
38
src/@types/polyfill.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -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':
|
||||||
|
|
|
@ -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":
|
|
@ -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
24
src/RoomNotifsTypes.ts
Normal 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;
|
|
@ -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();
|
||||||
|
|
|
@ -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} />;
|
||||||
|
};
|
||||||
|
|
51
src/accessibility/context_menu/ContextMenuButton.tsx
Normal file
51
src/accessibility/context_menu/ContextMenuButton.tsx
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
/*
|
||||||
|
Copyright 2015, 2016 OpenMarket Ltd
|
||||||
|
Copyright 2018 New Vector Ltd
|
||||||
|
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import AccessibleButton, {IProps as IAccessibleButtonProps} from "../../components/views/elements/AccessibleButton";
|
||||||
|
|
||||||
|
interface IProps extends IAccessibleButtonProps {
|
||||||
|
label?: string;
|
||||||
|
// whether or not the context menu is currently open
|
||||||
|
isExpanded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
|
||||||
|
export const ContextMenuButton: React.FC<IProps> = ({
|
||||||
|
label,
|
||||||
|
isExpanded,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
onContextMenu,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<AccessibleButton
|
||||||
|
{...props}
|
||||||
|
onClick={onClick}
|
||||||
|
onContextMenu={onContextMenu || onClick}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
aria-haspopup={true}
|
||||||
|
aria-expanded={isExpanded}
|
||||||
|
>
|
||||||
|
{ children }
|
||||||
|
</AccessibleButton>
|
||||||
|
);
|
||||||
|
};
|
30
src/accessibility/context_menu/MenuGroup.tsx
Normal file
30
src/accessibility/context_menu/MenuGroup.tsx
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
/*
|
||||||
|
Copyright 2015, 2016 OpenMarket Ltd
|
||||||
|
Copyright 2018 New Vector Ltd
|
||||||
|
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface IProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semantic component for representing a role=group for grouping menu radios/checkboxes
|
||||||
|
export const MenuGroup: React.FC<IProps> = ({children, label, ...props}) => {
|
||||||
|
return <div {...props} role="group" aria-label={label}>
|
||||||
|
{ children }
|
||||||
|
</div>;
|
||||||
|
};
|
35
src/accessibility/context_menu/MenuItem.tsx
Normal file
35
src/accessibility/context_menu/MenuItem.tsx
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
Copyright 2015, 2016 OpenMarket Ltd
|
||||||
|
Copyright 2018 New Vector Ltd
|
||||||
|
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import AccessibleButton from "../../components/views/elements/AccessibleButton";
|
||||||
|
|
||||||
|
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semantic component for representing a role=menuitem
|
||||||
|
export const MenuItem: React.FC<IProps> = ({children, label, ...props}) => {
|
||||||
|
return (
|
||||||
|
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={label}>
|
||||||
|
{ children }
|
||||||
|
</AccessibleButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
43
src/accessibility/context_menu/MenuItemCheckbox.tsx
Normal file
43
src/accessibility/context_menu/MenuItemCheckbox.tsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
Copyright 2015, 2016 OpenMarket Ltd
|
||||||
|
Copyright 2018 New Vector Ltd
|
||||||
|
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import AccessibleButton from "../../components/views/elements/AccessibleButton";
|
||||||
|
|
||||||
|
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
|
||||||
|
label?: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semantic component for representing a role=menuitemcheckbox
|
||||||
|
export const MenuItemCheckbox: React.FC<IProps> = ({children, label, active, disabled, ...props}) => {
|
||||||
|
return (
|
||||||
|
<AccessibleButton
|
||||||
|
{...props}
|
||||||
|
role="menuitemcheckbox"
|
||||||
|
aria-checked={active}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{ children }
|
||||||
|
</AccessibleButton>
|
||||||
|
);
|
||||||
|
};
|
43
src/accessibility/context_menu/MenuItemRadio.tsx
Normal file
43
src/accessibility/context_menu/MenuItemRadio.tsx
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
/*
|
||||||
|
Copyright 2015, 2016 OpenMarket Ltd
|
||||||
|
Copyright 2018 New Vector Ltd
|
||||||
|
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import AccessibleButton from "../../components/views/elements/AccessibleButton";
|
||||||
|
|
||||||
|
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
|
||||||
|
label?: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semantic component for representing a role=menuitemradio
|
||||||
|
export const MenuItemRadio: React.FC<IProps> = ({children, label, active, disabled, ...props}) => {
|
||||||
|
return (
|
||||||
|
<AccessibleButton
|
||||||
|
{...props}
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={active}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
disabled={disabled}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{ children }
|
||||||
|
</AccessibleButton>
|
||||||
|
);
|
||||||
|
};
|
64
src/accessibility/context_menu/StyledMenuItemCheckbox.tsx
Normal file
64
src/accessibility/context_menu/StyledMenuItemCheckbox.tsx
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
/*
|
||||||
|
Copyright 2015, 2016 OpenMarket Ltd
|
||||||
|
Copyright 2018 New Vector Ltd
|
||||||
|
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import {Key} from "../../Keyboard";
|
||||||
|
import StyledCheckbox from "../../components/views/elements/StyledCheckbox";
|
||||||
|
|
||||||
|
interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
|
||||||
|
label?: string;
|
||||||
|
onChange(); // we handle keyup/down ourselves so lose the ChangeEvent
|
||||||
|
onClose(): void; // gets called after onChange on Key.ENTER
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semantic component for representing a styled role=menuitemcheckbox
|
||||||
|
export const StyledMenuItemCheckbox: React.FC<IProps> = ({children, label, onChange, onClose, ...props}) => {
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === Key.ENTER || e.key === Key.SPACE) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onChange();
|
||||||
|
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
|
||||||
|
if (e.key === Key.ENTER) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyUp = (e: React.KeyboardEvent) => {
|
||||||
|
// prevent the input default handler as we handle it on keydown to match
|
||||||
|
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
|
||||||
|
if (e.key === Key.SPACE || e.key === Key.ENTER) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<StyledCheckbox
|
||||||
|
{...props}
|
||||||
|
role="menuitemcheckbox"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={label}
|
||||||
|
onChange={onChange}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onKeyUp={onKeyUp}
|
||||||
|
>
|
||||||
|
{ children }
|
||||||
|
</StyledCheckbox>
|
||||||
|
);
|
||||||
|
};
|
64
src/accessibility/context_menu/StyledMenuItemRadio.tsx
Normal file
64
src/accessibility/context_menu/StyledMenuItemRadio.tsx
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
/*
|
||||||
|
Copyright 2015, 2016 OpenMarket Ltd
|
||||||
|
Copyright 2018 New Vector Ltd
|
||||||
|
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import {Key} from "../../Keyboard";
|
||||||
|
import StyledRadioButton from "../../components/views/elements/StyledRadioButton";
|
||||||
|
|
||||||
|
interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
|
||||||
|
label?: string;
|
||||||
|
onChange(); // we handle keyup/down ourselves so lose the ChangeEvent
|
||||||
|
onClose(): void; // gets called after onChange on Key.ENTER
|
||||||
|
}
|
||||||
|
|
||||||
|
// Semantic component for representing a styled role=menuitemradio
|
||||||
|
export const StyledMenuItemRadio: React.FC<IProps> = ({children, label, onChange, onClose, ...props}) => {
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === Key.ENTER || e.key === Key.SPACE) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
onChange();
|
||||||
|
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
|
||||||
|
if (e.key === Key.ENTER) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onKeyUp = (e: React.KeyboardEvent) => {
|
||||||
|
// prevent the input default handler as we handle it on keydown to match
|
||||||
|
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
|
||||||
|
if (e.key === Key.SPACE || e.key === Key.ENTER) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<StyledRadioButton
|
||||||
|
{...props}
|
||||||
|
role="menuitemradio"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label={label}
|
||||||
|
onChange={onChange}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onKeyUp={onKeyUp}
|
||||||
|
>
|
||||||
|
{ children }
|
||||||
|
</StyledRadioButton>
|
||||||
|
);
|
||||||
|
};
|
|
@ -16,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";
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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}
|
||||||
/>);
|
/>);
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>;
|
|
@ -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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
});
|
}
|
|
@ -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} />
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
});
|
}
|
|
@ -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;
|
|
@ -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]} />
|
/>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
});
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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";
|
||||||
|
|
||||||
|
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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}>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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];
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 = (
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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();
|
||||||
|
|
37
src/components/views/voip/CallContainer.tsx
Normal file
37
src/components/views/voip/CallContainer.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
129
src/components/views/voip/CallPreview2.tsx
Normal file
129
src/components/views/voip/CallPreview2.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
200
src/components/views/voip/CallView2.tsx
Normal file
200
src/components/views/voip/CallView2.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
141
src/components/views/voip/IncomingCallBox2.tsx
Normal file
141
src/components/views/voip/IncomingCallBox2.tsx
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
|
@ -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) {
|
|
@ -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",
|
||||||
}
|
}
|
||||||
|
|
32
src/dispatcher/payloads/ViewRoomDeltaPayload.ts
Normal file
32
src/dispatcher/payloads/ViewRoomDeltaPayload.ts
Normal 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;
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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 isn’t bridging messages to any platforms. <a>Learn more.</a>": "This room isn’t bridging messages to any platforms. <a>Learn more.</a>",
|
"This room isn’t bridging messages to any platforms. <a>Learn more.</a>": "This room isn’t 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",
|
||||||
|
|
|
@ -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 },
|
|
@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -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'];
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
87
src/stores/notifications/NotificationState.ts
Normal file
87
src/stores/notifications/NotificationState.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
101
src/stores/notifications/RoomNotificationStateStore.ts
Normal file
101
src/stores/notifications/RoomNotificationStateStore.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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') {
|
||||||
|
|
73
src/stores/room-list/RoomListLayoutStore.ts
Normal file
73
src/stores/room-list/RoomListLayoutStore.ts
Normal 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;
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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});
|
||||||
}
|
}
|
||||||
|
|
56
src/utils/MarkedExecution.ts
Normal file
56
src/utils/MarkedExecution.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
|
|
@ -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); }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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};
|
||||||
|
|
|
@ -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};
|
||||||
|
|
51
yarn.lock
51
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue