diff --git a/package.json b/package.json index 41ba3f47c1..3d1fb535c0 100644 --- a/package.json +++ b/package.json @@ -89,11 +89,11 @@ "prop-types": "^15.5.8", "qrcode": "^1.4.4", "qs": "^6.6.0", + "re-resizable": "^6.5.2", "react": "^16.9.0", "react-beautiful-dnd": "^4.0.1", "react-dom": "^16.9.0", "react-focus-lock": "^2.2.1", - "react-resizable": "^1.10.1", "react-transition-group": "^4.4.1", "resize-observer-polyfill": "^1.5.0", "sanitize-html": "^1.18.4", @@ -120,7 +120,9 @@ "@babel/register": "^7.7.4", "@peculiar/webcrypto": "^1.0.22", "@types/classnames": "^2.2.10", + "@types/counterpart": "^0.18.1", "@types/flux": "^3.1.9", + "@types/linkifyjs": "^2.1.3", "@types/lodash": "^4.14.152", "@types/modernizr": "^3.5.3", "@types/node": "^12.12.41", @@ -128,6 +130,7 @@ "@types/react": "^16.9", "@types/react-dom": "^16.9.8", "@types/react-transition-group": "^4.4.0", + "@types/sanitize-html": "^1.23.3", "@types/zxcvbn": "^4.4.0", "babel-eslint": "^10.0.3", "babel-jest": "^24.9.0", diff --git a/res/css/_components.scss b/res/css/_components.scss index 8288cf34f6..85e08110ea 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -51,6 +51,7 @@ @import "./views/avatars/_BaseAvatar.scss"; @import "./views/avatars/_DecoratedRoomAvatar.scss"; @import "./views/avatars/_MemberStatusMessageAvatar.scss"; +@import "./views/avatars/_PulsedAvatar.scss"; @import "./views/context_menus/_MessageContextMenu.scss"; @import "./views/context_menus/_RoomTileContextMenu.scss"; @import "./views/context_menus/_StatusMessageContextMenu.scss"; @@ -225,6 +226,8 @@ @import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss"; @import "./views/terms/_InlineTermsAgreement.scss"; @import "./views/verification/_VerificationShowSas.scss"; +@import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallView.scss"; +@import "./views/voip/_CallView2.scss"; @import "./views/voip/_IncomingCallbox.scss"; @import "./views/voip/_VideoView.scss"; diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss index bdaada0d15..935511b160 100644 --- a/res/css/structures/_LeftPanel2.scss +++ b/res/css/structures/_LeftPanel2.scss @@ -14,7 +14,7 @@ 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 +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367 $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; .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 display: flex; @@ -72,7 +76,20 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations width: 100%; overflow-y: hidden; 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-right: 12px; + flex-shrink: 0; // to convince safari's layout engine the flexbox is fine + // Create a flexbox to organize the inputs display: flex; align-items: center; .mx_RoomSearch_expanded + .mx_LeftPanel2_exploreButton { // Cheaty way to return the occupied space to the filter input + flex-basis: 0; margin: 0; width: 0; - // Don't forget to hide the masked ::before icon - visibility: hidden; + // Don't forget to hide the masked ::before icon, + // using display:none or visibility:hidden would break accessibility + &::before { + content: none; + } } .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 { flex-grow: 1; // fill the available space overflow-y: auto; diff --git a/res/css/views/avatars/_DecoratedRoomAvatar.scss b/res/css/views/avatars/_DecoratedRoomAvatar.scss index b500d44a43..900f351074 100644 --- a/res/css/views/avatars/_DecoratedRoomAvatar.scss +++ b/res/css/views/avatars/_DecoratedRoomAvatar.scss @@ -24,7 +24,7 @@ limitations under the License. right: 0; } - .mx_NotificationBadge { + .mx_NotificationBadge, .mx_RoomTile2_badgeContainer { position: absolute; top: 0; right: 0; diff --git a/res/css/views/avatars/_PulsedAvatar.scss b/res/css/views/avatars/_PulsedAvatar.scss new file mode 100644 index 0000000000..ce9e3382ab --- /dev/null +++ b/res/css/views/avatars/_PulsedAvatar.scss @@ -0,0 +1,30 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_PulsedAvatar { + @keyframes shadow-pulse { + 0% { + box-shadow: 0 0 0 0px rgba($accent-color, 0.2); + } + 100% { + box-shadow: 0 0 0 6px rgba($accent-color, 0); + } + } + + img { + animation: shadow-pulse 1s infinite; + } +} diff --git a/res/css/views/rooms/_JumpToBottomButton.scss b/res/css/views/rooms/_JumpToBottomButton.scss index 63cf574596..23018df8da 100644 --- a/res/css/views/rooms/_JumpToBottomButton.scss +++ b/res/css/views/rooms/_JumpToBottomButton.scss @@ -41,6 +41,11 @@ limitations under the License. // with text-align in parent display: inline-block; padding: 0 4px; + color: $roomtile-badge-fg-color; + background-color: $roomtile-name-color; +} + +.mx_JumpToBottomButton_highlight .mx_JumpToBottomButton_badge { color: $secondary-accent-color; background-color: $warning-color; } diff --git a/res/css/views/rooms/_RoomBreadcrumbs2.scss b/res/css/views/rooms/_RoomBreadcrumbs2.scss index 6e5a5fbb16..0c3c41622e 100644 --- a/res/css/views/rooms/_RoomBreadcrumbs2.scss +++ b/res/css/views/rooms/_RoomBreadcrumbs2.scss @@ -14,7 +14,7 @@ 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 +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367 .mx_RoomBreadcrumbs2 { width: 100%; diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss index 0e76152f86..633c33feea 100644 --- a/res/css/views/rooms/_RoomSublist2.scss +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -14,7 +14,7 @@ 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 +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367 .mx_RoomSublist2 { // The sublist is a column of rows, essentially @@ -24,9 +24,7 @@ limitations under the License. margin-left: 8px; width: 100%; - &:first-child { - margin-top: 12px; // so we're not up against the search/filter - } + flex-shrink: 0; // to convince safari's layout engine the flexbox is fine .mx_RoomSublist2_headerContainer { // Create a flexbox to make alignment easy @@ -49,13 +47,15 @@ limitations under the License. padding-bottom: 8px; height: 24px; + // Hide the header container if the contained element is stickied. + // We don't use display:none as that causes the header to go away too. + &.mx_RoomSublist2_headerContainer_hasSticky { + height: 0; + } + .mx_RoomSublist2_stickable { flex: 1; max-width: 100%; - z-index: 2; // Prioritize headers in the visible list over sticky ones - - // Set the same background color as the room list for sticky headers - background-color: $roomlist2-bg-color; // Create a flexbox to make ordering easy display: flex; @@ -67,7 +67,6 @@ limitations under the License. // when sticky scrolls instead of collapses the list. &.mx_RoomSublist2_headerContainer_sticky { position: fixed; - z-index: 1; // over top of other elements, but still under the ones in the visible list height: 32px; // to match the header container // width set by JS } @@ -182,7 +181,6 @@ limitations under the License. } .mx_RoomSublist2_resizeBox { - margin-bottom: 4px; // for the resize handle position: relative; // Create another flexbox column for the tiles @@ -190,93 +188,89 @@ limitations under the License. flex-direction: column; overflow: hidden; - .mx_RoomSublist2_showNButton { - cursor: pointer; - font-size: $font-13px; - line-height: $font-18px; - color: $roomtile2-preview-color; - - // This is the same color as the left panel background because it needs - // to occlude the lastmost tile in the list. - background-color: $roomlist2-bg-color; - - // Update the render() function for RoomSublist2 if these change - // Update the ListLayout class for minVisibleTiles if these change. - // - // At 24px high and 8px padding on the top this equates to 0.65 of - // 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 + .mx_RoomSublist2_tiles { + flex: 1 0 0; + overflow: hidden; + // need this to be flex otherwise the overflow hidden from above + // 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 display: flex; - align-items: center; + flex-direction: column; + } - .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_resizerHandles_showNButton { + flex: 0 0 32px; + } - .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_isCutting::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 4px; - box-shadow: 0px -2px 3px rgba(46, 47, 50, 0.08); - } + .mx_RoomSublist2_resizerHandles { + flex: 0 0 4px; } // Class name comes from the ResizableBox component // The hover state needs to use the whole sublist, not just the resizable box, // so that selector is below and one level higher. - .react-resizable-handle { + .mx_RoomSublist2_resizerHandle { cursor: ns-resize; border-radius: 3px; - // Update RESIZE_HANDLE_HEIGHT if this changes - height: 4px; + // Override styles from library + width: unset !important; + height: 4px !important; // Update RESIZE_HANDLE_HEIGHT if this changes // This is positioned directly below the 'show more' button. position: absolute; - bottom: 0; + bottom: 0 !important; // override from library // Together, these make the bar 64px wide - left: calc(50% - 32px); - right: calc(50% - 32px); + // These are also overridden from the library + left: calc(50% - 32px) !important; + right: calc(50% - 32px) !important; } &:hover, &.mx_RoomSublist2_hasMenuOpen { - .react-resizable-handle { + .mx_RoomSublist2_resizerHandle { opacity: 0.8; background-color: $primary-fg-color; } } } + .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, &:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:focus-within, &:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:hover { @@ -322,13 +316,13 @@ limitations under the License. .mx_RoomSublist2_resizeBox { align-items: center; + } - .mx_RoomSublist2_showNButton { - flex-direction: column; + .mx_RoomSublist2_showNButton { + flex-direction: column; - .mx_RoomSublist2_showNButtonChevron { - margin-right: 12px; // to center - } + .mx_RoomSublist2_showNButtonChevron { + margin-right: 12px; // to center } } diff --git a/res/css/views/rooms/_RoomTile2.scss b/res/css/views/rooms/_RoomTile2.scss index 7b606ab947..7348398a10 100644 --- a/res/css/views/rooms/_RoomTile2.scss +++ b/res/css/views/rooms/_RoomTile2.scss @@ -14,7 +14,7 @@ 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 +// 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 .mx_RoomTile2 { @@ -77,7 +77,7 @@ limitations under the License. } } - .mx_RoomTile2_menuButton { + .mx_RoomTile2_notificationsButton { margin-left: 4px; // spacing between buttons } @@ -85,7 +85,6 @@ limitations under the License. height: 16px; // don't set width so that it takes no space when there is no badge to show margin: auto 0; // vertically align - position: relative; // fixes badge alignment in some scenarios // Create a flexbox to make aligning dot badges easier display: flex; @@ -108,7 +107,8 @@ limitations under the License. width: 20px; min-width: 20px; // yay flex height: 20px; - margin: auto 0; + margin-top: auto; + margin-bottom: auto; position: relative; display: none; @@ -223,6 +223,10 @@ limitations under the License. mask-image: url('$(res)/img/feather-customised/star.svg'); } + .mx_RoomTile2_iconFavorite::before { + mask-image: url('$(res)/img/feather-customised/favourites.svg'); + } + .mx_RoomTile2_iconArrowDown::before { mask-image: url('$(res)/img/feather-customised/arrow-down.svg'); } diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss new file mode 100644 index 0000000000..e13c851716 --- /dev/null +++ b/res/css/views/voip/_CallContainer.scss @@ -0,0 +1,89 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_CallContainer { + position: absolute; + right: 20px; + bottom: 72px; + border-radius: 8px; + overflow: hidden; + z-index: 100; + box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08); + + cursor: pointer; + + .mx_CallPreview { + .mx_VideoView { + width: 350px; + } + + .mx_VideoView_localVideoFeed { + border-radius: 8px; + overflow: hidden; + } + } + + .mx_IncomingCallBox2 { + min-width: 250px; + background-color: $primary-bg-color; + padding: 8px; + + .mx_IncomingCallBox2_CallerInfo { + display: flex; + direction: row; + + img { + margin: 8px; + } + + > div { + display: flex; + flex-direction: column; + + justify-content: center; + } + + h1, p { + margin: 0px; + padding: 0px; + font-size: $font-14px; + line-height: $font-16px; + } + + h1 { + font-weight: bold; + } + } + + .mx_IncomingCallBox2_buttons { + padding: 8px; + display: flex; + flex-direction: row; + + > .mx_IncomingCallBox2_spacer { + width: 8px; + } + + > * { + flex-shrink: 0; + flex-grow: 1; + margin-right: 0; + font-size: $font-15px; + line-height: $font-24px; + } + } + } +} diff --git a/res/css/views/voip/_CallView2.scss b/res/css/views/voip/_CallView2.scss new file mode 100644 index 0000000000..3b66e7a175 --- /dev/null +++ b/res/css/views/voip/_CallView2.scss @@ -0,0 +1,96 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 + +.mx_CallView2_voice { + background-color: $accent-color; + color: $accent-fg-color; + cursor: pointer; + padding: 6px; + font-weight: bold; + + border-radius: 8px; + min-width: 200px; + + display: flex; + align-items: center; + + img { + margin: 4px; + margin-right: 10px; + } + + > div { + display: flex; + flex-direction: column; + // Hacky vertical align + padding-top: 3px; + } + + > div > p, + > div > h1 { + padding: 0; + margin: 0; + font-size: $font-13px; + line-height: $font-15px; + } + + > div > p { + font-weight: bold; + } + + > * { + flex-grow: 0; + flex-shrink: 0; + } +} + +.mx_CallView2_hangup { + position: absolute; + + right: 8px; + bottom: 10px; + + height: 35px; + width: 35px; + + border-radius: 35px; + + background-color: $notice-primary-color; + + z-index: 101; + + cursor: pointer; + + &::before { + content: ''; + position: absolute; + + height: 20px; + width: 20px; + + top: 6.5px; + left: 7.5px; + + mask: url('$(res)/img/hangup.svg'); + mask-size: contain; + background-size: contain; + + background-color: $primary-fg-color; + } +} diff --git a/res/img/feather-customised/favourites.svg b/res/img/feather-customised/favourites.svg new file mode 100644 index 0000000000..80f08f6e55 --- /dev/null +++ b/res/img/feather-customised/favourites.svg @@ -0,0 +1,3 @@ +<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> diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index c4b4262642..8469a85bfe 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -36,7 +36,7 @@ $focus-bg-color: #dddddd; $accent-fg-color: #ffffff; $accent-color-50pct: rgba(3, 179, 129, 0.5); //#03b381 in rgb $accent-color-darker: #92caad; -$accent-color-alt: #238CF5; +$accent-color-alt: #238cf5; $selection-fg-color: $primary-bg-color; @@ -46,8 +46,8 @@ $focus-brightness: 105%; $warning-color: $notice-primary-color; // red $orange-warning-color: #ff8d13; // used for true warnings // background colour for warnings -$warning-bg-color: #DF2A8B; -$info-bg-color: #2A9EDF; +$warning-bg-color: #df2a8b; +$info-bg-color: #2a9edf; $mention-user-pill-bg-color: $warning-color; $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; // used by RoomDropTarget -$droptarget-bg-color: rgba(255,255,255,0.5); +$droptarget-bg-color: rgba(255, 255, 255, 0.5); // used by AddressSelector $selected-color: $secondary-accent-color; @@ -157,18 +157,18 @@ $rte-group-pill-color: #aaa; $topleftmenu-color: #212121; $roomheader-color: #45474a; -$roomheader-addroom-bg-color: #91A1C0; +$roomheader-addroom-bg-color: #91a1c0; $roomheader-addroom-fg-color: $accent-fg-color; -$tagpanel-button-color: #91A1C0; -$roomheader-button-color: #91A1C0; -$groupheader-button-color: #91A1C0; -$rightpanel-button-color: #91A1C0; -$composer-button-color: #91A1C0; +$tagpanel-button-color: #91a1c0; +$roomheader-button-color: #91a1c0; +$groupheader-button-color: #91a1c0; +$rightpanel-button-color: #91a1c0; +$composer-button-color: #91a1c0; $roomtopic-color: #9e9e9e; $eventtile-meta-color: $roomtopic-color; $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-default-badge-bg-color: #61708b; -$roomtile2-selected-bg-color: #FFF; +$roomtile2-selected-bg-color: #fff; $presence-online: $accent-color; -$presence-away: orange; // TODO: Get color -$presence-offline: #E3E8F0; +$presence-away: #d9b072; +$presence-offline: #e3e8f0; // ******************** diff --git a/src/@types/common.ts b/src/@types/common.ts index 9109993541..a24d47ac9e 100644 --- a/src/@types/common.ts +++ b/src/@types/common.ts @@ -17,3 +17,4 @@ limitations under the License. // Based on https://stackoverflow.com/a/53229857/3532235 export type Without<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 Writeable<T> = { -readonly [P in keyof T]: T[P] }; diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index ffd3277892..3f970ea8c3 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -20,6 +20,8 @@ import { IMatrixClientPeg } from "../MatrixClientPeg"; import ToastStore from "../stores/ToastStore"; import DeviceListener from "../DeviceListener"; import { RoomListStore2 } from "../stores/room-list/RoomListStore2"; +import { PlatformPeg } from "../PlatformPeg"; +import RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore"; declare global { interface Window { @@ -33,6 +35,11 @@ declare global { mx_ToastStore: ToastStore; mx_DeviceListener: DeviceListener; 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 @@ -45,6 +52,10 @@ declare global { hasStorageAccess?: () => Promise<boolean>; } + interface Navigator { + userLanguage?: string; + } + interface StorageEstimate { usageDetails?: {[key: string]: number}; } diff --git a/src/@types/polyfill.ts b/src/@types/polyfill.ts new file mode 100644 index 0000000000..3ce05d9c2f --- /dev/null +++ b/src/@types/polyfill.ts @@ -0,0 +1,38 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// This is intended to fix re-resizer because of its unguarded `instanceof TouchEvent` checks. +export function polyfillTouchEvent() { + // Firefox doesn't have touch events without touch devices being present, so create a fake + // one we can rely on lying about. + if (!window.TouchEvent) { + // We have no intention of actually using this, so just lie. + window.TouchEvent = class TouchEvent extends UIEvent { + public get altKey(): boolean { return false; } + public get changedTouches(): any { return []; } + public get ctrlKey(): boolean { return false; } + public get metaKey(): boolean { return false; } + public get shiftKey(): boolean { return false; } + public get targetTouches(): any { return []; } + public get touches(): any { return []; } + public get rotation(): number { return 0.0; } + public get scale(): number { return 0.0; } + constructor(eventType: string, params?: any) { + super(eventType, params); + } + }; + } +} diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 1d11495e61..acf72a986c 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -53,6 +53,10 @@ export default abstract class BasePlatform { this.startUpdateCheck = this.startUpdateCheck.bind(this); } + abstract async getConfig(): Promise<{}>; + + abstract getDefaultDeviceDisplayName(): string; + protected onAction = (payload: ActionPayload) => { switch (payload.action) { case 'on_client_not_viable': diff --git a/src/HtmlUtils.js b/src/HtmlUtils.tsx similarity index 83% rename from src/HtmlUtils.js rename to src/HtmlUtils.tsx index 34e9e55d25..6dba041685 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.tsx @@ -17,10 +17,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - -import ReplyThread from "./components/views/elements/ReplyThread"; - import React from 'react'; import sanitizeHtml from 'sanitize-html'; import * as linkify from 'linkifyjs'; @@ -28,12 +24,13 @@ import linkifyMatrix from './linkify-matrix'; import _linkifyElement from 'linkifyjs/element'; import _linkifyString from 'linkifyjs/string'; import classNames from 'classnames'; -import {MatrixClientPeg} from './MatrixClientPeg'; +import EMOJIBASE_REGEX from 'emojibase-regex'; import url from 'url'; -import EMOJIBASE_REGEX from 'emojibase-regex'; +import {MatrixClientPeg} from './MatrixClientPeg'; import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji"; +import ReplyThread from "./components/views/elements/ReplyThread"; linkifyMatrix(linkify); @@ -64,7 +61,7 @@ const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; * need emojification. * unicodeToImage uses this function. */ -function mightContainEmoji(str) { +function mightContainEmoji(str: string) { return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str); } @@ -74,7 +71,7 @@ function mightContainEmoji(str) { * @param {String} char The emoji character * @return {String} The shortcode (such as :thumbup:) */ -export function unicodeToShortcode(char) { +export function unicodeToShortcode(char: string) { const data = getEmojiFromUnicode(char); return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : ''); } @@ -85,7 +82,7 @@ export function unicodeToShortcode(char) { * @param {String} shortcode The shortcode (such as :thumbup:) * @return {String} The emoji character; null if none exists */ -export function shortcodeToUnicode(shortcode) { +export function shortcodeToUnicode(shortcode: string) { shortcode = shortcode.slice(1, shortcode.length - 1); const data = SHORTCODE_TO_EMOJI.get(shortcode); return data ? data.unicode : null; @@ -100,7 +97,7 @@ export function processHtmlForSending(html: string): string { } let contentHTML = ""; - for (let i=0; i < contentDiv.children.length; i++) { + for (let i = 0; i < contentDiv.children.length; i++) { const element = contentDiv.children[i]; if (element.tagName.toLowerCase() === 'p') { contentHTML += element.innerHTML; @@ -122,12 +119,19 @@ export function processHtmlForSending(html: string): string { * Given an untrusted HTML string, return a React node with an sanitized version * of that HTML. */ -export function sanitizedHtmlNode(insaneHtml) { +export function sanitizedHtmlNode(insaneHtml: string) { const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams); return <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 * The biggest threat here is javascript: URIs. @@ -136,7 +140,7 @@ export function sanitizedHtmlNode(insaneHtml) { * other places we need to sanitise URLs. * @return true if permitted, otherwise false */ -export function isUrlPermitted(inputUrl) { +export function isUrlPermitted(inputUrl: string) { try { const parsed = url.parse(inputUrl); if (!parsed.protocol) return false; @@ -147,9 +151,9 @@ export function isUrlPermitted(inputUrl) { } } -const transformTags = { // custom to matrix +const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to matrix // add blank targets to all hyperlinks except vector URLs - 'a': function(tagName, attribs) { + 'a': function(tagName: string, attribs: sanitizeHtml.Attributes) { if (attribs.href) { attribs.target = '_blank'; // by default @@ -162,7 +166,7 @@ const transformTags = { // custom to matrix attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/ return { tagName, attribs }; }, - 'img': function(tagName, attribs) { + 'img': function(tagName: string, attribs: sanitizeHtml.Attributes) { // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag // because transformTags is used _before_ we filter by allowedSchemesByTag and // we don't want to allow images with `https?` `src`s. @@ -176,7 +180,7 @@ const transformTags = { // custom to matrix ); return { tagName, attribs }; }, - 'code': function(tagName, attribs) { + 'code': function(tagName: string, attribs: sanitizeHtml.Attributes) { if (typeof attribs.class !== 'undefined') { // Filter out all classes other than ones starting with language- for syntax highlighting. const classes = attribs.class.split(/\s/).filter(function(cl) { @@ -186,7 +190,7 @@ const transformTags = { // custom to matrix } return { tagName, attribs }; }, - '*': function(tagName, attribs) { + '*': function(tagName: string, attribs: sanitizeHtml.Attributes) { // Delete any style previously assigned, style is an allowedTag for font and span // because attributes are stripped after transforming delete attribs.style; @@ -220,7 +224,7 @@ const transformTags = { // custom to matrix }, }; -const sanitizeHtmlParams = { +const sanitizeHtmlParams: sanitizeHtml.IOptions = { allowedTags: [ 'font', // custom to matrix for IRC-style font coloring 'del', // for markdown @@ -247,16 +251,16 @@ const sanitizeHtmlParams = { }; // this is the same as the above except with less rewriting -const composerSanitizeHtmlParams = Object.assign({}, sanitizeHtmlParams); -composerSanitizeHtmlParams.transformTags = { - 'code': transformTags['code'], - '*': transformTags['*'], +const composerSanitizeHtmlParams: sanitizeHtml.IOptions = { + ...sanitizeHtmlParams, + transformTags: { + 'code': transformTags['code'], + '*': transformTags['*'], + }, }; -class BaseHighlighter { - constructor(highlightClass, highlightLink) { - this.highlightClass = highlightClass; - this.highlightLink = highlightLink; +abstract class BaseHighlighter<T extends React.ReactNode> { + constructor(public highlightClass: string, public highlightLink: string) { } /** @@ -270,47 +274,49 @@ class BaseHighlighter { * returns a list of results (strings for HtmlHighligher, react nodes for * TextHighlighter). */ - applyHighlights(safeSnippet, safeHighlights) { + public applyHighlights(safeSnippet: string, safeHighlights: string[]): T[] { let lastOffset = 0; let offset; - let nodes = []; + let nodes: T[] = []; const safeHighlight = safeHighlights[0]; while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) { // handle preamble if (offset > lastOffset) { - var subSnippet = safeSnippet.substring(lastOffset, offset); - nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights)); + const subSnippet = safeSnippet.substring(lastOffset, offset); + nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights)); } // do highlight. use the original string rather than safeHighlight // to preserve the original casing. const endOffset = offset + safeHighlight.length; - nodes.push(this._processSnippet(safeSnippet.substring(offset, endOffset), true)); + nodes.push(this.processSnippet(safeSnippet.substring(offset, endOffset), true)); lastOffset = endOffset; } // handle postamble if (lastOffset !== safeSnippet.length) { - subSnippet = safeSnippet.substring(lastOffset, undefined); - nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights)); + const subSnippet = safeSnippet.substring(lastOffset, undefined); + nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights)); } return nodes; } - _applySubHighlights(safeSnippet, safeHighlights) { + private applySubHighlights(safeSnippet: string, safeHighlights: string[]): T[] { if (safeHighlights[1]) { // recurse into this range to check for the next set of highlight matches return this.applyHighlights(safeSnippet, safeHighlights.slice(1)); } else { // no more highlights to be found, just return the unhighlighted string - return [this._processSnippet(safeSnippet, false)]; + return [this.processSnippet(safeSnippet, false)]; } } + + protected abstract processSnippet(snippet: string, highlight: boolean): T; } -class HtmlHighlighter extends BaseHighlighter { +class HtmlHighlighter extends BaseHighlighter<string> { /* highlight the given snippet if required * * snippet: content of the span; must have been sanitised @@ -318,28 +324,23 @@ class HtmlHighlighter extends BaseHighlighter { * * returns an HTML string */ - _processSnippet(snippet, highlight) { + protected processSnippet(snippet: string, highlight: boolean): string { if (!highlight) { // nothing required here return snippet; } - let span = "<span class=\""+this.highlightClass+"\">" - + snippet + "</span>"; + let span = `<span class="${this.highlightClass}">${snippet}</span>`; if (this.highlightLink) { - span = "<a href=\""+encodeURI(this.highlightLink)+"\">" - +span+"</a>"; + span = `<a href="${encodeURI(this.highlightLink)}">${span}</a>`; } return span; } } -class TextHighlighter extends BaseHighlighter { - constructor(highlightClass, highlightLink) { - super(highlightClass, highlightLink); - this._key = 0; - } +class TextHighlighter extends BaseHighlighter<React.ReactNode> { + private key = 0; /* create a <span> node to hold the given content * @@ -348,13 +349,12 @@ class TextHighlighter extends BaseHighlighter { * * returns a React node */ - _processSnippet(snippet, highlight) { - const key = this._key++; + protected processSnippet(snippet: string, highlight: boolean): React.ReactNode { + const key = this.key++; - let node = - <span key={key} className={highlight ? this.highlightClass : null}> - { snippet } - </span>; + let node = <span key={key} className={highlight ? this.highlightClass : null}> + { snippet } + </span>; if (highlight && this.highlightLink) { 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 * @@ -378,7 +392,7 @@ class TextHighlighter extends BaseHighlighter { * opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer * opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString) */ -export function bodyToHtml(content, highlights, opts={}) { +export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) { const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body; let bodyHasEmoji = false; @@ -387,9 +401,9 @@ export function bodyToHtml(content, highlights, opts={}) { sanitizeParams = composerSanitizeHtmlParams; } - let strippedBody; - let safeBody; - let isDisplayedWithHtml; + let strippedBody: string; + let safeBody: string; + let isDisplayedWithHtml: boolean; // XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying // to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which // are interrupted by HTML tags (not that we did before) - e.g. 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 * @returns {string} Linkified string */ -export function linkifyString(str, options = linkifyMatrix.options) { +export function linkifyString(str: string, options = linkifyMatrix.options) { return _linkifyString(str, options); } @@ -482,7 +496,7 @@ export function linkifyString(str, options = linkifyMatrix.options) { * @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options * @returns {object} */ -export function linkifyElement(element, options = linkifyMatrix.options) { +export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options) { return _linkifyElement(element, options); } @@ -493,7 +507,7 @@ export function linkifyElement(element, options = linkifyMatrix.options) { * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options * @returns {string} */ -export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.options) { +export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) { return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams); } @@ -504,7 +518,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.option * @param {Node} node * @returns {bool} */ -export function checkBlockNode(node) { +export function checkBlockNode(node: Node) { switch (node.nodeName) { case "H1": case "H2": diff --git a/src/PlatformPeg.js b/src/PlatformPeg.ts similarity index 80% rename from src/PlatformPeg.js rename to src/PlatformPeg.ts index 34131fde7d..1d2b813ebc 100644 --- a/src/PlatformPeg.js +++ b/src/PlatformPeg.ts @@ -1,5 +1,6 @@ /* Copyright 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. @@ -14,6 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import BasePlatform from "./BasePlatform"; + /* * Holds the current Platform object used by the code to do anything * 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 * having to have a MatrixChat object */ -class PlatformPeg { - constructor() { - this.platform = null; - } +export class PlatformPeg { + platform: BasePlatform = null; /** * Returns the current Platform object for the application. @@ -39,12 +40,12 @@ class PlatformPeg { * application. * This should be an instance of a class extending BasePlatform. */ - set(plaf) { + set(plaf: BasePlatform) { this.platform = plaf; } } -if (!global.mxPlatformPeg) { - global.mxPlatformPeg = new PlatformPeg(); +if (!window.mxPlatformPeg) { + window.mxPlatformPeg = new PlatformPeg(); } -export default global.mxPlatformPeg; +export default window.mxPlatformPeg; diff --git a/src/RoomNotifsTypes.ts b/src/RoomNotifsTypes.ts new file mode 100644 index 0000000000..0e7093e434 --- /dev/null +++ b/src/RoomNotifsTypes.ts @@ -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; diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index f667c47b3c..11c955749d 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -660,7 +660,7 @@ export const Commands = [ if (args) { const cli = MatrixClientPeg.get(); - const matches = args.match(/^(\S+)$/); + const matches = args.match(/^(@[^:]+:\S+)$/); if (matches) { const userId = matches[1]; const ignoredUsers = cli.getIgnoredUsers(); @@ -690,7 +690,7 @@ export const Commands = [ if (args) { const cli = MatrixClientPeg.get(); - const matches = args.match(/^(\S+)$/); + const matches = args.match(/(^@[^:]+:\S+$)/); if (matches) { const userId = matches[1]; const ignoredUsers = cli.getIgnoredUsers(); diff --git a/src/accessibility/RovingTabIndex.js b/src/accessibility/RovingTabIndex.tsx similarity index 75% rename from src/accessibility/RovingTabIndex.js rename to src/accessibility/RovingTabIndex.tsx index b481f08fe2..388d67d9f3 100644 --- a/src/accessibility/RovingTabIndex.js +++ b/src/accessibility/RovingTabIndex.tsx @@ -22,9 +22,13 @@ import React, { useMemo, useRef, useReducer, + Reducer, + RefObject, + Dispatch, } from "react"; -import PropTypes from "prop-types"; + import {Key} from "../Keyboard"; +import AccessibleButton from "../components/views/elements/AccessibleButton"; /** * Module to simplify implementing the Roving TabIndex accessibility technique @@ -41,7 +45,19 @@ import {Key} from "../Keyboard"; 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: { activeRef: null, refs: [], // list of refs in DOM order @@ -50,16 +66,22 @@ const RovingTabIndexContext = createContext({ }); RovingTabIndexContext.displayName = "RovingTabIndexContext"; -// TODO use a TypeScript type here -const types = { - REGISTER: "REGISTER", - UNREGISTER: "UNREGISTER", - SET_FOCUS: "SET_FOCUS", -}; +enum Type { + Register = "REGISTER", + Unregister = "UNREGISTER", + SetFocus = "SET_FOCUS", +} -const reducer = (state, action) => { +interface IAction { + type: Type; + payload: { + ref: Ref; + }; +} + +const reducer = (state: IState, action: IAction) => { switch (action.type) { - case types.REGISTER: { + case Type.Register: { if (state.refs.length === 0) { // Our list of refs was empty, set activeRef to this first item return { @@ -92,7 +114,7 @@ const reducer = (state, action) => { ], }; } - case types.UNREGISTER: { + case Type.Unregister: { // filter out the ref which we are removing const refs = state.refs.filter(r => r !== action.payload.ref); @@ -117,7 +139,7 @@ const reducer = (state, action) => { refs, }; } - case types.SET_FOCUS: { + case Type.SetFocus: { // update active ref return { ...state, @@ -129,13 +151,21 @@ const reducer = (state, action) => { } }; -export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) => { - const [state, dispatch] = useReducer(reducer, { +interface IProps { + 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, refs: [], }); - const context = useMemo(() => ({state, dispatch}), [state]); + const context = useMemo<IContext>(() => ({state, dispatch}), [state]); const onKeyDownHandler = useCallback((ev) => { let handled = false; @@ -171,19 +201,17 @@ export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) => { children({onKeyDownHandler}) } </RovingTabIndexContext.Provider>; }; -RovingTabIndexProvider.propTypes = { - handleHomeEnd: PropTypes.bool, - onKeyDown: PropTypes.func, -}; + +type FocusHandler = () => void; // Hook to register a roving tab index // inputRef parameter specifies the ref to use // 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}` // 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); - let ref = useRef(null); + let ref = useRef<HTMLElement>(null); if (inputRef) { // if we are given a ref, use it instead of ours @@ -193,13 +221,13 @@ export const useRovingTabIndex = (inputRef) => { // setup (after refs) useLayoutEffect(() => { context.dispatch({ - type: types.REGISTER, + type: Type.Register, payload: {ref}, }); // teardown return () => { context.dispatch({ - type: types.UNREGISTER, + type: Type.Unregister, payload: {ref}, }); }; @@ -207,7 +235,7 @@ export const useRovingTabIndex = (inputRef) => { const onFocus = useCallback(() => { context.dispatch({ - type: types.SET_FOCUS, + type: Type.SetFocus, payload: {ref}, }); }, [ref, context]); @@ -216,9 +244,28 @@ export const useRovingTabIndex = (inputRef) => { 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. -export const RovingTabIndexWrapper = ({children, inputRef}) => { +export const RovingTabIndexWrapper: React.FC<IRovingTabIndexWrapperProps> = ({children, inputRef}) => { const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); 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} />; +}; + diff --git a/src/accessibility/context_menu/ContextMenuButton.tsx b/src/accessibility/context_menu/ContextMenuButton.tsx new file mode 100644 index 0000000000..c358155e10 --- /dev/null +++ b/src/accessibility/context_menu/ContextMenuButton.tsx @@ -0,0 +1,51 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; + +import AccessibleButton, {IProps as IAccessibleButtonProps} from "../../components/views/elements/AccessibleButton"; + +interface IProps extends IAccessibleButtonProps { + label?: string; + // whether or not the context menu is currently open + isExpanded: boolean; +} + +// Semantic component for representing the AccessibleButton which launches a <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> + ); +}; diff --git a/src/accessibility/context_menu/MenuGroup.tsx b/src/accessibility/context_menu/MenuGroup.tsx new file mode 100644 index 0000000000..9334e17a18 --- /dev/null +++ b/src/accessibility/context_menu/MenuGroup.tsx @@ -0,0 +1,30 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; + +interface IProps extends React.HTMLAttributes<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>; +}; diff --git a/src/accessibility/context_menu/MenuItem.tsx b/src/accessibility/context_menu/MenuItem.tsx new file mode 100644 index 0000000000..64233e51ad --- /dev/null +++ b/src/accessibility/context_menu/MenuItem.tsx @@ -0,0 +1,35 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; + +import AccessibleButton from "../../components/views/elements/AccessibleButton"; + +interface IProps extends React.ComponentProps<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> + ); +}; + diff --git a/src/accessibility/context_menu/MenuItemCheckbox.tsx b/src/accessibility/context_menu/MenuItemCheckbox.tsx new file mode 100644 index 0000000000..5eb8cc4819 --- /dev/null +++ b/src/accessibility/context_menu/MenuItemCheckbox.tsx @@ -0,0 +1,43 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; + +import AccessibleButton from "../../components/views/elements/AccessibleButton"; + +interface IProps extends React.ComponentProps<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> + ); +}; diff --git a/src/accessibility/context_menu/MenuItemRadio.tsx b/src/accessibility/context_menu/MenuItemRadio.tsx new file mode 100644 index 0000000000..472f13ff14 --- /dev/null +++ b/src/accessibility/context_menu/MenuItemRadio.tsx @@ -0,0 +1,43 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; + +import AccessibleButton from "../../components/views/elements/AccessibleButton"; + +interface IProps extends React.ComponentProps<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> + ); +}; diff --git a/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx b/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx new file mode 100644 index 0000000000..d373f892c9 --- /dev/null +++ b/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx @@ -0,0 +1,64 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; + +import {Key} from "../../Keyboard"; +import StyledCheckbox from "../../components/views/elements/StyledCheckbox"; + +interface IProps extends React.ComponentProps<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> + ); +}; diff --git a/src/accessibility/context_menu/StyledMenuItemRadio.tsx b/src/accessibility/context_menu/StyledMenuItemRadio.tsx new file mode 100644 index 0000000000..5e5aa90a38 --- /dev/null +++ b/src/accessibility/context_menu/StyledMenuItemRadio.tsx @@ -0,0 +1,64 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; + +import {Key} from "../../Keyboard"; +import StyledRadioButton from "../../components/views/elements/StyledRadioButton"; + +interface IProps extends React.ComponentProps<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> + ); +}; diff --git a/src/components/structures/ContextMenu.js b/src/components/structures/ContextMenu.tsx similarity index 64% rename from src/components/structures/ContextMenu.js rename to src/components/structures/ContextMenu.tsx index e43b0d1431..cb1349da4b 100644 --- a/src/components/structures/ContextMenu.js +++ b/src/components/structures/ContextMenu.tsx @@ -16,13 +16,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {useRef, useState} from 'react'; -import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; +import React, {CSSProperties, useRef, useState} from "react"; +import ReactDOM from "react-dom"; +import classNames from "classnames"; + import {Key} from "../../Keyboard"; -import * as sdk from "../../index"; -import AccessibleButton from "../views/elements/AccessibleButton"; +import {Writeable} from "../../@types/common"; // Shamelessly ripped off Modal.js. There's probably a better way // of doing reusable widgets like dialog boxes & menus where we go and @@ -30,8 +29,8 @@ import AccessibleButton from "../views/elements/AccessibleButton"; const ContextualMenuContainerId = "mx_ContextualMenu_Container"; -function getOrCreateContainer() { - let container = document.getElementById(ContextualMenuContainerId); +function getOrCreateContainer(): HTMLDivElement { + let container = document.getElementById(ContextualMenuContainerId) as HTMLDivElement; if (!container) { container = document.createElement("div"); @@ -43,50 +42,70 @@ function getOrCreateContainer() { } const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]); + +interface IPosition { + top?: number; + bottom?: number; + left?: number; + right?: number; +} + +export enum ChevronFace { + Top = "top", + Bottom = "bottom", + Left = "left", + Right = "right", + None = "none", +} + +interface IProps extends IPosition { + menuWidth?: number; + menuHeight?: number; + + chevronOffset?: number; + chevronFace?: ChevronFace; + + menuPaddingTop?: number; + menuPaddingBottom?: number; + menuPaddingLeft?: number; + menuPaddingRight?: number; + + zIndex?: number; + + // If true, insert an invisible screen-sized element behind the menu that when clicked will close it. + hasBackground?: boolean; + // whether this context menu should be focus managed. If false it must handle itself + managed?: boolean; + + // Function to be called on menu close + onFinished(); + // on resize callback + windowResize?(); +} + +interface IState { + contextMenuElem: HTMLDivElement; +} + // Generic ContextMenu Portal wrapper // all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1} // this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines. -export class ContextMenu extends React.Component { - static propTypes = { - top: PropTypes.number, - bottom: PropTypes.number, - left: PropTypes.number, - right: PropTypes.number, - menuWidth: PropTypes.number, - menuHeight: PropTypes.number, - chevronOffset: PropTypes.number, - chevronFace: PropTypes.string, // top, bottom, left, right or none - // Function to be called on menu close - onFinished: PropTypes.func.isRequired, - menuPaddingTop: PropTypes.number, - menuPaddingRight: PropTypes.number, - menuPaddingBottom: PropTypes.number, - menuPaddingLeft: PropTypes.number, - zIndex: PropTypes.number, - - // If true, insert an invisible screen-sized element behind the - // menu that when clicked will close it. - hasBackground: PropTypes.bool, - - // on resize callback - windowResize: PropTypes.func, - - managed: PropTypes.bool, // whether this context menu should be focus managed. If false it must handle itself - }; +export class ContextMenu extends React.PureComponent<IProps, IState> { + private initialFocus: HTMLElement; static defaultProps = { hasBackground: true, managed: true, }; - constructor() { - super(); + constructor(props, context) { + super(props, context); this.state = { contextMenuElem: null, }; // persist what had focus when we got initialized so we can return it after - this.initialFocus = document.activeElement; + this.initialFocus = document.activeElement as HTMLElement; } componentWillUnmount() { @@ -94,7 +113,7 @@ export class ContextMenu extends React.Component { this.initialFocus.focus(); } - collectContextMenuRect = (element) => { + private collectContextMenuRect = (element) => { // We don't need to clean up when unmounting, so ignore if (!element) return; @@ -111,7 +130,7 @@ export class ContextMenu extends React.Component { }); }; - onContextMenu = (e) => { + private onContextMenu = (e) => { if (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 // but do not inhibit the default browser menu e.stopPropagation(); }; // Prevent clicks on the background from going through to the component which opened the menu. - _onFinished = (ev: InputEvent) => { + private onFinished = (ev: React.MouseEvent) => { ev.stopPropagation(); ev.preventDefault(); if (this.props.onFinished) this.props.onFinished(); }; - _onMoveFocus = (element, up) => { + private onMoveFocus = (element: Element, up: boolean) => { let descending = false; // are we currently descending or ascending through the DOM tree? do { @@ -181,25 +200,25 @@ export class ContextMenu extends React.Component { } while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role"))); if (element) { - element.focus(); + (element as HTMLElement).focus(); } }; - _onMoveFocusHomeEnd = (element, up) => { + private onMoveFocusHomeEnd = (element: Element, up: boolean) => { let results = element.querySelectorAll('[role^="menuitem"]'); if (!results) { results = element.querySelectorAll('[tab-index]'); } if (results && results.length) { if (up) { - results[0].focus(); + (results[0] as HTMLElement).focus(); } else { - results[results.length - 1].focus(); + (results[results.length - 1] as HTMLElement).focus(); } } }; - _onKeyDown = (ev) => { + private onKeyDown = (ev: React.KeyboardEvent) => { if (!this.props.managed) { if (ev.key === Key.ESCAPE) { this.props.onFinished(); @@ -217,16 +236,16 @@ export class ContextMenu extends React.Component { this.props.onFinished(); break; case Key.ARROW_UP: - this._onMoveFocus(ev.target, true); + this.onMoveFocus(ev.target as Element, true); break; case Key.ARROW_DOWN: - this._onMoveFocus(ev.target, false); + this.onMoveFocus(ev.target as Element, false); break; case Key.HOME: - this._onMoveFocusHomeEnd(this.state.contextMenuElem, true); + this.onMoveFocusHomeEnd(this.state.contextMenuElem, true); break; case Key.END: - this._onMoveFocusHomeEnd(this.state.contextMenuElem, false); + this.onMoveFocusHomeEnd(this.state.contextMenuElem, false); break; default: handled = false; @@ -239,9 +258,8 @@ export class ContextMenu extends React.Component { } }; - renderMenu(hasBackground=this.props.hasBackground) { - const position = {}; - let chevronFace = null; + protected renderMenu(hasBackground = this.props.hasBackground) { + const position: Partial<Writeable<DOMRect>> = {}; const props = this.props; if (props.top) { @@ -250,23 +268,24 @@ export class ContextMenu extends React.Component { position.bottom = props.bottom; } + let chevronFace: ChevronFace; if (props.left) { position.left = props.left; - chevronFace = 'left'; + chevronFace = ChevronFace.Left; } else { position.right = props.right; - chevronFace = 'right'; + chevronFace = ChevronFace.Right; } const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null; - const chevronOffset = {}; + const chevronOffset: CSSProperties = {}; if (props.chevronFace) { chevronFace = props.chevronFace; } - const hasChevron = chevronFace && chevronFace !== "none"; + const hasChevron = chevronFace && chevronFace !== ChevronFace.None; - if (chevronFace === 'top' || chevronFace === 'bottom') { + if (chevronFace === ChevronFace.Top || chevronFace === ChevronFace.Bottom) { chevronOffset.left = props.chevronOffset; } else if (position.top !== undefined) { const target = position.top; @@ -296,13 +315,13 @@ export class ContextMenu extends React.Component { 'mx_ContextualMenu_right': !hasChevron && position.right, 'mx_ContextualMenu_top': !hasChevron && position.top, 'mx_ContextualMenu_bottom': !hasChevron && position.bottom, - 'mx_ContextualMenu_withChevron_left': chevronFace === 'left', - 'mx_ContextualMenu_withChevron_right': chevronFace === 'right', - 'mx_ContextualMenu_withChevron_top': chevronFace === 'top', - 'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom', + 'mx_ContextualMenu_withChevron_left': chevronFace === ChevronFace.Left, + 'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right, + 'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top, + 'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom, }); - const menuStyle = {}; + const menuStyle: CSSProperties = {}; if (props.menuWidth) { menuStyle.width = props.menuWidth; } @@ -333,13 +352,28 @@ export class ContextMenu extends React.Component { let background; if (hasBackground) { 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 ( - <div 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}> + <div + 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 } { props.children } </div> @@ -348,99 +382,13 @@ export class ContextMenu extends React.Component { ); } - render() { + render(): React.ReactChild { return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer()); } } -// Semantic component for representing the AccessibleButton which launches a <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 -export const toRightOf = (elementRect, chevronOffset=12) => { +export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => { const left = elementRect.right + window.pageXOffset + 3; let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset; top -= chevronOffset + 8; // where 8 is half the height of the chevron @@ -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 -export const aboveLeftOf = (elementRect, chevronFace="none") => { - const menuOptions = { chevronFace }; +export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None) => { + const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; const buttonRight = elementRect.right + window.pageXOffset; const buttonBottom = elementRect.bottom + window.pageYOffset; @@ -507,3 +455,12 @@ export function createMenu(ElementClass, props) { return {close: onFinished}; } + +// re-export the semantic helper components for simplicity +export {ContextMenuButton} from "../../accessibility/context_menu/ContextMenuButton"; +export {MenuGroup} from "../../accessibility/context_menu/MenuGroup"; +export {MenuItem} from "../../accessibility/context_menu/MenuItem"; +export {MenuItemCheckbox} from "../../accessibility/context_menu/MenuItemCheckbox"; +export {MenuItemRadio} from "../../accessibility/context_menu/MenuItemRadio"; +export {StyledMenuItemCheckbox} from "../../accessibility/context_menu/StyledMenuItemCheckbox"; +export {StyledMenuItemRadio} from "../../accessibility/context_menu/StyledMenuItemRadio"; diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index 23a9e74646..3c8994b1c0 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -21,6 +21,7 @@ import classNames from "classnames"; import dis from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; import RoomList2 from "../views/rooms/RoomList2"; +import { HEADER_HEIGHT } from "../views/rooms/RoomSublist2"; import { Action } from "../../dispatcher/actions"; import UserMenu from "./UserMenu"; import RoomSearch from "./RoomSearch"; @@ -32,9 +33,10 @@ import ResizeNotifier from "../../utils/ResizeNotifier"; import SettingsStore from "../../settings/SettingsStore"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore2"; import {Key} from "../../Keyboard"; +import IndicatorScrollbar from "../structures/IndicatorScrollbar"; -// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 -// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 +// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14367 +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367 /******************************************************************* * CAUTION * @@ -55,12 +57,20 @@ interface IState { 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> { private listContainerRef: React.RefObject<HTMLDivElement> = createRef(); private tagPanelWatcherRef: string; private focusedElement = null; - - // TODO: a11y: https://github.com/vector-im/riot-web/issues/14180 + private isDoingStickyHeaders = false; constructor(props: IProps) { super(props); @@ -105,40 +115,131 @@ export default class LeftPanel2 extends React.Component<IProps, IState> { }; private handleStickyHeaders(list: HTMLDivElement) { - const rlRect = list.getBoundingClientRect(); - const bottom = rlRect.bottom; - const top = rlRect.top; + if (this.isDoingStickyHeaders) return; + this.isDoingStickyHeaders = true; + 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 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) { - const slRect = sublist.getBoundingClientRect(); - const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist2_stickable"); + header.style.removeProperty("display"); // always clear display:none first - if (slRect.top + headerHeight > bottom && !gotBottom) { - header.classList.add("mx_RoomSublist2_headerContainer_sticky"); - header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom"); - header.style.width = `${headerStickyWidth}px`; - header.style.top = `unset`; - gotBottom = true; - } else if (slRect.top < top) { - header.classList.add("mx_RoomSublist2_headerContainer_sticky"); - header.classList.add("mx_RoomSublist2_headerContainer_stickyTop"); - header.style.width = `${headerStickyWidth}px`; - header.style.top = `${rlRect.top}px`; + // When an element is <=40% off screen, make it take over + const offScreenFactor = 0.4; + const isOffTop = (sublist.offsetTop + (offScreenFactor * HEADER_HEIGHT)) <= topEdge; + const isOffBottom = (sublist.offsetTop + (offScreenFactor * HEADER_HEIGHT)) >= bottomEdge; + + if (isOffTop || sublist === sublists[0]) { + targetStyles.set(header, { stickyTop: true }); + if (lastTopHeader) { + lastTopHeader.style.display = "none"; + targetStyles.set(lastTopHeader, { makeInvisible: true }); + } + lastTopHeader = header; + } else if (isOffBottom && !firstBottomHeader) { + targetStyles.set(header, { stickyBottom: true }); + firstBottomHeader = header; } else { - header.classList.remove("mx_RoomSublist2_headerContainer_sticky"); - header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop"); - header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom"); - header.style.width = `unset`; - header.style.top = `unset`; + targetStyles.set(header, {}); // nothing == clear } } + + // Run over the style changes and make them reality. We check to see if we're about to + // cause a no-op update, as adding/removing properties that are/aren't there cause + // layout updates. + for (const header of targetStyles.keys()) { + const style = targetStyles.get(header); + const headerContainer = header.parentElement; // .mx_RoomSublist2_headerContainer + + if (style.makeInvisible) { + // we will have already removed the 'display: none', so add it back. + header.style.display = "none"; + continue; // nothing else to do, even if sticky somehow + } + + if (style.stickyTop) { + if (!header.classList.contains("mx_RoomSublist2_headerContainer_stickyTop")) { + header.classList.add("mx_RoomSublist2_headerContainer_stickyTop"); + } + + const newTop = `${list.parentElement.offsetTop}px`; + if (header.style.top !== newTop) { + header.style.top = newTop; + } + } else if (style.stickyBottom) { + if (!header.classList.contains("mx_RoomSublist2_headerContainer_stickyBottom")) { + header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom"); + } + } + + if (style.stickyTop || style.stickyBottom) { + if (!header.classList.contains("mx_RoomSublist2_headerContainer_sticky")) { + header.classList.add("mx_RoomSublist2_headerContainer_sticky"); + } + if (!headerContainer.classList.contains("mx_RoomSublist2_headerContainer_hasSticky")) { + headerContainer.classList.add("mx_RoomSublist2_headerContainer_hasSticky"); + } + + const newWidth = `${headerStickyWidth}px`; + if (header.style.width !== newWidth) { + header.style.width = newWidth; + } + } else if (!style.stickyTop && !style.stickyBottom) { + if (header.classList.contains("mx_RoomSublist2_headerContainer_sticky")) { + header.classList.remove("mx_RoomSublist2_headerContainer_sticky"); + } + if (header.classList.contains("mx_RoomSublist2_headerContainer_stickyTop")) { + header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop"); + } + if (header.classList.contains("mx_RoomSublist2_headerContainer_stickyBottom")) { + header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom"); + } + if (headerContainer.classList.contains("mx_RoomSublist2_headerContainer_hasSticky")) { + headerContainer.classList.remove("mx_RoomSublist2_headerContainer_hasSticky"); + } + if (header.style.width) { + header.style.removeProperty('width'); + } + if (header.style.top) { + header.style.removeProperty('top'); + } + } + } + + // add appropriate sticky classes to wrapper so it has + // the necessary top/bottom padding to put the sticky header in + const listWrapper = list.parentElement; // .mx_LeftPanel2_roomListWrapper + if (lastTopHeader) { + listWrapper.classList.add("mx_LeftPanel2_roomListWrapper_stickyTop"); + } else { + listWrapper.classList.remove("mx_LeftPanel2_roomListWrapper_stickyTop"); + } + if (firstBottomHeader) { + listWrapper.classList.add("mx_LeftPanel2_roomListWrapper_stickyBottom"); + } else { + listWrapper.classList.remove("mx_LeftPanel2_roomListWrapper_stickyBottom"); + } } // TODO: Improve header reliability: https://github.com/vector-im/riot-web/issues/14232 @@ -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) => { let element = this.focusedElement; @@ -204,10 +313,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> { if (element) { classes = element.classList; } - } while (element && !( - classes.contains("mx_RoomTile2") || - classes.contains("mx_RoomSublist2_headerText") || - classes.contains("mx_RoomSearch_input"))); + } while (element && !cssClasses.some(c => classes.contains(c))); if (element) { element.focus(); @@ -217,11 +323,14 @@ export default class LeftPanel2 extends React.Component<IProps, IState> { private renderHeader(): React.ReactNode { let breadcrumbs; - if (this.state.showBreadcrumbs) { + if (this.state.showBreadcrumbs && !this.props.isMinimized) { breadcrumbs = ( - <div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer mx_AutoHideScrollbar"> - {this.props.isMinimized ? null : <RoomBreadcrumbs2 />} - </div> + <IndicatorScrollbar + className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer mx_AutoHideScrollbar" + verticalScrollsHorizontally={true} + > + <RoomBreadcrumbs2 /> + </IndicatorScrollbar> ); } @@ -235,17 +344,22 @@ export default class LeftPanel2 extends React.Component<IProps, IState> { private renderSearchExplore(): React.ReactNode { 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 onQueryUpdate={this.onSearch} isMinimized={this.props.isMinimized} onVerticalArrow={this.onKeyDown} + onEnter={this.onEnter} /> <AccessibleButton - // TODO fix the accessibility of this: https://github.com/vector-im/riot-web/issues/14180 className="mx_LeftPanel2_exploreButton" onClick={this.onExplore} - alt={_t("Explore rooms")} + title={_t("Explore rooms")} /> </div> ); @@ -266,6 +380,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> { onFocus={this.onFocus} onBlur={this.onBlur} isMinimized={this.props.isMinimized} + onResize={this.onResize} />; // 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"> {this.renderHeader()} {this.renderSearchExplore()} - <div - className={roomListClasses} - onScroll={this.onScroll} - ref={this.listContainerRef} - // Firefox sometimes makes this element focusable due to - // overflow:scroll;, so force it out of tab order. - tabIndex={-1} - > - {roomList} + <div className="mx_LeftPanel2_roomListWrapper"> + <div + className={roomListClasses} + onScroll={this.onScroll} + ref={this.listContainerRef} + // Firefox sometimes makes this element focusable due to + // overflow:scroll;, so force it out of tab order. + tabIndex={-1} + > + {roomList} + </div> </div> </aside> </div> diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 9fbc98dee3..b65f176089 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -19,7 +19,6 @@ limitations under the License. import * as React from 'react'; import * as PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk/src/client'; -import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { DragDropContext } from 'react-beautiful-dnd'; import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent} from '../../Keyboard'; @@ -53,6 +52,8 @@ import { } from "../../toasts/ServerLimitToast"; import { Action } from "../../dispatcher/actions"; import LeftPanel2 from "./LeftPanel2"; +import CallContainer from '../views/voip/CallContainer'; +import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload"; // We need to fetch each pinned message individually (if we don't already have it) // 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) => { - /* - // 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; const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); 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_DOWN: if (ev.altKey && !ev.ctrlKey && !ev.metaKey) { - dis.dispatch({ - action: 'view_room_delta', + dis.dispatch<ViewRoomDeltaPayload>({ + action: Action.ViewRoomDelta, delta: ev.key === Key.ARROW_UP ? -1 : 1, unread: ev.shiftKey, }); @@ -681,8 +668,7 @@ class LoggedInView extends React.Component<IProps, IState> { disabled={this.props.leftDisabled} /> ); - if (SettingsStore.isFeatureEnabled("feature_new_room_list")) { - // TODO: Supply props like collapsed and disabled to LeftPanel2 + if (SettingsStore.getValue("feature_new_room_list")) { leftPanel = ( <LeftPanel2 isMinimized={this.props.collapseLhs || false} @@ -710,6 +696,7 @@ class LoggedInView extends React.Component<IProps, IState> { </div> </DragDropContext> </div> + <CallContainer /> </MatrixClientContext.Provider> ); } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 315c648e15..89ee1bc22d 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -596,15 +596,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> { } break; } - case 'view_prev_room': - this.viewNextRoom(-1); - break; case 'view_next_room': this.viewNextRoom(1); break; - case 'view_indexed_room': - this.viewIndexedRoom(payload.roomIndex); - break; case Action.ViewUserSettings: { const tabPayload = payload as OpenToTabPayload; 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 // // @param {Object} roomInfo Object containing data about the room to be joined diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 7ed2acf276..231bd92ddf 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -25,7 +25,7 @@ import { Key } from "../../Keyboard"; import AccessibleButton from "../views/elements/AccessibleButton"; 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 * @@ -39,6 +39,7 @@ interface IProps { onQueryUpdate: (newQuery: string) => void; isMinimized: boolean; onVerticalArrow(ev: React.KeyboardEvent); + onEnter(ev: React.KeyboardEvent); } interface IState { @@ -81,6 +82,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> { private openSearch = () => { defaultDispatcher.dispatch({action: "show_left_panel"}); + defaultDispatcher.dispatch({action: "focus_room_filter"}); }; private onChange = () => { @@ -104,7 +106,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> { ev.target.select(); }; - private onBlur = () => { + private onBlur = (ev: React.FocusEvent<HTMLInputElement>) => { this.setState({focused: false}); }; @@ -114,6 +116,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> { defaultDispatcher.fire(Action.FocusComposer); } else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) { 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 = ( <AccessibleButton tabIndex={-1} - className='mx_RoomSearch_clearButton' + title={_t("Clear filter")} + className="mx_RoomSearch_clearButton" onClick={this.clearInput} /> ); @@ -157,8 +162,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> { if (this.props.isMinimized) { icon = ( <AccessibleButton - tabIndex={-1} - className='mx_RoomSearch_icon' + title={_t("Search rooms")} + className="mx_RoomSearch_icon" onClick={this.openSearch} /> ); diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 519c4c1f8e..a9f75ce632 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -2044,6 +2044,7 @@ export default createReactClass({ if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) { const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton'); jumpToBottom = (<JumpToBottomButton + highlight={this.state.room.getUnreadNotificationCount('highlight') > 0} numUnreadMessages={this.state.numUnreadMessages} onScrollToBottomClick={this.jumpToLiveTimeline} />); diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 5955a046a4..a6eabe25f7 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -14,14 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import * as React from "react"; +import React, { createRef } from "react"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { ActionPayload } from "../../dispatcher/payloads"; import { Action } from "../../dispatcher/actions"; -import { createRef } from "react"; import { _t } from "../../languageHandler"; -import {ContextMenu, ContextMenuButton, MenuItem} from "./ContextMenu"; +import { ChevronFace, ContextMenu, ContextMenuButton, MenuItem } from "./ContextMenu"; import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog"; import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog"; @@ -122,7 +121,7 @@ export default class UserMenu extends React.Component<IProps, IState> { } }; - private onOpenMenuClick = (ev: InputEvent) => { + private onOpenMenuClick = (ev: React.MouseEvent) => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; @@ -235,7 +234,7 @@ export default class UserMenu extends React.Component<IProps, IState> { return ( <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 left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 20} 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")} onClick={(e) => this.onSettingsOpen(e, null)} /> - <MenuButton + {/* <MenuButton iconClassName="mx_UserMenu_iconArchive" label={_t("Archived rooms")} onClick={this.onShowArchived} - /> + /> */} <MenuButton iconClassName="mx_UserMenu_iconMessage" label={_t("Feedback")} @@ -329,7 +328,7 @@ export default class UserMenu extends React.Component<IProps, IState> { className={classes} onClick={this.onOpenMenuClick} inputRef={this.buttonRef} - label={_t("Account settings")} + label={_t("User menu")} isExpanded={!!this.state.contextMenuPosition} onContextMenu={this.onContextMenu} > @@ -348,8 +347,8 @@ export default class UserMenu extends React.Component<IProps, IState> { {name} {buttons} </div> - {this.renderContextMenu()} </ContextMenuButton> + {this.renderContextMenu()} </React.Fragment> ); } diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.tsx similarity index 75% rename from src/components/views/avatars/BaseAvatar.js rename to src/components/views/avatars/BaseAvatar.tsx index 508691e5fd..7f30a7a377 100644 --- a/src/components/views/avatars/BaseAvatar.js +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -18,7 +18,7 @@ limitations under the License. */ import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react'; -import PropTypes from 'prop-types'; +import classNames from 'classnames'; import * as AvatarLogic from '../../../Avatar'; import SettingsStore from "../../../settings/SettingsStore"; import AccessibleButton from '../elements/AccessibleButton'; @@ -26,9 +26,25 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {useEventEmitter} from "../../../hooks/useEventEmitter"; import {toPx} from "../../../utils/units"; -const useImageUrl = ({url, urls}) => { - const [imageUrls, setUrls] = useState([]); - const [urlsIndex, setIndex] = useState(); +interface IProps { + name: string; // The name (first initial used as default) + idName?: string; // ID for generating hash colours + title?: string; // onHover title text + url?: string; // highest priority of them all, shortcut to set in urls[0] + urls?: string[]; // [highest_priority, ... , lowest_priority] + width?: number; + height?: number; + // XXX: resizeMethod not actually used. + resizeMethod?: string; + defaultToInitialLetter?: boolean; // true to add default url + onClick?: React.MouseEventHandler; + inputRef?: React.RefObject<HTMLImageElement & HTMLSpanElement>; + className?: string; +} + +const useImageUrl = ({url, urls}): [string, () => void] => { + const [imageUrls, setUrls] = useState<string[]>([]); + const [urlsIndex, setIndex] = useState<number>(); const onError = useCallback(() => { setIndex(i => i + 1); // try the next one @@ -70,19 +86,20 @@ const useImageUrl = ({url, urls}) => { return [imageUrl, onError]; }; -const BaseAvatar = (props) => { +const BaseAvatar = (props: IProps) => { const { name, idName, title, url, urls, - width=40, - height=40, - resizeMethod="crop", // eslint-disable-line no-unused-vars - defaultToInitialLetter=true, + width = 40, + height = 40, + resizeMethod = "crop", // eslint-disable-line no-unused-vars + defaultToInitialLetter = true, onClick, inputRef, + className, ...otherProps } = props; @@ -117,12 +134,12 @@ const BaseAvatar = (props) => { aria-hidden="true" /> ); - if (onClick != null) { + if (onClick !== null) { return ( <AccessibleButton {...otherProps} element="span" - className="mx_BaseAvatar" + className={classNames("mx_BaseAvatar", className)} onClick={onClick} inputRef={inputRef} > @@ -132,7 +149,12 @@ const BaseAvatar = (props) => { ); } else { return ( - <span className="mx_BaseAvatar" ref={inputRef} {...otherProps}> + <span + className={classNames("mx_BaseAvatar", className)} + ref={inputRef} + {...otherProps} + role="presentation" + > { textNode } { imgNode } </span> @@ -140,10 +162,10 @@ const BaseAvatar = (props) => { } } - if (onClick != null) { + if (onClick !== null) { return ( <AccessibleButton - className="mx_BaseAvatar mx_BaseAvatar_image" + className={classNames("mx_BaseAvatar mx_BaseAvatar_image", className)} element='img' src={imageUrl} onClick={onClick} @@ -159,7 +181,7 @@ const BaseAvatar = (props) => { } else { return ( <img - className="mx_BaseAvatar mx_BaseAvatar_image" + className={classNames("mx_BaseAvatar mx_BaseAvatar_image", className)} src={imageUrl} onError={onError} 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 type BaseAvatarType = React.FC<IProps>; \ No newline at end of file diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx index e0ad3202b8..40ba15af33 100644 --- a/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -21,8 +21,8 @@ import { TagID } from '../../../stores/room-list/models'; import RoomAvatar from "./RoomAvatar"; import RoomTileIcon from "../rooms/RoomTileIcon"; import NotificationBadge from '../rooms/NotificationBadge'; -import { INotificationState } from "../../../stores/notifications/INotificationState"; -import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState"; +import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; +import { NotificationState } from "../../../stores/notifications/NotificationState"; interface IProps { room: Room; @@ -33,7 +33,7 @@ interface IProps { } interface IState { - notificationState?: INotificationState; + notificationState?: NotificationState; } export default class DecoratedRoomAvatar extends React.PureComponent<IProps, IState> { @@ -42,7 +42,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt super(props); this.state = { - notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag), + notificationState: RoomNotificationStateStore.instance.getRoomState(this.props.room, this.props.tag), }; } diff --git a/src/components/views/avatars/GroupAvatar.js b/src/components/views/avatars/GroupAvatar.tsx similarity index 64% rename from src/components/views/avatars/GroupAvatar.js rename to src/components/views/avatars/GroupAvatar.tsx index 0da57bcb99..e55e2e6fac 100644 --- a/src/components/views/avatars/GroupAvatar.js +++ b/src/components/views/avatars/GroupAvatar.tsx @@ -15,43 +15,36 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; -import * as sdk from '../../../index'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import BaseAvatar from './BaseAvatar'; -export default createReactClass({ - displayName: 'GroupAvatar', +export interface IProps { + groupId?: string; + groupName?: string; + groupAvatarUrl?: string; + width?: number; + height?: number; + resizeMethod?: string; + onClick?: React.MouseEventHandler; +} - propTypes: { - groupId: PropTypes.string, - groupName: PropTypes.string, - groupAvatarUrl: PropTypes.string, - width: PropTypes.number, - height: PropTypes.number, - resizeMethod: PropTypes.string, - onClick: PropTypes.func, - }, +export default class GroupAvatar extends React.Component<IProps> { + public static defaultProps = { + width: 36, + height: 36, + resizeMethod: 'crop', + }; - getDefaultProps: function() { - return { - width: 36, - height: 36, - resizeMethod: 'crop', - }; - }, - - getGroupAvatarUrl: function() { + getGroupAvatarUrl() { return MatrixClientPeg.get().mxcUrlToHttp( this.props.groupAvatarUrl, this.props.width, this.props.height, this.props.resizeMethod, ); - }, + } - render: function() { - const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); + render() { // extract the props we use from props so we can pass any others through // should consider adding this as a global rule in js-sdk? /*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/ @@ -65,5 +58,5 @@ export default createReactClass({ {...otherProps} /> ); - }, -}); + } +} diff --git a/src/components/views/avatars/MemberAvatar.js b/src/components/views/avatars/MemberAvatar.tsx similarity index 64% rename from src/components/views/avatars/MemberAvatar.js rename to src/components/views/avatars/MemberAvatar.tsx index b763129dd8..1d23d85b0f 100644 --- a/src/components/views/avatars/MemberAvatar.js +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -16,48 +16,50 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; -import * as sdk from "../../../index"; import dis from "../../../dispatcher/dispatcher"; import {Action} from "../../../dispatcher/actions"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import BaseAvatar from "./BaseAvatar"; -export default createReactClass({ - displayName: 'MemberAvatar', +interface IProps { + // TODO: replace with correct type + member: any; + fallbackUserId: string; + width: number; + height: number; + resizeMethod: string; + // The onClick to give the avatar + onClick: React.MouseEventHandler; + // Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser` + viewUserOnClick: boolean; + title: string; +} - propTypes: { - member: PropTypes.object, - fallbackUserId: PropTypes.string, - width: PropTypes.number, - height: PropTypes.number, - resizeMethod: PropTypes.string, - // The onClick to give the avatar - onClick: PropTypes.func, - // Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser` - viewUserOnClick: PropTypes.bool, - title: PropTypes.string, - }, +interface IState { + name: string; + title: string; + imageUrl?: string; +} - getDefaultProps: function() { - return { - width: 40, - height: 40, - resizeMethod: 'crop', - viewUserOnClick: false, - }; - }, +export default class MemberAvatar extends React.Component<IProps, IState> { + public static defaultProps = { + width: 40, + height: 40, + resizeMethod: 'crop', + viewUserOnClick: false, + }; - getInitialState: function() { - return this._getState(this.props); - }, + constructor(props: IProps) { + super(props); - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps: function(nextProps) { - this.setState(this._getState(nextProps)); - }, + this.state = MemberAvatar.getState(props); + } - _getState: function(props) { + public static getDerivedStateFromProps(nextProps: IProps): IState { + return MemberAvatar.getState(nextProps); + } + + private static getState(props: IProps): IState { if (props.member && props.member.name) { return { name: props.member.name, @@ -79,11 +81,9 @@ export default createReactClass({ } else { console.error("MemberAvatar called somehow with null member or fallbackUserId"); } - }, - - render: function() { - const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); + } + render() { let {member, fallbackUserId, onClick, viewUserOnClick, ...otherProps} = this.props; const userId = member ? member.userId : fallbackUserId; @@ -100,5 +100,5 @@ export default createReactClass({ <BaseAvatar {...otherProps} name={this.state.name} title={this.state.title} idName={userId} url={this.state.imageUrl} onClick={onClick} /> ); - }, -}); + } +} diff --git a/src/stores/notifications/INotificationState.ts b/src/components/views/avatars/PulsedAvatar.tsx similarity index 67% rename from src/stores/notifications/INotificationState.ts rename to src/components/views/avatars/PulsedAvatar.tsx index 65bd7b7957..94a6c87687 100644 --- a/src/stores/notifications/INotificationState.ts +++ b/src/components/views/avatars/PulsedAvatar.tsx @@ -14,13 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventEmitter } from "events"; -import { NotificationColor } from "./NotificationColor"; +import React from 'react'; -export const NOTIFICATION_STATE_UPDATE = "update"; - -export interface INotificationState extends EventEmitter { - symbol?: string; - count: number; - color: NotificationColor; +interface IProps { } + +const PulsedAvatar: React.FC<IProps> = (props) => { + return <div className="mx_PulsedAvatar"> + {props.children} + </div>; +}; + +export default PulsedAvatar; \ No newline at end of file diff --git a/src/components/views/avatars/RoomAvatar.js b/src/components/views/avatars/RoomAvatar.tsx similarity index 56% rename from src/components/views/avatars/RoomAvatar.js rename to src/components/views/avatars/RoomAvatar.tsx index a72d318b8d..3317ed3a60 100644 --- a/src/components/views/avatars/RoomAvatar.js +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -13,90 +13,96 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; -import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; -import {MatrixClientPeg} from "../../../MatrixClientPeg"; +import React from 'react'; +import Room from 'matrix-js-sdk/src/models/room'; +import {getHttpUriForMxc} from 'matrix-js-sdk/src/content-repo'; + +import BaseAvatar from './BaseAvatar'; +import ImageView from '../elements/ImageView'; +import {MatrixClientPeg} from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; -import * as sdk from "../../../index"; import * as Avatar from '../../../Avatar'; -import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; - -export default createReactClass({ - displayName: 'RoomAvatar', +interface IProps { // Room may be left unset here, but if it is, // oobData.avatarUrl should be set (else there // would be nowhere to get the avatar from) - propTypes: { - room: PropTypes.object, - oobData: PropTypes.object, - width: PropTypes.number, - height: PropTypes.number, - resizeMethod: PropTypes.string, - viewAvatarOnClick: PropTypes.bool, - }, + room?: Room; + // TODO: type when js-sdk has types + oobData?: any; + width?: number; + height?: number; + resizeMethod?: string; + viewAvatarOnClick?: boolean; +} - getDefaultProps: function() { - return { - width: 36, - height: 36, - resizeMethod: 'crop', - oobData: {}, +interface IState { + urls: string[]; +} + +export default class RoomAvatar extends React.Component<IProps, IState> { + public static defaultProps = { + width: 36, + height: 36, + resizeMethod: 'crop', + oobData: {}, + }; + + constructor(props: IProps) { + super(props); + + this.state = { + urls: RoomAvatar.getImageUrls(this.props), }; - }, + } - getInitialState: function() { - return { - urls: this.getImageUrls(this.props), - }; - }, - - componentDidMount: function() { + public componentDidMount() { MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents); - }, + } - componentWillUnmount: function() { + public componentWillUnmount() { const cli = MatrixClientPeg.get(); if (cli) { cli.removeListener("RoomState.events", this.onRoomStateEvents); } - }, + } - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps: function(newProps) { - this.setState({ - urls: this.getImageUrls(newProps), - }); - }, + public static getDerivedStateFromProps(nextProps: IProps): IState { + return { + urls: RoomAvatar.getImageUrls(nextProps), + }; + } - onRoomStateEvents: function(ev) { + // TODO: type when js-sdk has types + private onRoomStateEvents = (ev: any) => { if (!this.props.room || ev.getRoomId() !== this.props.room.roomId || ev.getType() !== 'm.room.avatar' ) return; this.setState({ - urls: this.getImageUrls(this.props), + urls: RoomAvatar.getImageUrls(this.props), }); - }, + }; - getImageUrls: function(props) { + private static getImageUrls(props: IProps): string[] { return [ getHttpUriForMxc( MatrixClientPeg.get().getHomeserverUrl(), + // Default props don't play nicely with getDerivedStateFromProps + //props.oobData !== undefined ? props.oobData.avatarUrl : {}, props.oobData.avatarUrl, Math.floor(props.width * window.devicePixelRatio), Math.floor(props.height * window.devicePixelRatio), props.resizeMethod, ), // highest priority - this.getRoomAvatarUrl(props), + RoomAvatar.getRoomAvatarUrl(props), ].filter(function(url) { - return (url != null && url != ""); + return (url !== null && url !== ""); }); - }, + } - getRoomAvatarUrl: function(props) { + private static getRoomAvatarUrl(props: IProps): string { if (!props.room) return null; return Avatar.avatarUrlForRoom( @@ -105,35 +111,32 @@ export default createReactClass({ Math.floor(props.height * window.devicePixelRatio), props.resizeMethod, ); - }, + } - onRoomAvatarClick: function() { + private onRoomAvatarClick = () => { const avatarUrl = this.props.room.getAvatarUrl( MatrixClientPeg.get().getHomeserverUrl(), null, null, null, false); - const ImageView = sdk.getComponent("elements.ImageView"); const params = { src: avatarUrl, name: this.props.room.name, }; Modal.createDialog(ImageView, params, "mx_Dialog_lightbox"); - }, + }; - render: function() { - const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); - - /*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/ + public render() { const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props; const roomName = room ? room.name : oobData.name; return ( - <BaseAvatar {...otherProps} name={roomName} + <BaseAvatar {...otherProps} + name={roomName} idName={room ? room.roomId : null} urls={this.state.urls} - onClick={this.props.viewAvatarOnClick ? this.onRoomAvatarClick : null} - disabled={!this.state.urls[0]} /> + onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : null} + /> ); - }, -}); + } +} diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index 040147bb16..34481601f7 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -64,7 +64,6 @@ export default function AccessibleButton({ className, ...restProps }: IProps) { - const newProps: IAccessibleButtonProps = restProps; if (!disabled) { newProps.onClick = onClick; diff --git a/src/components/views/elements/EditableItemList.js b/src/components/views/elements/EditableItemList.js index 50d5a3d10f..34e53906a2 100644 --- a/src/components/views/elements/EditableItemList.js +++ b/src/components/views/elements/EditableItemList.js @@ -16,7 +16,7 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {_t} from '../../../languageHandler.js'; +import {_t} from '../../../languageHandler'; import Field from "./Field"; import AccessibleButton from "./AccessibleButton"; diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.js index fc79fc87d0..956b69ca7b 100644 --- a/src/components/views/elements/MemberEventListSummary.js +++ b/src/components/views/elements/MemberEventListSummary.js @@ -1,6 +1,6 @@ /* Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); @@ -23,6 +23,7 @@ import { _t } from '../../../languageHandler'; import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; import * as sdk from "../../../index"; import {MatrixEvent} from "matrix-js-sdk"; +import {isValid3pidInvite} from "../../../RoomInvite"; export default createReactClass({ displayName: 'MemberEventListSummary', @@ -284,6 +285,9 @@ export default createReactClass({ _getTransition: function(e) { if (e.mxEvent.getType() === 'm.room.third_party_invite') { // Handle 3pid invites the same as invites so they get bundled together + if (!isValid3pidInvite(e.mxEvent)) { + return 'invite_withdrawal'; + } return 'invited'; } diff --git a/src/components/views/rooms/JumpToBottomButton.js b/src/components/views/rooms/JumpToBottomButton.js index d3305f498a..b6cefc1231 100644 --- a/src/components/views/rooms/JumpToBottomButton.js +++ b/src/components/views/rooms/JumpToBottomButton.js @@ -16,13 +16,18 @@ limitations under the License. import { _t } from '../../../languageHandler'; import AccessibleButton from '../elements/AccessibleButton'; +import classNames from 'classnames'; export default (props) => { + const className = classNames({ + 'mx_JumpToBottomButton': true, + 'mx_JumpToBottomButton_highlight': props.highlight, + }); let badge; if (props.numUnreadMessages) { badge = (<div className="mx_JumpToBottomButton_badge">{props.numUnreadMessages}</div>); } - return (<div className="mx_JumpToBottomButton"> + return (<div className={className}> <AccessibleButton className="mx_JumpToBottomButton_scrollDown" title={_t("Scroll to most recent messages")} onClick={props.onScrollToBottomClick}> diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx index 829b05fbfc..941a057927 100644 --- a/src/components/views/rooms/NotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge.tsx @@ -22,11 +22,10 @@ import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { readReceiptChangeIsFor } from "../../../utils/read-receipts"; import AccessibleButton from "../elements/AccessibleButton"; import { XOR } from "../../../@types/common"; -import { INotificationState, NOTIFICATION_STATE_UPDATE } from "../../../stores/notifications/INotificationState"; -import { NotificationColor } from "../../../stores/notifications/NotificationColor"; +import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState"; interface IProps { - notification: INotificationState; + notification: NotificationState; /** * If true, the badge will show a count if at all possible. This is typically @@ -97,19 +96,17 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I const {notification, forceCount, roomId, onClick, ...props} = this.props; // 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 // 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. // 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; - let isEmptyBadge = !hasAnySymbol || !hasCount; + let isEmptyBadge = !hasAnySymbol || !notification.hasUnreadCount; if (forceCount) { isEmptyBadge = false; - if (!hasCount) return null; // Can't render a badge + if (!notification.hasUnreadCount) return null; // Can't render a badge } let symbol = notification.symbol || formatMinimalBadgeCount(notification.count); @@ -117,8 +114,8 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I const classes = classNames({ 'mx_NotificationBadge': true, - 'mx_NotificationBadge_visible': isEmptyBadge ? true : hasCount, - 'mx_NotificationBadge_highlighted': hasNotif, + 'mx_NotificationBadge_visible': isEmptyBadge ? true : notification.hasUnreadCount, + 'mx_NotificationBadge_highlighted': notification.hasMentions, 'mx_NotificationBadge_dot': isEmptyBadge, 'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3, 'mx_NotificationBadge_3char': symbol.length > 2, diff --git a/src/components/views/rooms/RoomBreadcrumbs2.tsx b/src/components/views/rooms/RoomBreadcrumbs2.tsx index 687f4dd73e..7d0584ef66 100644 --- a/src/components/views/rooms/RoomBreadcrumbs2.tsx +++ b/src/components/views/rooms/RoomBreadcrumbs2.tsx @@ -16,7 +16,6 @@ limitations under the License. import React from "react"; import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore"; -import AccessibleButton from "../elements/AccessibleButton"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import { _t } from "../../../languageHandler"; import { Room } from "matrix-js-sdk/src/models/room"; @@ -28,8 +27,8 @@ import RoomListStore from "../../../stores/room-list/RoomListStore2"; import { DefaultTagID } from "../../../stores/room-list/models"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; -// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 -// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 +// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14367 +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367 /******************************************************************* * CAUTION * @@ -92,9 +91,6 @@ export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState }; 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 roomTags = RoomListStore.instance.getTagsForRoom(r); const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0]; diff --git a/src/components/views/rooms/RoomList2.tsx b/src/components/views/rooms/RoomList2.tsx index b0bb70c9a0..67787963a3 100644 --- a/src/components/views/rooms/RoomList2.tsx +++ b/src/components/views/rooms/RoomList2.tsx @@ -17,27 +17,32 @@ limitations under the License. */ import * as React from "react"; +import { Dispatcher } from "flux"; +import { Room } from "matrix-js-sdk/src/models/room"; + import { _t, _td } from "../../../languageHandler"; import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; 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 { DefaultTagID, TagID } from "../../../stores/room-list/models"; -import { Dispatcher } from "flux"; import dis from "../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import RoomSublist2 from "./RoomSublist2"; import { ActionPayload } from "../../../dispatcher/payloads"; import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition"; -import { ListLayout } from "../../../stores/room-list/ListLayout"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import GroupAvatar from "../avatars/GroupAvatar"; import TemporaryTile from "./TemporaryTile"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; import { NotificationColor } from "../../../stores/notifications/NotificationColor"; +import { 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: Rename 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/14367 /******************************************************************* * CAUTION * @@ -51,6 +56,7 @@ interface IProps { onKeyDown: (ev: React.KeyboardEvent) => void; onFocus: (ev: React.FocusEvent) => void; onBlur: (ev: React.FocusEvent) => void; + onResize: () => void; resizeNotifier: ResizeNotifier; collapsed: boolean; searchFilter: string; @@ -59,12 +65,9 @@ interface IProps { interface IState { sublists: ITagMap; - layouts: Map<TagID, ListLayout>; } const TAG_ORDER: TagID[] = [ - // -- Community Invites Placeholder -- - DefaultTagID.Invite, DefaultTagID.Favourite, DefaultTagID.DM, @@ -76,7 +79,6 @@ const TAG_ORDER: TagID[] = [ DefaultTagID.ServerNotice, DefaultTagID.Archived, ]; -const COMMUNITY_TAGS_BEFORE_TAG = DefaultTagID.Invite; const CUSTOM_TAGS_BEFORE_TAG = DefaultTagID.LowPriority; const ALWAYS_VISIBLE_TAGS: TagID[] = [ DefaultTagID.DM, @@ -140,14 +142,16 @@ const TAG_AESTHETICS: { export default class RoomList2 extends React.Component<IProps, IState> { private searchFilter: NameFilterCondition = new NameFilterCondition(); + private dispatcherRef; constructor(props: IProps) { super(props); this.state = { sublists: {}, - layouts: new Map<TagID, ListLayout>(), }; + + this.dispatcherRef = defaultDispatcher.register(this.onAction); } public componentDidUpdate(prevProps: Readonly<IProps>): void { @@ -172,25 +176,64 @@ export default class RoomList2 extends React.Component<IProps, IState> { public componentWillUnmount() { 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 = () => { const newLists = RoomListStore.instance.orderedLists; - console.log("new lists", newLists); - - const layoutMap = new Map<TagID, ListLayout>(); - for (const tagId of Object.keys(newLists)) { - layoutMap.set(tagId, new ListLayout(tagId)); + if (!window.mx_QuietRoomListLogging) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 + console.log("new lists", newLists); } - this.setState({sublists: newLists, layouts: layoutMap}); + this.setState({sublists: newLists}, () => { + this.props.onResize(); + }); }; private renderCommunityInvites(): React.ReactElement[] { // TODO: Put community invites in a more sensible place (not in the room list) return MatrixClientPeg.get().getGroups().filter(g => { if (g.myMembership !== 'invite') return false; - return !this.searchFilter || this.searchFilter.matches(g.name); + return !this.searchFilter || this.searchFilter.matches(g.name || ""); }).map(g => { const avatar = ( <GroupAvatar @@ -224,17 +267,15 @@ export default class RoomList2 extends React.Component<IProps, IState> { const components: React.ReactElement[] = []; 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) { // Populate custom tags if needed // TODO: Custom tags: https://github.com/vector-im/riot-web/issues/14091 } 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 } @@ -242,7 +283,6 @@ export default class RoomList2 extends React.Component<IProps, IState> { if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`); const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null; - const extraTiles = orderedTagId === DefaultTagID.Invite ? this.renderCommunityInvites() : null; components.push( <RoomSublist2 key={`sublist-${orderedTagId}`} @@ -253,10 +293,10 @@ export default class RoomList2 extends React.Component<IProps, IState> { label={_t(aesthetics.sectionLabel)} onAddRoom={onAddRoomFn} addRoomLabel={aesthetics.addRoomLabel} - isInvite={aesthetics.isInvite} - layout={this.state.layouts.get(orderedTagId)} isMinimized={this.props.isMinimized} + onResize={this.props.onResize} extraBadTilesThatShouldntExist={extraTiles} + isFiltered={!!this.searchFilter.search} /> ); } @@ -276,9 +316,6 @@ export default class RoomList2 extends React.Component<IProps, IState> { className="mx_RoomList2" role="tree" 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> )} </RovingTabIndexProvider> diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index 21e7c581f0..3623b8d48d 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -17,30 +17,39 @@ limitations under the License. */ import * as React from "react"; -import { createRef } from "react"; +import {createRef, UIEventHandler} from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import classNames from 'classnames'; -import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; +import { RovingAccessibleButton, RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import { _t } from "../../../languageHandler"; import AccessibleButton from "../../views/elements/AccessibleButton"; import RoomTile2 from "./RoomTile2"; -import { ResizableBox, ResizeCallbackData } from "react-resizable"; import { ListLayout } from "../../../stores/room-list/ListLayout"; -import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; -import StyledCheckbox from "../elements/StyledCheckbox"; -import StyledRadioButton from "../elements/StyledRadioButton"; +import { + ChevronFace, + ContextMenu, + ContextMenuButton, + StyledMenuItemCheckbox, + StyledMenuItemRadio, +} from "../../structures/ContextMenu"; import RoomListStore from "../../../stores/room-list/RoomListStore2"; import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import dis from "../../../dispatcher/dispatcher"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; import NotificationBadge from "./NotificationBadge"; import { ListNotificationState } from "../../../stores/notifications/ListNotificationState"; -import Tooltip from "../elements/Tooltip"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; 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: Rename 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/14367 /******************************************************************* * CAUTION * @@ -50,11 +59,15 @@ import { Key } from "../../../Keyboard"; * 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 +export const HEADER_HEIGHT = 32; // As defined by CSS const MAX_PADDING_HEIGHT = SHOW_N_BUTTON_HEIGHT + RESIZE_HANDLE_HEIGHT; +// HACK: We really shouldn't have to do this. +polyfillTouchEvent(); + interface IProps { forRooms: boolean; rooms?: Room[]; @@ -62,10 +75,10 @@ interface IProps { label: string; onAddRoom?: () => void; addRoomLabel: string; - isInvite: boolean; - layout: ListLayout; isMinimized: boolean; tagId: TagID; + onResize: () => void; + isFiltered: boolean; // TODO: Don't use this. It's for community invites, and community invites shouldn't be here. // You should feel bad if you use this. @@ -74,78 +87,178 @@ interface IProps { // 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">; interface IState { notificationState: ListNotificationState; contextMenuPosition: PartialDOMRect; isResizing: boolean; + isExpanded: boolean; // used for the for expand of the sublist when the room list is being filtered + height: number; } export default class RoomSublist2 extends React.Component<IProps, IState> { private headerButton = createRef<HTMLDivElement>(); private sublistRef = createRef<HTMLDivElement>(); + private dispatcherRef: string; + private layout: ListLayout; + private heightAtStart: number; constructor(props: IProps) { super(props); + this.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId); + this.heightAtStart = 0; + const height = this.calculateInitialHeight(); this.state = { - notificationState: new ListNotificationState(this.props.isInvite, this.props.tagId), + notificationState: RoomNotificationStateStore.instance.getListState(this.props.tagId), contextMenuPosition: null, isResizing: false, + isExpanded: this.props.isFiltered ? this.props.isFiltered : !this.layout.isCollapsed, + height, }; 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 { - 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 { - if (!this.props.layout) return 0; - const nVisible = Math.floor(this.props.layout.visibleTiles); + const nVisible = Math.ceil(this.layout.visibleTiles); return Math.min(nVisible, this.numTiles); } - public componentDidUpdate() { + public componentDidUpdate(prevProps: Readonly<IProps>) { 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() { this.state.notificationState.destroy(); + defaultDispatcher.unregister(this.dispatcherRef); } + private onAction = (payload: ActionPayload) => { + if (payload.action === "view_room" && payload.show_room_tile && this.props.rooms) { + // XXX: we have to do this a tick later because we have incorrect intermediate props during a room change + // where we lose the room we are changing from temporarily and then it comes back in an update right after. + setImmediate(() => { + const roomIndex = this.props.rooms.findIndex((r) => r.roomId === payload.room_id); + + if (!this.state.isExpanded && roomIndex > -1) { + this.toggleCollapsed(); + } + // extend the visible section to include the room if it is entirely invisible + if (roomIndex >= this.numVisibleTiles) { + this.layout.visibleTiles = this.layout.tilesWithPadding(roomIndex + 1, MAX_PADDING_HEIGHT); + this.forceUpdate(); // because the layout doesn't trigger a re-render + } + }); + } + }; + private onAddRoom = (e) => { e.stopPropagation(); if (this.props.onAddRoom) this.props.onAddRoom(); }; - private onResize = (e: React.MouseEvent, data: ResizeCallbackData) => { - const direction = e.movementY < 0 ? -1 : +1; - const tileDiff = this.props.layout.pixelsToTiles(Math.abs(e.movementY)) * direction; - this.props.layout.setVisibleTilesWithin(tileDiff, this.numTiles); - this.forceUpdate(); // because the layout doesn't trigger a re-render + private applyHeightChange(newHeight: number) { + const heightInTiles = Math.ceil(this.layout.pixelsToTiles(newHeight - this.padding)); + this.layout.visibleTiles = Math.min(this.numTiles, heightInTiles); + } + + 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 = () => { + this.heightAtStart = this.state.height; this.setState({isResizing: true}); }; - private onResizeStop = () => { - this.setState({isResizing: false}); + private onResizeStop = ( + 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 = () => { - this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT); - this.forceUpdate(); // because the layout doesn't trigger a re-render + const newHeight = this.layout.tilesToPixelsWithPadding(this.numTiles, this.padding); + this.applyHeightChange(newHeight); + this.setState({height: newHeight}, () => { + this.focusRoomTile(this.numTiles - 1); + }); }; private onShowLessClick = () => { - this.props.layout.visibleTiles = this.props.layout.defaultVisibleTiles; - this.forceUpdate(); // because the layout doesn't trigger a re-render + const newHeight = this.layout.tilesToPixelsWithPadding(this.layout.defaultVisibleTiles, this.padding); + 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.stopPropagation(); const target = ev.target as HTMLButtonElement; @@ -179,7 +292,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { }; 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 }; @@ -203,6 +316,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { dis.dispatch({ action: 'view_room', 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 sublist = possibleSticky.parentElement.parentElement; - if (possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky')) { + const list = sublist.parentElement.parentElement; + // the scrollTop is capped at the height of the header in LeftPanel2 + const isAtTop = list.scrollTop <= HEADER_HEIGHT; + const isSticky = possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky'); + if (isSticky && !isAtTop) { // is sticky - jump to list sublist.scrollIntoView({behavior: 'smooth'}); } else { @@ -226,23 +344,23 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { }; private toggleCollapsed = () => { - this.props.layout.isCollapsed = !this.props.layout.isCollapsed; - this.forceUpdate(); // because the layout doesn't trigger an update + this.layout.isCollapsed = this.state.isExpanded; + this.setState({isExpanded: !this.layout.isCollapsed}); + setImmediate(() => this.props.onResize()); // needs to happen when the DOM is updated }; private onHeaderKeyDown = (ev: React.KeyboardEvent) => { - const isCollapsed = this.props.layout && this.props.layout.isCollapsed; switch (ev.key) { case Key.ARROW_LEFT: ev.stopPropagation(); - if (!isCollapsed) { + if (this.state.isExpanded) { // On ARROW_LEFT collapse the room sublist if it isn't already this.toggleCollapsed(); } break; case Key.ARROW_RIGHT: { ev.stopPropagation(); - if (isCollapsed) { + if (!this.state.isExpanded) { // On ARROW_RIGHT expand the room sublist if it isn't already this.toggleCollapsed(); } else if (this.sublistRef.current) { @@ -271,17 +389,13 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { }; private renderVisibleTiles(): React.ReactElement[] { - if (this.props.layout && this.props.layout.isCollapsed) { + if (!this.state.isExpanded) { // don't waste time on rendering return []; } const tiles: React.ReactElement[] = []; - if (this.props.extraBadTilesThatShouldntExist) { - tiles.push(...this.props.extraBadTilesThatShouldntExist); - } - if (this.props.rooms) { const visibleRooms = this.props.rooms.slice(0, this.numVisibleTiles); for (const room of visibleRooms) { @@ -289,7 +403,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { <RoomTile2 room={room} key={`room-${room.roomId}`} - showMessagePreview={this.props.layout.showPreviews} + showMessagePreview={this.layout.showPreviews} isMinimized={this.props.isMinimized} 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 // 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 @@ -309,18 +427,45 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { } 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; if (this.state.contextMenuPosition) { const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic; 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 - chevronFace="none" + chevronFace={ChevronFace.None} left={this.state.contextMenuPosition.left} top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height} onFinished={this.onCloseMenu} @@ -328,41 +473,24 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { <div className="mx_RoomSublist2_contextMenu"> <div> <div className='mx_RoomSublist2_contextMenu_title'>{_t("Sort by")}</div> - <StyledRadioButton + <StyledMenuItemRadio + onClose={this.onCloseMenu} onChange={() => this.onTagSortChanged(SortAlgorithm.Recent)} checked={!isAlphabetical} name={`mx_${this.props.tagId}_sortBy`} > {_t("Activity")} - </StyledRadioButton> - <StyledRadioButton + </StyledMenuItemRadio> + <StyledMenuItemRadio + onClose={this.onCloseMenu} onChange={() => this.onTagSortChanged(SortAlgorithm.Alphabetic)} checked={isAlphabetical} name={`mx_${this.props.tagId}_sortBy`} > {_t("A-Z")} - </StyledRadioButton> - </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> + </StyledMenuItemRadio> </div> + {otherSections} </div> </ContextMenu> ); @@ -383,16 +511,22 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { private renderHeader(): React.ReactElement { return ( - <RovingTabIndexWrapper> + <RovingTabIndexWrapper inputRef={this.headerButton}> {({onFocus, isActive, ref}) => { 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 = ( <NotificationBadge forceCount={true} notification={this.state.notificationState} onClick={this.onBadgeClick} tabIndex={tabIndex} + aria-label={ariaLabel} /> ); @@ -412,7 +546,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { const collapseClasses = classNames({ 'mx_RoomSublist2_collapseBtn': true, - 'mx_RoomSublist2_collapseBtn_collapsed': this.props.layout && this.props.layout.isCollapsed, + 'mx_RoomSublist2_collapseBtn_collapsed': !this.state.isExpanded, }); const classes = classNames({ @@ -426,14 +560,13 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { </div> ); - // TODO: a11y (see old component): https://github.com/vector-im/riot-web/issues/14180 // Note: the addRoomButton conditionally gets moved around // the DOM depending on whether or not the list is minimized. // If we're minimized, we want it below the header so it // doesn't become sticky. // The same applies to the notification badge. 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"> <AccessibleButton onFocus={onFocus} @@ -441,6 +574,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { tabIndex={tabIndex} className="mx_RoomSublist2_headerText" role="treeitem" + aria-expanded={this.state.isExpanded} aria-level={1} onClick={this.onHeaderClick} 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 { // TODO: Error boundary: https://github.com/vector-im/riot-web/issues/14185 const visibleTiles = this.renderVisibleTiles(); - const classes = classNames({ 'mx_RoomSublist2': true, 'mx_RoomSublist2_hasMenuOpen': !!this.state.contextMenuPosition, @@ -474,21 +613,26 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { let content = null; if (visibleTiles.length > 0) { - const layout = this.props.layout; // to shorten calls + const layout = this.layout; // to shorten calls - const maxTilesFactored = layout.tilesWithResizerBoxFactor(this.numTiles); + const 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({ '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 // floats above the resize handle, if we have one present. If the user has all // tiles visible, it becomes 'show less'. let showNButton = null; - if (this.numTiles > visibleTiles.length) { - // we have a cutoff condition - add the button to show all - const numMissing = this.numTiles - visibleTiles.length; + + if (maxTilesPx > this.state.height) { + 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 = ( <span className='mx_RoomSublist2_showNButtonText'> {_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; showNButton = ( - <div onClick={this.onShowAllClick} className={showMoreBtnClasses}> + <RovingAccessibleButton onClick={this.onShowAllClick} className={showMoreBtnClasses}> <span className='mx_RoomSublist2_showMoreButtonChevron mx_RoomSublist2_showNButtonChevron'> {/* set by CSS masking */} </span> {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 let showLessText = ( <span className='mx_RoomSublist2_showNButtonText'> @@ -512,19 +656,29 @@ export default class RoomSublist2 extends React.Component<IProps, IState> { ); if (this.props.isMinimized) showLessText = null; showNButton = ( - <div onClick={this.onShowLessClick} className={showMoreBtnClasses}> + <RovingAccessibleButton onClick={this.onShowLessClick} className={showMoreBtnClasses}> <span className='mx_RoomSublist2_showLessButtonChevron mx_RoomSublist2_showNButtonChevron'> {/* set by CSS masking */} </span> {showLessText} - </div> + </RovingAccessibleButton> ); } // Figure out if we need a handle - let handles = ['s']; + const handles: Enable = { + bottom: true, // the only one we need, but the others must be explicitly false + bottomLeft: false, + bottomRight: false, + left: false, + right: false, + top: false, + topLeft: false, + topRight: false, + }; if (layout.visibleTiles >= this.numTiles && this.numTiles <= layout.minVisibleTiles) { - handles = []; // no handles, we're at a minimum + // we're at a minimum, don't have a bottom handle + handles.bottom = false; } // We have to account for padding so we can accommodate a 'show more' button and @@ -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 // only mathematically 7 possible). - // The padding is variable though, so figure out what we need padding for. - let padding = 0; - if (showNButton) padding += SHOW_N_BUTTON_HEIGHT; - 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); + const handleWrapperClasses = classNames({ + 'mx_RoomSublist2_resizerHandles': true, + 'mx_RoomSublist2_resizerHandles_showNButton': !!showNButton, + }); content = ( - <ResizableBox - width={-1} - height={tilesPx} - axis="y" - minConstraints={[-1, minTilesPx]} - maxConstraints={[-1, maxTilesPx]} - resizeHandles={handles} - onResize={this.onResize} - className="mx_RoomSublist2_resizeBox" - onResizeStart={this.onResizeStart} - onResizeStop={this.onResizeStop} - > - {visibleTiles} - {showNButton} - </ResizableBox> + <React.Fragment> + <Resizable + size={{height: this.state.height} as any} + minHeight={minTilesPx} + maxHeight={maxTilesPx} + onResizeStart={this.onResizeStart} + onResizeStop={this.onResizeStop} + onResize={this.onResize} + handleWrapperClass={handleWrapperClasses} + handleClasses={{bottom: "mx_RoomSublist2_resizerHandle"}} + className="mx_RoomSublist2_resizeBox" + enable={handles} + > + <div className="mx_RoomSublist2_tiles" onScroll={this.onScrollPrevent}> + {visibleTiles} + </div> + {showNButton} + </Resizable> + </React.Fragment> ); } diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx index 8a9712b5a4..ed188e996b 100644 --- a/src/components/views/rooms/RoomTile2.tsx +++ b/src/components/views/rooms/RoomTile2.tsx @@ -17,7 +17,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, {createRef} from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import classNames from "classnames"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; @@ -26,20 +26,37 @@ import dis from '../../../dispatcher/dispatcher'; import { Key } from "../../../Keyboard"; import ActiveRoomObserver from "../../../ActiveRoomObserver"; 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 { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; 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 { setRoomNotifsState } from "../../../RoomNotifs"; -import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState"; -import { INotificationState } from "../../../stores/notifications/INotificationState"; import NotificationBadge from "./NotificationBadge"; -import { NotificationColor } from "../../../stores/notifications/NotificationColor"; +import { Volume } from "../../../RoomNotifsTypes"; +import RoomListStore from "../../../stores/room-list/RoomListStore2"; +import RoomListActions from "../../../actions/RoomListActions"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import {ActionPayload} from "../../../dispatcher/payloads"; +import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; +import { NotificationState } from "../../../stores/notifications/NotificationState"; -// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 -// TODO: Rename 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/14367 /******************************************************************* * CAUTION * @@ -62,17 +79,19 @@ type PartialDOMRect = Pick<DOMRect, "left" | "bottom">; interface IState { hover: boolean; - notificationState: INotificationState; + notificationState: NotificationState; selected: boolean; notificationsMenuPosition: PartialDOMRect; generalMenuPosition: PartialDOMRect; } +const messagePreviewId = (roomId: string) => `mx_RoomTile2_messagePreview_${roomId}`; + const contextMenuBelow = (elementRect: PartialDOMRect) => { // align the context menu's icons with the icon which opened the context menu const left = elementRect.left + window.pageXOffset - 9; const top = elementRect.bottom + window.pageYOffset + 17; - const chevronFace = "none"; + const chevronFace = ChevronFace.None; return {left, top, chevronFace}; }; @@ -103,6 +122,8 @@ const NotifOption: React.FC<INotifOptionProps> = ({active, onClick, iconClassNam }; export default class RoomTile2 extends React.Component<IProps, IState> { + private dispatcherRef: string; + private roomTileRef = createRef<HTMLDivElement>(); // TODO: a11y: https://github.com/vector-im/riot-web/issues/14180 constructor(props: IProps) { @@ -110,25 +131,54 @@ export default class RoomTile2 extends React.Component<IProps, IState> { this.state = { hover: false, - notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag), + notificationState: RoomNotificationStateStore.instance.getRoomState(this.props.room, this.props.tag), selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId, notificationsMenuPosition: null, generalMenuPosition: null, }; ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate); + this.dispatcherRef = defaultDispatcher.register(this.onAction); } private get showContextMenu(): boolean { 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() { if (this.props.room) { ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate); } + defaultDispatcher.unregister(this.dispatcherRef); } + private onAction = (payload: ActionPayload) => { + if (payload.action === "view_room" && payload.room_id === this.props.room.roomId && payload.show_room_tile) { + setImmediate(() => { + this.scrollIntoView(); + }); + } + }; + + private scrollIntoView = () => { + if (!this.roomTileRef.current) return; + this.roomTileRef.current.scrollIntoView({ + block: "nearest", + behavior: "auto", + }); + }; + private onTileMouseEnter = () => { this.setState({hover: true}); }; @@ -142,7 +192,6 @@ export default class RoomTile2 extends React.Component<IProps, IState> { ev.stopPropagation(); dis.dispatch({ action: 'view_room', - // TODO: Support show_room_tile in new room list: https://github.com/vector-im/riot-web/issues/14233 show_room_tile: true, // make sure the room is visible in the list room_id: this.props.room.roomId, clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)), @@ -153,7 +202,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> { this.setState({selected: isActive}); }; - private onNotificationsMenuOpenClick = (ev: InputEvent) => { + private onNotificationsMenuOpenClick = (ev: React.MouseEvent) => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; @@ -164,7 +213,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> { this.setState({notificationsMenuPosition: null}); }; - private onGeneralMenuOpenClick = (ev: InputEvent) => { + private onGeneralMenuOpenClick = (ev: React.MouseEvent) => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; @@ -193,8 +242,27 @@ export default class RoomTile2 extends React.Component<IProps, IState> { ev.preventDefault(); ev.stopPropagation(); - // TODO: Support tagging: https://github.com/vector-im/riot-web/issues/14211 - // TODO: XOR favourites and low priority: https://github.com/vector-im/riot-web/issues/14210 + if (tagId === DefaultTagID.Favourite) { + const roomTags = RoomListStore.instance.getTagsForRoom(this.props.room); + const isFavourite = roomTags.includes(DefaultTagID.Favourite); + const removeTag = isFavourite ? DefaultTagID.Favourite : DefaultTagID.LowPriority; + const addTag = isFavourite ? null : DefaultTagID.Favourite; + dis.dispatch(RoomListActions.tagRoom( + MatrixClientPeg.get(), + this.props.room, + removeTag, + addTag, + undefined, + 0 + )); + } else { + console.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) => { @@ -219,11 +287,13 @@ export default class RoomTile2 extends React.Component<IProps, IState> { 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.stopPropagation(); if (MatrixClientPeg.get().isGuest()) return; + // get key before we go async and React discards the nativeEvent + const key = (ev as React.KeyboardEvent).key; try { // TODO add local echo - https://github.com/vector-im/riot-web/issues/14280 await setRoomNotifsState(this.props.room.roomId, newState); @@ -233,7 +303,10 @@ export default class RoomTile2 extends React.Component<IProps, IState> { 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); @@ -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 + const roomTags = RoomListStore.instance.getTagsForRoom(this.props.room); + + const isFavorite = roomTags.includes(DefaultTagID.Favourite); + const favouriteIconClassName = isFavorite ? "mx_RoomTile2_iconFavorite" : "mx_RoomTile2_iconStar"; + const favouriteLabelClassName = isFavorite ? "mx_RoomTile2_contextMenu_activeRow" : ""; + const favouriteLabel = isFavorite ? _t("Favourited") : _t("Favourite"); + let contextMenu = null; if (this.state.generalMenuPosition) { contextMenu = ( <ContextMenu {...contextMenuBelow(this.state.generalMenuPosition)} onFinished={this.onCloseGeneralMenu}> <div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu"> <div className="mx_IconizedContextMenu_optionList"> - <AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}> - <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" /> - <span className="mx_IconizedContextMenu_label">{_t("Favourite")}</span> - </AccessibleButton> - <AccessibleButton onClick={this.onOpenRoomSettings}> + <MenuItemCheckbox + className={favouriteLabelClassName} + onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)} + active={isFavorite} + 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_label">{_t("Settings")}</span> - </AccessibleButton> + </MenuItem> </div> <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_label">{_t("Leave Room")}</span> - </AccessibleButton> + </MenuItem> </div> </div> </ContextMenu> @@ -357,7 +442,6 @@ export default class RoomTile2 extends React.Component<IProps, IState> { public render(): React.ReactElement { // 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({ 'mx_RoomTile2': true, @@ -375,8 +459,9 @@ export default class RoomTile2 extends React.Component<IProps, IState> { let badge: React.ReactNode; if (!this.props.isMinimized) { + // aria-hidden because we summarise the unread count/highlight status in a manual aria-label below badge = ( - <div className="mx_RoomTile2_badgeContainer"> + <div className="mx_RoomTile2_badgeContainer" aria-hidden="true"> <NotificationBadge notification={this.state.notificationState} 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 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. const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag); // Only show the preview if there is one to show. if (text) { messagePreview = ( - <div className="mx_RoomTile2_messagePreview"> + <div className="mx_RoomTile2_messagePreview" id={messagePreviewId(this.props.room.roomId)}> {text} </div> ); @@ -409,7 +494,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> { const nameClasses = classNames({ "mx_RoomTile2_name": true, "mx_RoomTile2_nameWithPreview": !!messagePreview, - "mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.color >= NotificationColor.Bold, + "mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.isUnread, }); let nameContainer = ( @@ -422,9 +507,30 @@ export default class RoomTile2 extends React.Component<IProps, IState> { ); 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 ( <React.Fragment> - <RovingTabIndexWrapper> + <RovingTabIndexWrapper inputRef={this.roomTileRef}> {({onFocus, isActive, ref}) => <AccessibleButton onFocus={onFocus} @@ -434,14 +540,17 @@ export default class RoomTile2 extends React.Component<IProps, IState> { onMouseEnter={this.onTileMouseEnter} onMouseLeave={this.onTileMouseLeave} onClick={this.onTileClick} - role="treeitem" onContextMenu={this.onContextMenu} + role="treeitem" + aria-label={ariaLabel} + aria-selected={this.state.selected} + aria-describedby={ariaDescribedBy} > {roomAvatar} {nameContainer} {badge} - {this.renderNotificationsMenu(isActive)} {this.renderGeneralMenu()} + {this.renderNotificationsMenu(isActive)} </AccessibleButton> } </RovingTabIndexWrapper> diff --git a/src/components/views/rooms/TemporaryTile.tsx b/src/components/views/rooms/TemporaryTile.tsx index b6c165ecda..a3ee7eb5bd 100644 --- a/src/components/views/rooms/TemporaryTile.tsx +++ b/src/components/views/rooms/TemporaryTile.tsx @@ -18,16 +18,15 @@ import React from "react"; import classNames from "classnames"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import AccessibleButton from "../../views/elements/AccessibleButton"; -import { INotificationState } from "../../../stores/notifications/INotificationState"; import NotificationBadge from "./NotificationBadge"; -import { NotificationColor } from "../../../stores/notifications/NotificationColor"; +import { NotificationState } from "../../../stores/notifications/NotificationState"; interface IProps { isMinimized: boolean; isSelected: boolean; displayName: string; avatar: React.ReactElement; - notificationState: INotificationState; + notificationState: NotificationState; onClick: () => void; } @@ -74,7 +73,7 @@ export default class TemporaryTile extends React.Component<IProps, IState> { const nameClasses = classNames({ "mx_RoomTile2_name": true, - "mx_RoomTile2_nameHasUnreadEvents": this.props.notificationState.color >= NotificationColor.Bold, + "mx_RoomTile2_nameHasUnreadEvents": this.props.notificationState.isUnread, }); let nameContainer = ( diff --git a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.js b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.js index f57d5d3798..2edf3021dc 100644 --- a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.js +++ b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.js @@ -22,6 +22,10 @@ import * as sdk from "../../../../.."; import AccessibleButton from "../../../elements/AccessibleButton"; import Modal from "../../../../../Modal"; import dis from "../../../../../dispatcher/dispatcher"; +import RoomListStore from "../../../../../stores/room-list/RoomListStore2"; +import RoomListActions from "../../../../../actions/RoomListActions"; +import { DefaultTagID } from '../../../../../stores/room-list/models'; +import LabelledToggleSwitch from '../../../elements/LabelledToggleSwitch'; export default class AdvancedRoomSettingsTab extends React.Component { static propTypes = { @@ -29,12 +33,16 @@ export default class AdvancedRoomSettingsTab extends React.Component { closeSettingsFn: PropTypes.func.isRequired, }; - constructor() { - super(); + constructor(props) { + super(props); + + const room = MatrixClientPeg.get().getRoom(props.roomId); + const roomTags = RoomListStore.instance.getTagsForRoom(room); this.state = { // This is eventually set to the value of room.getRecommendedVersion() upgradeRecommendation: null, + isLowPriorityRoom: roomTags.includes(DefaultTagID.LowPriority), }; } @@ -86,6 +94,25 @@ export default class AdvancedRoomSettingsTab extends React.Component { this.props.closeSettingsFn(); }; + _onToggleLowPriorityTag = (e) => { + this.setState({ + isLowPriorityRoom: !this.state.isLowPriorityRoom, + }); + + const removeTag = this.state.isLowPriorityRoom ? DefaultTagID.LowPriority : DefaultTagID.Favourite; + const addTag = this.state.isLowPriorityRoom ? null : DefaultTagID.LowPriority; + const client = MatrixClientPeg.get(); + + dis.dispatch(RoomListActions.tagRoom( + client, + client.getRoom(this.props.roomId), + removeTag, + addTag, + undefined, + 0, + )); + } + render() { const client = MatrixClientPeg.get(); const room = client.getRoom(this.props.roomId); @@ -156,6 +183,17 @@ export default class AdvancedRoomSettingsTab extends React.Component { {_t("Open Devtools")} </AccessibleButton> </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> ); } diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx index 325d5cede6..6826eed7b7 100644 --- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx @@ -402,6 +402,12 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I useCheckbox={true} disabled={this.state.useIRCLayout} /> + <SettingsFlag + name="useIRCLayout" + level={SettingLevel.DEVICE} + useCheckbox={true} + onChange={(checked) => this.setState({useIRCLayout: checked})} + /> <SettingsFlag name="useSystemFont" level={SettingLevel.DEVICE} @@ -440,7 +446,6 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I </div> {this.renderThemeSection()} {this.renderFontSection()} - {SettingsStore.isFeatureEnabled("feature_irc_ui") ? this.renderLayoutSection() : null} {this.renderAdvancedSection()} </div> ); diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js index 40b622cf37..abe6b48712 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js @@ -32,12 +32,12 @@ export default class PreferencesUserSettingsTab extends React.Component { '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 = [ '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 = () => { if (RoomListStoreTempProxy.isUsingNewStore()) { return PreferencesUserSettingsTab.ROOM_LIST_2_SETTINGS; diff --git a/src/components/views/toasts/GenericToast.tsx b/src/components/views/toasts/GenericToast.tsx index 9f8885ba47..6cd881b9eb 100644 --- a/src/components/views/toasts/GenericToast.tsx +++ b/src/components/views/toasts/GenericToast.tsx @@ -14,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {ReactChild} from "react"; +import React, {ReactNode} from "react"; import FormButton from "../elements/FormButton"; import {XOR} from "../../../@types/common"; export interface IProps { - description: ReactChild; + description: ReactNode; acceptLabel: string; onAccept(); diff --git a/src/components/views/voip/CallContainer.tsx b/src/components/views/voip/CallContainer.tsx new file mode 100644 index 0000000000..0e901fac7d --- /dev/null +++ b/src/components/views/voip/CallContainer.tsx @@ -0,0 +1,37 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import IncomingCallBox2 from './IncomingCallBox2'; +import CallPreview from './CallPreview2'; +import * as VectorConferenceHandler from '../../../VectorConferenceHandler'; + +interface IProps { + +} + +interface IState { + +} + +export default class CallContainer extends React.PureComponent<IProps, IState> { + public render() { + return <div className="mx_CallContainer"> + <IncomingCallBox2 /> + <CallPreview ConferenceHandler={VectorConferenceHandler} /> + </div>; + } +} \ No newline at end of file diff --git a/src/components/views/voip/CallPreview2.tsx b/src/components/views/voip/CallPreview2.tsx new file mode 100644 index 0000000000..1f2caf5ef8 --- /dev/null +++ b/src/components/views/voip/CallPreview2.tsx @@ -0,0 +1,129 @@ +/* +Copyright 2017, 2018 New Vector Ltd +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 + +import React from 'react'; + +import CallView from "./CallView2"; +import RoomViewStore from '../../../stores/RoomViewStore'; +import CallHandler from '../../../CallHandler'; +import dis from '../../../dispatcher/dispatcher'; +import { ActionPayload } from '../../../dispatcher/payloads'; +import PersistentApp from "../elements/PersistentApp"; +import SettingsStore from "../../../settings/SettingsStore"; + +interface IProps { + // A Conference Handler implementation + // Must have a function signature: + // getConferenceCallForRoom(roomId: string): MatrixCall + ConferenceHandler: any; +} + +interface IState { + roomId: string; + activeCall: any; + newRoomListActive: boolean; +} + +export default class CallPreview extends React.Component<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; + } +} + diff --git a/src/components/views/voip/CallView2.tsx b/src/components/views/voip/CallView2.tsx new file mode 100644 index 0000000000..c80d82d395 --- /dev/null +++ b/src/components/views/voip/CallView2.tsx @@ -0,0 +1,200 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 + +import React, {createRef} from 'react'; +import Room from 'matrix-js-sdk/src/models/room'; +import dis from '../../../dispatcher/dispatcher'; +import CallHandler from '../../../CallHandler'; +import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { _t } from '../../../languageHandler'; +import AccessibleButton from '../elements/AccessibleButton'; +import VideoView from "./VideoView"; +import RoomAvatar from "../avatars/RoomAvatar"; +import PulsedAvatar from '../avatars/PulsedAvatar'; + +interface IProps { + // js-sdk room object. If set, we will only show calls for the given + // room; if not, we will show any active call. + room?: Room; + + // A Conference Handler implementation + // Must have a function signature: + // getConferenceCallForRoom(roomId: string): MatrixCall + ConferenceHandler?: any; + + // maxHeight style attribute for the video panel + maxVideoHeight?: number; + + // a callback which is called when the user clicks on the video div + onClick?: React.MouseEventHandler; + + // a callback which is called when the content in the callview changes + // in a way that is likely to cause a resize. + onResize?: any; + + // classname applied to view, + className?: string; + + // Whether to show the hang up icon:W + showHangup?: boolean; +} + +interface IState { + call: any; +} + +export default class CallView extends React.Component<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>; + } +} + diff --git a/src/components/views/voip/IncomingCallBox2.tsx b/src/components/views/voip/IncomingCallBox2.tsx new file mode 100644 index 0000000000..6dfcb4bcee --- /dev/null +++ b/src/components/views/voip/IncomingCallBox2.tsx @@ -0,0 +1,141 @@ +/* +Copyright 2015, 2016 OpenMarket Ltd +Copyright 2018 New Vector Ltd +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 + +import React from 'react'; +import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import dis from '../../../dispatcher/dispatcher'; +import { _t } from '../../../languageHandler'; +import { ActionPayload } from '../../../dispatcher/payloads'; +import CallHandler from '../../../CallHandler'; +import PulsedAvatar from '../avatars/PulsedAvatar'; +import RoomAvatar from '../avatars/RoomAvatar'; +import FormButton from '../elements/FormButton'; + +interface IProps { +} + +interface IState { + incomingCall: any; +} + +export default class IncomingCallBox2 extends React.Component<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>; + } +} + diff --git a/src/contexts/MatrixClientContext.js b/src/contexts/MatrixClientContext.ts similarity index 85% rename from src/contexts/MatrixClientContext.js rename to src/contexts/MatrixClientContext.ts index 54a23ca132..7e8a92064d 100644 --- a/src/contexts/MatrixClientContext.js +++ b/src/contexts/MatrixClientContext.ts @@ -15,7 +15,8 @@ limitations under the License. */ import { createContext } from "react"; +import { MatrixClient } from "matrix-js-sdk/src/client"; -const MatrixClientContext = createContext(undefined); +const MatrixClientContext = createContext<MatrixClient>(undefined); MatrixClientContext.displayName = "MatrixClientContext"; export default MatrixClientContext; diff --git a/src/createRoom.js b/src/createRoom.ts similarity index 81% rename from src/createRoom.js rename to src/createRoom.ts index affdf196a7..c436196c27 100644 --- a/src/createRoom.js +++ b/src/createRoom.ts @@ -15,6 +15,9 @@ See the License for the specific language governing permissions and 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 Modal from './Modal'; import * as sdk from './index'; @@ -26,6 +29,56 @@ import {getAddressType} from "./UserAddress"; 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. * @@ -40,11 +93,12 @@ const E2EE_WK_KEY = "im.vector.riot.e2ee"; * Default: False * @param {bool=} opts.inlineErrors True to raise errors off the promise instead of resolving to null. * 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 * action was aborted or failed. */ -export default function createRoom(opts) { +export default function createRoom(opts: IOpts): Promise<string | null> { opts = opts || {}; if (opts.spinner === undefined) opts.spinner = true; if (opts.guestAccess === undefined) opts.guestAccess = true; @@ -59,12 +113,12 @@ export default function createRoom(opts) { 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 const createOpts = opts.createOpts || {}; createOpts.preset = createOpts.preset || defaultPreset; - createOpts.visibility = createOpts.visibility || 'private'; + createOpts.visibility = createOpts.visibility || Visibility.Private; if (opts.dmUserId && createOpts.invite === undefined) { switch (getAddressType(opts.dmUserId)) { 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 rooms = roomIds.map(id => client.getRoom(id)); 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 * 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; let handler; 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 * can encrypt to. */ -export async function canEncryptToAllUsers(client, userIds) { +export async function canEncryptToAllUsers(client: MatrixClient, userIds: string[]) { const usersDeviceMap = await client.downloadKeys(userIds); // { "@user:host": { "DEVICE": {...}, ... }, ... } 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); let roomId; if (existingDMRoom) { diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 379a0a4451..9be674b59e 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -79,4 +79,9 @@ export enum Action { * Sets a system font. Should be used with UpdateSystemFontPayload */ UpdateSystemFont = "update_system_font", + + /** + * Changes room based on room list order and payload parameters. Should be used with ViewRoomDeltaPayload. + */ + ViewRoomDelta = "view_room_delta", } diff --git a/src/dispatcher/payloads/ViewRoomDeltaPayload.ts b/src/dispatcher/payloads/ViewRoomDeltaPayload.ts new file mode 100644 index 0000000000..de33a88b2e --- /dev/null +++ b/src/dispatcher/payloads/ViewRoomDeltaPayload.ts @@ -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; +} diff --git a/src/groups.js b/src/groups.js index 860cf71fff..e73af15c79 100644 --- a/src/groups.js +++ b/src/groups.js @@ -15,7 +15,8 @@ limitations under the License. */ import PropTypes from 'prop-types'; -import { _t } from './languageHandler.js'; + +import { _t } from './languageHandler'; export const GroupMemberType = PropTypes.shape({ userId: PropTypes.string.isRequired, diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 5a79b01003..4b1dfe2b8e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -488,7 +488,6 @@ "Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)", "Use the improved room list (will refresh to apply changes)": "Use the improved room list (will refresh to apply changes)", "Support adding custom themes": "Support adding custom themes", - "Enable IRC layout option in the appearance tab": "Enable IRC layout option in the appearance tab", "Show info about bridges in room settings": "Show info about bridges in room settings", "Font size": "Font size", "Use custom size": "Use custom size", @@ -538,7 +537,7 @@ "How fast should messages be downloaded.": "How fast should messages be downloaded.", "Manually verify all remote sessions": "Manually verify all remote sessions", "IRC display name width": "IRC display name width", - "Use IRC layout": "Use IRC layout", + "Enable experimental, compact IRC style layout": "Enable experimental, compact IRC style layout", "Collecting app version information": "Collecting app version information", "Collecting logs": "Collecting logs", "Uploading report": "Uploading report", @@ -557,12 +556,17 @@ "My Ban List": "My Ban List", "This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!", "Active call (%(roomName)s)": "Active call (%(roomName)s)", + "Active call": "Active call", "unknown caller": "unknown caller", "Incoming voice call from %(name)s": "Incoming voice call from %(name)s", "Incoming video call from %(name)s": "Incoming video call from %(name)s", "Incoming call from %(name)s": "Incoming call from %(name)s", "Decline": "Decline", "Accept": "Accept", + "Unknown caller": "Unknown caller", + "Incoming voice call": "Incoming voice call", + "Incoming video call": "Incoming video call", + "Incoming call": "Incoming call", "The other party cancelled the verification.": "The other party cancelled the verification.", "Verified!": "Verified!", "You've successfully verified this user.": "You've successfully verified this user.", @@ -965,6 +969,8 @@ "Room version:": "Room version:", "Developer options": "Developer options", "Open Devtools": "Open Devtools", + "Make this room low priority": "Make this room low priority", + "Low priority rooms show up at the bottom of your room list in a dedicated section at the bottom of your room list": "Low priority rooms show up at the bottom of your room list in a dedicated section at the bottom of your room list", "This room is bridging messages to the following platforms. <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>", "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>", "Not now": "Not now", "Don't ask me again": "Don't ask me again", - "Sort by": "Sort by", - "Activity": "Activity", - "A-Z": "A-Z", "Unread rooms": "Unread rooms", "Always show first": "Always show first", "Show": "Show", "Message preview": "Message preview", + "Sort by": "Sort by", + "Activity": "Activity", + "A-Z": "A-Z", "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", "Show %(count)s more|other": "Show %(count)s more", "Show %(count)s more|one": "Show %(count)s more", @@ -1221,6 +1229,7 @@ "All messages": "All messages", "Mentions & Keywords": "Mentions & Keywords", "Notification options": "Notification options", + "Favourited": "Favourited", "Favourite": "Favourite", "Leave Room": "Leave Room", "Room options": "Room options", @@ -2088,6 +2097,8 @@ "Find a room…": "Find a room…", "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>.", + "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>.", "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.", @@ -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.", "Connectivity to the server has been lost.": "Connectivity to the server has been lost.", "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.", - "Active call": "Active call", "There's no one else here! Would you like to <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 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", @@ -2115,7 +2123,6 @@ "Click to mute video": "Click to mute video", "Click to unmute audio": "Click to unmute 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 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", @@ -2128,9 +2135,8 @@ "Switch theme": "Switch theme", "Security & privacy": "Security & privacy", "All settings": "All settings", - "Archived rooms": "Archived rooms", "Feedback": "Feedback", - "Account settings": "Account settings", + "User menu": "User menu", "Could not load user profile": "Could not load user profile", "Verify this login": "Verify this login", "Session verified": "Session verified", diff --git a/src/languageHandler.js b/src/languageHandler.tsx similarity index 87% rename from src/languageHandler.js rename to src/languageHandler.tsx index 79a172015a..91d90d4e6c 100644 --- a/src/languageHandler.js +++ b/src/languageHandler.tsx @@ -1,7 +1,7 @@ /* Copyright 2017 MTRNord and Cooperative EITA 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> Licensed under the Apache License, Version 2.0 (the "License"); @@ -20,10 +20,11 @@ limitations under the License. import request from 'browser-request'; import counterpart from 'counterpart'; import React from 'react'; + import SettingsStore, {SettingLevel} from "./settings/SettingsStore"; 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"; const i18nFolder = 'i18n/'; @@ -37,27 +38,31 @@ counterpart.setSeparator('|'); // Fall back to English counterpart.setFallbackLocale('en'); +interface ITranslatableError extends Error { + translatedMessage: string; +} + /** * Helper function to create an error which has an English message * with a translatedMessage property for use by the consumer. * @param {string} message Message to translate. * @returns {Error} The constructed error. */ -export function newTranslatableError(message) { - const error = new Error(message); +export function newTranslatableError(message: string) { + const error = new Error(message) as ITranslatableError; error.translatedMessage = _t(message); return error; } // 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 -export function _td(s) { +export function _td(s: string): string { return s; } // Wrapper for counterpart's translation function so that it handles nulls and undefineds properly // 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 // 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 @@ -89,6 +94,13 @@ function safeCounterpartTranslate(text, options) { 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 * @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 */ -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 // 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 @@ -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 */ -export function substitute(text, variables, tags) { - let result = text; +export function substitute(text: string, variables?: IVariables): string; +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) { - const regexpMapping = {}; + const regexpMapping: IVariables = {}; for (const variable in variables) { regexpMapping[`%\\(${variable}\\)s`] = variables[variable]; } - result = replaceByRegexes(result, regexpMapping); + result = replaceByRegexes(result as string, regexpMapping); } if (tags !== undefined) { - const regexpMapping = {}; + const regexpMapping: Tags = {}; for (const tag in tags) { regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag]; } - result = replaceByRegexes(result, regexpMapping); + result = replaceByRegexes(result as string, regexpMapping); } 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 */ -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). // This will then be converted to a string or a <span> at the end 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. // 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 - for (const outputIndex in output) { + for (let outputIndex = 0; outputIndex < output.length; outputIndex++) { const inputText = output[outputIndex]; if (typeof inputText !== 'string') { // We might have inserted objects earlier, don't try to replace them continue; @@ -216,7 +234,7 @@ export function replaceByRegexes(text, mapping) { let replaced; // If substitution is a function, call it if (mapping[regexpString] instanceof Function) { - replaced = mapping[regexpString].apply(null, capturedGroups); + replaced = (mapping as Tags)[regexpString].apply(null, capturedGroups); } else { replaced = mapping[regexpString]; } @@ -277,11 +295,11 @@ export function replaceByRegexes(text, mapping) { // Allow overriding the text displayed when no translation exists // Currently only used in unit tests to avoid having to load // the translations in riot-web -export function setMissingEntryGenerator(f) { +export function setMissingEntryGenerator(f: (value: string) => void) { counterpart.setMissingEntryGenerator(f); } -export function setLanguage(preferredLangs) { +export function setLanguage(preferredLangs: string | string[]) { if (!Array.isArray(preferredLangs)) { preferredLangs = [preferredLangs]; } @@ -358,8 +376,8 @@ export function getLanguageFromBrowser() { * @param {string} language The input language string * @return {string[]} List of normalised languages */ -export function getNormalizedLanguageKeys(language) { - const languageKeys = []; +export function getNormalizedLanguageKeys(language: string) { + const languageKeys: string[] = []; const normalizedLanguage = normalizeLanguageKey(language); const languageParts = normalizedLanguage.split('-'); 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 * @returns {string} The normalized language string */ -export function normalizeLanguageKey(language) { +export function normalizeLanguageKey(language: string) { return language.toLowerCase().replace("_", "-"); } @@ -396,7 +414,7 @@ export function getCurrentLanguage() { * @param {string[]} langs List of language codes to pick from * @returns {string} The most appropriate language code from langs */ -export function pickBestLanguage(langs) { +export function pickBestLanguage(langs: string[]): string { const currentLang = getCurrentLanguage(); const normalisedLangs = langs.map(normalizeLanguageKey); @@ -408,13 +426,13 @@ export function pickBestLanguage(langs) { { // 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]; } { // 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]; } @@ -422,7 +440,7 @@ export function pickBestLanguage(langs) { return langs[0]; } -function getLangsJson() { +function getLangsJson(): Promise<object> { return new Promise(async (resolve, reject) => { let url; 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 = {}; for (const key of Object.keys(inTrs)) { @@ -463,7 +481,7 @@ function weblateToCounterpart(inTrs) { return outTrs; } -function getLanguage(langPath) { +function getLanguage(langPath: string): object { return new Promise((resolve, reject) => { request( { method: "GET", url: langPath }, diff --git a/src/settings/Settings.js b/src/settings/Settings.js index fd85f6970d..3b1218c0d3 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -141,7 +141,8 @@ export const SETTINGS = { default: false, }, "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)"), supportedLevels: LEVELS_FEATURE, default: true, @@ -153,12 +154,6 @@ export const SETTINGS = { supportedLevels: LEVELS_FEATURE, default: false, }, - "feature_irc_ui": { - supportedLevels: LEVELS_ACCOUNT_SETTINGS, - displayName: _td('Enable IRC layout option in the appearance tab'), - default: false, - isFeature: true, - }, "mjolnirRooms": { supportedLevels: ['account'], default: [], @@ -472,13 +467,13 @@ export const SETTINGS = { 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": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td("Order rooms by name"), 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": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td("Show rooms with unread notifications first"), @@ -568,7 +563,7 @@ export const SETTINGS = { }, "useIRCLayout": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, - displayName: _td("Use IRC layout"), + displayName: _td("Enable experimental, compact IRC style layout"), default: false, }, }; diff --git a/src/settings/handlers/RoomSettingsHandler.js b/src/settings/handlers/RoomSettingsHandler.js index d8e775742c..00dd5b8bec 100644 --- a/src/settings/handlers/RoomSettingsHandler.js +++ b/src/settings/handlers/RoomSettingsHandler.js @@ -43,11 +43,14 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl const roomId = event.getRoomId(); const room = this.client.getRoom(roomId); - // Note: the tests often fire setting updates that don't have rooms in the store, so - // we fail softly here. We shouldn't assume that the state being fired is current - // state, but we also don't need to explode just because we didn't find a room. - if (!room) console.warn(`Unknown room caused setting update: ${roomId}`); - if (room && state !== room.currentState) return; // ignore state updates which are not current + // Note: in tests and during the encryption setup on initial load we might not have + // rooms in the store, so we just quietly ignore the problem. If we log it then we'll + // just end up spamming the logs a few thousand times. It is perfectly fine for us + // to ignore the problem as the app will not have loaded enough to care yet. + if (!room) return; + + // ignore state updates which are not current + if (room && state !== room.currentState) return; if (event.getType() === "org.matrix.room.preview_urls") { let val = event.getContent()['disable']; diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts index c78f15c3b4..48ef75cb59 100644 --- a/src/stores/BreadcrumbsStore.ts +++ b/src/stores/BreadcrumbsStore.ts @@ -57,7 +57,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> { protected async onAction(payload: ActionPayload) { 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 (payload.action === 'setting_updated') { @@ -80,7 +80,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> { } 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; await this.updateRooms(); @@ -91,7 +91,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> { } 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; this.matrixClient.removeListener("Room.myMembership", this.onMyMembership); @@ -125,6 +125,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> { } private async appendRoom(room: Room) { + let updated = false; const rooms = (this.state.rooms || []).slice(); // cheap clone // If the room is upgraded, use that room instead. We'll also splice out @@ -136,30 +137,42 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> { // Take out any room that isn't the most recent room for (let i = 0; i < history.length - 1; i++) { const idx = rooms.findIndex(r => r.roomId === history[i].roomId); - if (idx !== -1) rooms.splice(idx, 1); + if (idx !== -1) { + rooms.splice(idx, 1); + updated = true; + } } } // Remove the existing room, if it is present const existingIdx = rooms.findIndex(r => r.roomId === room.roomId); - if (existingIdx !== -1) { - rooms.splice(existingIdx, 1); - } - // Splice the room to the start of the list - rooms.splice(0, 0, room); + // If we're focusing on the first room no-op + if (existingIdx !== 0) { + if (existingIdx !== -1) { + rooms.splice(existingIdx, 1); + } + + // Splice the room to the start of the list + rooms.splice(0, 0, room); + updated = true; + } if (rooms.length > MAX_ROOMS) { // This looks weird, but it's saying to start at the MAX_ROOMS point in the // list and delete everything after it. rooms.splice(MAX_ROOMS, rooms.length - MAX_ROOMS); + updated = true; } - // Update the breadcrumbs - await this.updateState({rooms}); - const roomIds = rooms.map(r => r.roomId); - if (roomIds.length > 0) { - await SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds); + + if (updated) { + // Update the breadcrumbs + await this.updateState({rooms}); + const roomIds = rooms.map(r => r.roomId); + if (roomIds.length > 0) { + await SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds); + } } } diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js index c19b2f8bc2..1861085a27 100644 --- a/src/stores/RoomListStore.js +++ b/src/stores/RoomListStore.js @@ -99,7 +99,7 @@ class RoomListStore extends Store { } _checkDisabled() { - this.disabled = SettingsStore.isFeatureEnabled("feature_new_room_list"); + this.disabled = SettingsStore.getValue("feature_new_room_list"); if (this.disabled) { console.warn("👋 legacy room list store has been disabled"); } diff --git a/src/stores/notifications/ListNotificationState.ts b/src/stores/notifications/ListNotificationState.ts index 5773693b47..6c67dbdd08 100644 --- a/src/stores/notifications/ListNotificationState.ts +++ b/src/stores/notifications/ListNotificationState.ts @@ -14,23 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventEmitter } from "events"; -import { INotificationState, NOTIFICATION_STATE_UPDATE } from "./INotificationState"; import { NotificationColor } from "./NotificationColor"; -import { IDestroyable } from "../../utils/IDestroyable"; import { TagID } from "../room-list/models"; import { Room } from "matrix-js-sdk/src/models/room"; import { arrayDiff } from "../../utils/arrays"; import { RoomNotificationState } from "./RoomNotificationState"; -import { TagSpecificNotificationState } from "./TagSpecificNotificationState"; +import { NOTIFICATION_STATE_UPDATE, NotificationState } from "./NotificationState"; -export class ListNotificationState extends EventEmitter implements IDestroyable, INotificationState { - private _count: number; - private _color: NotificationColor; +export type FetchRoomFn = (room: Room) => RoomNotificationState; + +export class ListNotificationState extends NotificationState { private rooms: Room[] = []; private states: { [roomId: string]: RoomNotificationState } = {}; - constructor(private byTileCount = false, private tagId: TagID) { + constructor(private byTileCount = false, private tagId: TagID, private getRoomFn: FetchRoomFn) { super(); } @@ -38,14 +35,6 @@ export class ListNotificationState extends EventEmitter implements IDestroyable, return null; // This notification state doesn't support symbols } - public get count(): number { - return this._count; - } - - public get color(): NotificationColor { - return this._color; - } - public setRooms(rooms: Room[]) { // If we're only concerned about the tile count, don't bother setting up listeners. if (this.byTileCount) { @@ -62,16 +51,10 @@ export class ListNotificationState extends EventEmitter implements IDestroyable, if (!state) continue; // We likely just didn't have a badge (race condition) delete this.states[oldRoom.roomId]; state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); - state.destroy(); } for (const newRoom of diff.added) { - const state = new TagSpecificNotificationState(newRoom, this.tagId); + const state = this.getRoomFn(newRoom); state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); - if (this.states[newRoom.roomId]) { - // "Should never happen" disclaimer. - console.warn("Overwriting notification state for room:", newRoom.roomId); - this.states[newRoom.roomId].destroy(); - } this.states[newRoom.roomId] = state; } @@ -85,8 +68,9 @@ export class ListNotificationState extends EventEmitter implements IDestroyable, } public destroy() { + super.destroy(); for (const state of Object.values(this.states)) { - state.destroy(); + state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate); } this.states = {}; } @@ -96,7 +80,7 @@ export class ListNotificationState extends EventEmitter implements IDestroyable, }; private calculateTotalState() { - const before = {count: this.count, symbol: this.symbol, color: this.color}; + const snapshot = this.snapshot(); if (this.byTileCount) { this._color = NotificationColor.Red; @@ -111,10 +95,7 @@ export class ListNotificationState extends EventEmitter implements IDestroyable, } // finally, publish an update if needed - const after = {count: this.count, symbol: this.symbol, color: this.color}; - if (JSON.stringify(before) !== JSON.stringify(after)) { - this.emit(NOTIFICATION_STATE_UPDATE); - } + this.emitIfUpdated(snapshot); } } diff --git a/src/stores/notifications/NotificationState.ts b/src/stores/notifications/NotificationState.ts new file mode 100644 index 0000000000..c8ef0ba859 --- /dev/null +++ b/src/stores/notifications/NotificationState.ts @@ -0,0 +1,87 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EventEmitter } from "events"; +import { NotificationColor } from "./NotificationColor"; +import { IDestroyable } from "../../utils/IDestroyable"; + +export const NOTIFICATION_STATE_UPDATE = "update"; + +export abstract class NotificationState extends EventEmitter implements IDestroyable { + protected _symbol: string; + protected _count: number; + protected _color: NotificationColor; + + public get symbol(): string { + return this._symbol; + } + + public get count(): number { + return this._count; + } + + public get color(): NotificationColor { + return this._color; + } + + public get isIdle(): boolean { + return this.color <= NotificationColor.None; + } + + public get isUnread(): boolean { + return this.color >= NotificationColor.Bold; + } + + public get hasUnreadCount(): boolean { + return this.color >= NotificationColor.Grey && (!!this.count || !!this.symbol); + } + + public get hasMentions(): boolean { + return this.color >= NotificationColor.Red; + } + + protected emitIfUpdated(snapshot: NotificationStateSnapshot) { + if (snapshot.isDifferentFrom(this)) { + this.emit(NOTIFICATION_STATE_UPDATE); + } + } + + protected snapshot(): NotificationStateSnapshot { + return new NotificationStateSnapshot(this); + } + + public destroy(): void { + this.removeAllListeners(NOTIFICATION_STATE_UPDATE); + } +} + +export class NotificationStateSnapshot { + private readonly symbol: string; + private readonly count: number; + private readonly color: NotificationColor; + + constructor(state: NotificationState) { + this.symbol = state.symbol; + this.count = state.count; + this.color = state.color; + } + + public isDifferentFrom(other: NotificationState): boolean { + const before = {count: this.count, symbol: this.symbol, color: this.color}; + const after = {count: other.count, symbol: other.symbol, color: other.color}; + return JSON.stringify(before) !== JSON.stringify(after); + } +} diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts index f9b19fcbcb..ab354c0e93 100644 --- a/src/stores/notifications/RoomNotificationState.ts +++ b/src/stores/notifications/RoomNotificationState.ts @@ -14,8 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventEmitter } from "events"; -import { INotificationState, NOTIFICATION_STATE_UPDATE } from "./INotificationState"; import { NotificationColor } from "./NotificationColor"; import { IDestroyable } from "../../utils/IDestroyable"; import { MatrixClientPeg } from "../../MatrixClientPeg"; @@ -25,13 +23,10 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; import * as RoomNotifs from '../../RoomNotifs'; import * as Unread from '../../Unread'; +import { NotificationState } from "./NotificationState"; -export class RoomNotificationState extends EventEmitter implements IDestroyable, INotificationState { - private _symbol: string; - private _count: number; - private _color: NotificationColor; - - constructor(private room: Room) { +export class RoomNotificationState extends NotificationState implements IDestroyable { + constructor(public readonly room: Room) { super(); this.room.on("Room.receipt", this.handleReadReceipt); this.room.on("Room.timeline", this.handleRoomEventUpdate); @@ -41,23 +36,12 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable, this.updateNotificationState(); } - public get symbol(): string { - return this._symbol; - } - - public get count(): number { - return this._count; - } - - public get color(): NotificationColor { - return this._color; - } - private get roomIsInvite(): boolean { return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite; } public destroy(): void { + super.destroy(); this.room.removeListener("Room.receipt", this.handleReadReceipt); this.room.removeListener("Room.timeline", this.handleRoomEventUpdate); this.room.removeListener("Room.redaction", this.handleRoomEventUpdate); @@ -87,7 +71,7 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable, }; private updateNotificationState() { - const before = {count: this.count, symbol: this.symbol, color: this.color}; + const snapshot = this.snapshot(); if (RoomNotifs.getRoomNotifsState(this.room.roomId) === RoomNotifs.MUTE) { // When muted we suppress all notification states, even if we have context on them. @@ -136,9 +120,6 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable, } // finally, publish an update if needed - const after = {count: this.count, symbol: this.symbol, color: this.color}; - if (JSON.stringify(before) !== JSON.stringify(after)) { - this.emit(NOTIFICATION_STATE_UPDATE); - } + this.emitIfUpdated(snapshot); } } diff --git a/src/stores/notifications/RoomNotificationStateStore.ts b/src/stores/notifications/RoomNotificationStateStore.ts new file mode 100644 index 0000000000..311dcdf2d6 --- /dev/null +++ b/src/stores/notifications/RoomNotificationStateStore.ts @@ -0,0 +1,101 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { ActionPayload } from "../../dispatcher/payloads"; +import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { DefaultTagID, TagID } from "../room-list/models"; +import { FetchRoomFn, ListNotificationState } from "./ListNotificationState"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { RoomNotificationState } from "./RoomNotificationState"; +import { TagSpecificNotificationState } from "./TagSpecificNotificationState"; + +const INSPECIFIC_TAG = "INSPECIFIC_TAG"; +type INSPECIFIC_TAG = "INSPECIFIC_TAG"; + +interface IState {} + +export class RoomNotificationStateStore extends AsyncStoreWithClient<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(); + } +} diff --git a/src/stores/notifications/StaticNotificationState.ts b/src/stores/notifications/StaticNotificationState.ts index 51902688fe..0392ed3716 100644 --- a/src/stores/notifications/StaticNotificationState.ts +++ b/src/stores/notifications/StaticNotificationState.ts @@ -14,13 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventEmitter } from "events"; -import { INotificationState } from "./INotificationState"; import { NotificationColor } from "./NotificationColor"; +import { NotificationState } from "./NotificationState"; -export class StaticNotificationState extends EventEmitter implements INotificationState { - constructor(public symbol: string, public count: number, public color: NotificationColor) { +export class StaticNotificationState extends NotificationState { + constructor(symbol: string, count: number, color: NotificationColor) { super(); + this._symbol = symbol; + this._count = count; + this._color = color; } public static forCount(count: number, color: NotificationColor): StaticNotificationState { diff --git a/src/stores/room-list/ListLayout.ts b/src/stores/room-list/ListLayout.ts index efb0c4bdfb..caf2e92bd1 100644 --- a/src/stores/room-list/ListLayout.ts +++ b/src/stores/room-list/ListLayout.ts @@ -18,10 +18,6 @@ import { TagID } from "./models"; 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 { numTiles: number; showPreviews: boolean; @@ -81,35 +77,12 @@ export class ListLayout { } public get minVisibleTiles(): number { - return 1 + RESIZER_BOX_FACTOR; + return 1; } public get defaultVisibleTiles(): number { - // 10 is what "feels right", and mostly subject to design's opinion. - return 10 + RESIZER_BOX_FACTOR; - } - - 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; + // This number is what "feels right", and mostly subject to design's opinion. + return 5; } public tilesWithPadding(n: number, paddingPx: number): number { diff --git a/src/stores/room-list/MessagePreviewStore.ts b/src/stores/room-list/MessagePreviewStore.ts index 01ddde2e17..ea7fa830cd 100644 --- a/src/stores/room-list/MessagePreviewStore.ts +++ b/src/stores/room-list/MessagePreviewStore.ts @@ -192,7 +192,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> { protected async onAction(payload: ActionPayload) { 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 (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') { diff --git a/src/stores/room-list/RoomListLayoutStore.ts b/src/stores/room-list/RoomListLayoutStore.ts new file mode 100644 index 0000000000..fbc7d7719d --- /dev/null +++ b/src/stores/room-list/RoomListLayoutStore.ts @@ -0,0 +1,73 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { TagID } from "./models"; +import { ListLayout } from "./ListLayout"; +import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import { ActionPayload } from "../../dispatcher/payloads"; + +interface IState {} + +export default class RoomListLayoutStore extends AsyncStoreWithClient<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; diff --git a/src/stores/room-list/RoomListStore2.ts b/src/stores/room-list/RoomListStore2.ts index e5205f6051..8686a3a054 100644 --- a/src/stores/room-list/RoomListStore2.ts +++ b/src/stores/room-list/RoomListStore2.ts @@ -25,12 +25,15 @@ import { IListOrderingMap, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm import { ActionPayload } from "../../dispatcher/payloads"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { readReceiptChangeIsFor } from "../../utils/read-receipts"; -import { IFilterCondition } from "./filters/IFilterCondition"; +import { FILTER_CHANGED, IFilterCondition } from "./filters/IFilterCondition"; import { TagWatcher } from "./TagWatcher"; import RoomViewStore from "../RoomViewStore"; import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm"; import { EffectiveMembership, getEffectiveMembership } from "./membership"; import { ListLayout } from "./ListLayout"; +import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; +import RoomListLayoutStore from "./RoomListLayoutStore"; +import { MarkedExecution } from "../../utils/MarkedExecution"; interface IState { tagsEnabled?: boolean; @@ -43,12 +46,19 @@ interface IState { export const LISTS_UPDATE_EVENT = "lists_update"; 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 initialListsGenerated = false; private enabled = false; private algorithm = new Algorithm(); private filterConditions: IFilterCondition[] = []; private tagWatcher = new TagWatcher(this); + private updateFn = new MarkedExecution(() => this.emit(LISTS_UPDATE_EVENT)); private readonly watchedSettings = [ 'feature_custom_tags', @@ -59,8 +69,9 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> { this.checkEnabled(); 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(FILTER_CHANGED, this.onAlgorithmFilterUpdated); } public get orderedLists(): ITagMap { @@ -72,9 +83,43 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> { 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() { - this.enabled = SettingsStore.isFeatureEnabled("feature_new_room_list"); + this.enabled = SettingsStore.getValue("feature_new_room_list"); if (this.enabled) { console.log("⚡ new room list store engaged"); } @@ -88,44 +133,58 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> { 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 const activeRoomId = RoomViewStore.getRoomId(); if (!activeRoomId && this.algorithm.stickyRoom) { - this.algorithm.stickyRoom = null; + await this.algorithm.setStickyRoom(null); } else if (activeRoomId) { const activeRoom = this.matrixClient.getRoom(activeRoomId); if (!activeRoom) { 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) { - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`Changing sticky room to ${activeRoomId}`); - this.algorithm.stickyRoom = activeRoom; + if (!window.mx_QuietRoomListLogging) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 + console.log(`Changing sticky room to ${activeRoomId}`); + } + await this.algorithm.setStickyRoom(activeRoom); } } - }; + + if (trigger) this.updateFn.trigger(); + } 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') { // Filter out anything that isn't the first PREPARED sync. if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) { return; } - // TODO: Remove with https://github.com/vector-im/riot-web/issues/14231 - this.checkEnabled(); - if (!this.enabled) return; + await this.makeReady(payload.matrixClient); - this._matrixClient = payload.matrixClient; - - // 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 + return; // no point in running the next conditions - they won't match } // 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') { // Reset state without causing updates as the client will have been destroyed // and downstream code will throw NPE errors. - this.reset(null, true); + await this.reset(null, true); this._matrixClient = null; 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"); 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}`); return; } - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`[RoomListDebug] Got own read receipt in ${room.roomId}`); + if (!window.mx_QuietRoomListLogging) { + // 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); + this.updateFn.trigger(); return; } } else if (payload.action === 'MatrixActions.Room.tags') { const roomPayload = (<any>payload); // TODO: Type out the dispatcher types - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`[RoomListDebug] Got tag change in ${roomPayload.room.roomId}`); + if (!window.mx_QuietRoomListLogging) { + // 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); + this.updateFn.trigger(); } else if (payload.action === 'MatrixActions.Room.timeline') { 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 room = this.matrixClient.getRoom(roomId); const tryUpdate = async (updatedRoom: Room) => { - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - 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`); + 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']); if (newRoom) { // 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); + this.updateFn.trigger(); }; if (!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}`); return; } - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`[RoomListDebug] Decrypted timeline event ${eventPayload.event.getId()} in ${roomId}`); - // TODO: Verify that e2e rooms are handled on init: https://github.com/vector-im/riot-web/issues/14238 - // 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 :( + if (!window.mx_QuietRoomListLogging) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 + console.log(`[RoomListDebug] Decrypted timeline event ${eventPayload.event.getId()} in ${roomId}`); + } await this.handleRoomUpdate(room, RoomUpdateCause.Timeline); + this.updateFn.trigger(); } else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') { const eventPayload = (<any>payload); // TODO: Type out the dispatcher types - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`[RoomListDebug] Received updated DM map`); + if (!window.mx_QuietRoomListLogging) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 + console.log(`[RoomListDebug] Received updated DM map`); + } const dmMap = eventPayload.event.getContent(); for (const userId of Object.keys(dmMap)) { const roomIds = dmMap[userId]; @@ -246,51 +319,73 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> { await this.handleRoomUpdate(room, RoomUpdateCause.PossibleTagChange); } } + this.updateFn.trigger(); } else if (payload.action === 'MatrixActions.Room.myMembership') { const membershipPayload = (<any>payload); // TODO: Type out the dispatcher types const oldMembership = getEffectiveMembership(membershipPayload.oldMembership); const newMembership = getEffectiveMembership(membershipPayload.membership); if (oldMembership !== EffectiveMembership.Join && newMembership === EffectiveMembership.Join) { - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`[RoomListDebug] Handling new room ${membershipPayload.room.roomId}`); + if (!window.mx_QuietRoomListLogging) { + // 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 // the dead room in the list. const createEvent = membershipPayload.room.currentState.getStateEvents("m.room.create", ""); 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']); if (prevRoom) { const isSticky = this.algorithm.stickyRoom === prevRoom; if (isSticky) { - console.log(`[RoomListDebug] Clearing sticky room due to room upgrade`); - await this.algorithm.setStickyRoomAsync(null); + if (!window.mx_QuietRoomListLogging) { + // 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 // 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); } } - 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); + this.updateFn.trigger(); return; } if (oldMembership !== EffectiveMembership.Invite && newMembership === EffectiveMembership.Invite) { - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`[RoomListDebug] Handling invite to ${membershipPayload.room.roomId}`); + if (!window.mx_QuietRoomListLogging) { + // 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); + this.updateFn.trigger(); return; } // If it's not a join, it's transitioning into a different list (possibly historical) if (oldMembership !== newMembership) { - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`[RoomListDebug] Handling membership change in ${membershipPayload.room.roomId}`); + if (!window.mx_QuietRoomListLogging) { + // 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); + this.updateFn.trigger(); return; } } @@ -299,13 +394,20 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> { private async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<any> { const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause); if (shouldUpdate) { - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`[DEBUG] Room "${room.name}" (${room.roomId}) triggered by ${cause} requires list update`); - this.emit(LISTS_UPDATE_EVENT, this); + if (!window.mx_QuietRoomListLogging) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 + console.log(`[DEBUG] Room "${room.name}" (${room.roomId}) triggered by ${cause} requires list update`); + } + this.updateFn.mark(); } } 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); // TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114 localStorage.setItem(`mx_tagSort_${tagId}`, sort); @@ -321,7 +423,34 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> { 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) { + await this.setAndPersistListOrder(tagId, order); + this.updateFn.trigger(); + } + + private async setAndPersistListOrder(tagId: TagID, order: ListAlgorithm) { await this.algorithm.setListOrdering(tagId, order); // TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114 localStorage.setItem(`mx_listOrder_${tagId}`, order); @@ -337,25 +466,45 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> { return <ListAlgorithm>localStorage.getItem(`mx_listOrder_${tagId}`); } - private async updateAlgorithmInstances() { - const defaultSort = SortAlgorithm.Alphabetic; + // logic must match calculateTagSorting + private calculateListOrder(tagId: TagID): ListAlgorithm { 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)) { const definedSort = this.getTagSorting(tag); const definedOrder = this.getListOrder(tag); - const storedSort = this.getStoredTagSorting(tag); - const storedOrder = this.getStoredListOrder(tag); - - const tagSort = storedSort ? storedSort : (definedSort ? definedSort : defaultSort); - const listOrder = storedOrder ? storedOrder : (definedOrder ? definedOrder : defaultOrder); + const tagSort = this.calculateTagSorting(tag); + const listOrder = this.calculateListOrder(tag); if (tagSort !== definedSort) { - await this.setTagSorting(tag, tagSort); + await this.setAndPersistTagSorting(tag, tagSort); } 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 = () => { - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log("Underlying algorithm has triggered a list update - refiring"); - this.emit(LISTS_UPDATE_EVENT, this); + if (!window.mx_QuietRoomListLogging) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 + 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"); const sorts: ITagSortingMap = {}; const orders: IListOrderingMap = {}; for (const tagId of OrderedDefaultTagIDs) { - sorts[tagId] = this.getStoredTagSorting(tagId) || SortAlgorithm.Alphabetic; - orders[tagId] = this.getStoredListOrder(tagId) || ListAlgorithm.Natural; + sorts[tagId] = this.calculateTagSorting(tagId); + orders[tagId] = this.calculateListOrder(tagId); + + RoomListLayoutStore.instance.ensureLayoutExists(tagId); } if (this.state.tagsEnabled) { @@ -395,30 +562,26 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> { this.initialListsGenerated = true; - this.emit(LISTS_UPDATE_EVENT, this); - } - - // Note: this primarily exists for debugging, and isn't really intended to be used by anything. - public async resetLayouts() { - console.warn("Resetting layouts for room list"); - for (const tagId of Object.keys(this.orderedLists)) { - new ListLayout(tagId).reset(); - } - await this.regenerateAllLists(); + if (trigger) this.updateFn.trigger(); } public addFilter(filter: IFilterCondition): void { - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log("Adding filter condition:", filter); + if (!window.mx_QuietRoomListLogging) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 + console.log("Adding filter condition:", filter); + } this.filterConditions.push(filter); if (this.algorithm) { this.algorithm.addFilterCondition(filter); } + this.updateFn.trigger(); } public removeFilter(filter: IFilterCondition): void { - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log("Removing filter condition:", filter); + if (!window.mx_QuietRoomListLogging) { + // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 + console.log("Removing filter condition:", filter); + } const idx = this.filterConditions.indexOf(filter); if (idx >= 0) { this.filterConditions.splice(idx, 1); @@ -427,6 +590,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> { this.algorithm.removeFilterCondition(filter); } } + this.updateFn.trigger(); } /** diff --git a/src/stores/room-list/RoomListStoreTempProxy.ts b/src/stores/room-list/RoomListStoreTempProxy.ts index 86aff178ee..2a5348ab6e 100644 --- a/src/stores/room-list/RoomListStoreTempProxy.ts +++ b/src/stores/room-list/RoomListStoreTempProxy.ts @@ -24,11 +24,11 @@ import { ITagMap } from "./algorithms/models"; * Temporary RoomListStore proxy. Should be replaced with RoomListStore2 when * 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 { public static isUsingNewStore(): boolean { - return SettingsStore.isFeatureEnabled("feature_new_room_list"); + return SettingsStore.getValue("feature_new_room_list"); } public static addListener(handler: () => void): RoomListStoreTempToken { diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 36abf86975..17e8283c74 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -18,7 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import DMRoomMap from "../../../utils/DMRoomMap"; import { EventEmitter } from "events"; -import { arrayHasDiff, ArrayUtil } from "../../../utils/arrays"; +import { arrayDiff, arrayHasDiff, ArrayUtil } from "../../../utils/arrays"; import { getEnumValues } from "../../../utils/enums"; import { DefaultTagID, RoomUpdateCause, TagID } from "../models"; import { @@ -41,6 +41,17 @@ import { getListAlgorithmInstance } from "./list-ordering"; */ export const LIST_UPDATED_EVENT = "list_updated_event"; +// These are the causes which require a room to be known in order for us to handle them. If +// a cause in this list is raised and we don't know about the room, we don't handle the update. +// +// Note: these typically happen when a new room is coming in, such as the user creating or +// joining the room. For these cases, we need to know about the room prior to handling it otherwise +// we'll make bad assumptions. +const CAUSES_REQUIRING_ROOM = [ + RoomUpdateCause.Timeline, + RoomUpdateCause.ReadReceipt, +]; + interface IStickyRoom { room: Room; position: number; @@ -57,6 +68,7 @@ export class Algorithm extends EventEmitter { private _cachedStickyRooms: ITagMap = {}; // a clone of the _cachedRooms, with the sticky room private filteredRooms: ITagMap = {}; private _stickyRoom: IStickyRoom = null; + private _lastStickyRoom: IStickyRoom = null; // only not-null when changing the sticky room private sortAlgorithms: ITagSortingMap; private listAlgorithms: IListOrderingMap; private algorithms: IOrderingAlgorithmMap; @@ -75,12 +87,6 @@ export class Algorithm extends EventEmitter { 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 { return this.allowedByFilter.size > 0; } @@ -103,11 +109,12 @@ export class Algorithm extends EventEmitter { * Awaitable version of the sticky room setter. * @param val The new room to sticky. */ - public async setStickyRoomAsync(val: Room) { + public async setStickyRoom(val: Room) { await this.updateStickyRoom(val); } public getTagSorting(tagId: TagID): SortAlgorithm { + if (!this.sortAlgorithms) return null; return this.sortAlgorithms[tagId]; } @@ -124,6 +131,7 @@ export class Algorithm extends EventEmitter { } public getListOrdering(tagId: TagID): ListAlgorithm { + if (!this.listAlgorithms) return null; return this.listAlgorithms[tagId]; } @@ -145,11 +153,11 @@ export class Algorithm extends EventEmitter { // Populate the cache of the new filter this.allowedByFilter.set(filterCondition, this.rooms.filter(r => filterCondition.isVisible(r))); this.recalculateFilteredRooms(); - filterCondition.on(FILTER_CHANGED, this.recalculateFilteredRooms.bind(this)); + filterCondition.on(FILTER_CHANGED, this.handleFilterChange.bind(this)); } 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)) { 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) { + 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, // 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 if (!val) { 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 - 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`); // 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 // a new update for ourselves. const lastStickyRoom = this._stickyRoom; - this._stickyRoom = null; + this._stickyRoom = null; // clear before we update the algorithm this.recalculateStickyRoom(); // 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 // 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 await this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom); } // Lie to the algorithm and remove the room from it's field of view 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 // 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 @@ -273,8 +322,10 @@ export class Algorithm extends EventEmitter { } newMap[tagId] = allowedRoomsInThisTag; - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`[DEBUG] ${newMap[tagId].length}/${rooms.length} rooms filtered into ${tagId}`); + if (!window.mx_QuietRoomListLogging) { + // 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[]>[]); @@ -283,26 +334,13 @@ export class Algorithm extends EventEmitter { 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 { 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]; const rooms = this.cachedRooms[tagId].map(r => r); // cheap clone this.tryInsertStickyRoomToFilterSet(rooms, tagId); @@ -311,8 +349,10 @@ export class Algorithm extends EventEmitter { this.filteredRooms[tagId] = filteredRooms; } - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`[DEBUG] ${filteredRooms.length}/${rooms.length} rooms filtered into ${tagId}`); + if (!window.mx_QuietRoomListLogging) { + // 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) { @@ -351,8 +391,10 @@ export class Algorithm extends EventEmitter { } if (!this._cachedStickyRooms || !updatedTag) { - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`Generating clone of cached rooms for sticky room handling`); + if (!window.mx_QuietRoomListLogging) { + // 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 = {}; for (const tagId of Object.keys(this.cachedRooms)) { stickiedTagMap[tagId] = this.cachedRooms[tagId].map(r => r); // shallow clone @@ -363,8 +405,10 @@ export class Algorithm extends EventEmitter { if (updatedTag) { // Update the tag indicated by the caller, if possible. This is mostly to ensure // our cache is up to date. - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`Replacing cached sticky rooms for ${updatedTag}`); + if (!window.mx_QuietRoomListLogging) { + // 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 } @@ -373,8 +417,10 @@ export class Algorithm extends EventEmitter { // we might have updated from the cache is also our sticky room. const sticky = this._stickyRoom; if (!updatedTag || updatedTag === 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}`); + if (!window.mx_QuietRoomListLogging) { + // 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); } @@ -466,13 +512,9 @@ export class Algorithm extends EventEmitter { // Split out the easy rooms first (leave and invite) const memberships = splitRoomsByMembership(rooms); 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); } 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); } @@ -483,11 +525,7 @@ export class Algorithm extends EventEmitter { let inTag = false; if (tags.length > 0) { 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])) { - // 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); inTag = true; } @@ -495,11 +533,11 @@ export class Algorithm extends EventEmitter { } if (!inTag) { - // TODO: Determine if DM and push there instead: https://github.com/vector-im/riot-web/issues/14236 - newTags[DefaultTagID.Untagged].push(room); - - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Untagged`); + if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { + newTags[DefaultTagID.DM].push(room); + } else { + newTags[DefaultTagID.Untagged].push(room); + } } } @@ -560,7 +598,7 @@ export class Algorithm extends EventEmitter { /** * Updates the roomsToTags map */ - protected updateTagsFromCache() { + private updateTagsFromCache() { const newMap = {}; const tags = Object.keys(this.cachedRooms); @@ -607,21 +645,118 @@ export class Algorithm extends EventEmitter { * processing. */ 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"); + // 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) { + const isForLastSticky = this._lastStickyRoom && this._lastStickyRoom.room === room; 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`); 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) { - // TODO: Be smarter and splice rather than regen the planet. https://github.com/vector-im/riot-web/issues/14035 - // TODO: No-op if no change. https://github.com/vector-im/riot-web/issues/14035 - await this.setKnownRooms(this.rooms); - return true; + let didTagChange = false; + const oldTags = this.roomIdsToTags[room.roomId] || []; + const newTags = this.getTagsForRoom(room); + 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 @@ -629,14 +764,27 @@ export class Algorithm extends EventEmitter { // as the sticky room relies on this. if (cause !== RoomUpdateCause.NewRoom && cause !== RoomUpdateCause.RoomRemoved) { if (this.stickyRoom === room) { - // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 - console.warn(`[RoomListDebug] Received ${cause} update for sticky room ${room.roomId} - ignoring`); + if (!window.mx_QuietRoomListLogging) { + // 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; } } - if (cause === RoomUpdateCause.NewRoom && !this.roomIdsToTags[room.roomId]) { - console.log(`[RoomListDebug] Updating tags for new room ${room.roomId} (${room.name})`); + if (!this.roomIdsToTags[room.roomId]) { + 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 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}`); 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) { console.warn(`No tags known for "${room.name}" (${room.roomId})`); return false; @@ -668,6 +826,10 @@ export class Algorithm extends EventEmitter { 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; } } diff --git a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts index e95f92f985..b3f1c2b146 100644 --- a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts @@ -19,47 +19,29 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { RoomUpdateCause, TagID } from "../../models"; import { SortAlgorithm } from "../models"; import { sortRoomsWithAlgorithm } from "../tag-sorting"; -import * as Unread from '../../../../Unread'; import { OrderingAlgorithm } from "./OrderingAlgorithm"; - -/** - * 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", -} +import { NotificationColor } from "../../../notifications/NotificationColor"; +import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore"; interface ICategorizedRoomMap { // @ts-ignore - TS wants this to be a string, but we know better - [category: Category]: Room[]; + [category: NotificationColor]: Room[]; } interface ICategoryIndex { // @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 // comments! Check the usage of Category carefully to figure out what needs changing // 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 @@ -87,18 +69,15 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) { 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 private categorizeRooms(rooms: Room[]): ICategorizedRoomMap { const map: ICategorizedRoomMap = { - [Category.Red]: [], - [Category.Grey]: [], - [Category.Bold]: [], - [Category.Idle]: [], + [NotificationColor.Red]: [], + [NotificationColor.Grey]: [], + [NotificationColor.Bold]: [], + [NotificationColor.None]: [], }; for (const room of rooms) { const category = this.getRoomCategory(room); @@ -108,25 +87,11 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { } // noinspection JSMethodCanBeStatic - private getRoomCategory(room: Room): Category { - // Function implementation borrowed from old RoomListStore - - const mentions = room.getUnreadNotificationCount('highlight') > 0; - if (mentions) { - 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; + private getRoomCategory(room: Room): NotificationColor { + // 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 state = RoomNotificationStateStore.instance.getRoomState(room, this.tagId); + return state.color; } 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) } else if (cause === RoomUpdateCause.RoomRemoved) { const roomIdx = this.getRoomIndex(room); - if (roomIdx === -1) return false; // no change + if (roomIdx === -1) { + console.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`); + return false; // no change + } const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices); this.alterCategoryPositionBy(oldCategory, -1, this.indices); this.cachedOrderedRooms.splice(roomIdx, 1); // remove the room @@ -169,15 +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> { try { 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 // 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 @@ -243,7 +202,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { } // 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++) { const category = CATEGORY_ORDER[i]; const isLast = i === (CATEGORY_ORDER.length - 1); @@ -259,7 +218,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { } // 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 // 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. @@ -270,7 +229,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm { 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 // the target and not the target itself. diff --git a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts index f74329cb4d..ae1a2c98f6 100644 --- a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts @@ -28,9 +28,6 @@ export class NaturalAlgorithm extends OrderingAlgorithm { public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) { 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> { @@ -50,8 +47,12 @@ export class NaturalAlgorithm extends OrderingAlgorithm { if (cause === RoomUpdateCause.NewRoom) { this.cachedOrderedRooms.push(room); } else if (cause === RoomUpdateCause.RoomRemoved) { - const idx = this.cachedOrderedRooms.indexOf(room); - if (idx >= 0) this.cachedOrderedRooms.splice(idx, 1); + const idx = this.getRoomIndex(room); + if (idx >= 0) { + this.cachedOrderedRooms.splice(idx, 1); + } else { + console.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`); + } } // TODO: Optimize this to avoid useless operations: https://github.com/vector-im/riot-web/issues/14035 diff --git a/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts index 4ab7650367..c47a35523c 100644 --- a/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts +++ b/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts @@ -70,4 +70,13 @@ export abstract class OrderingAlgorithm { * @returns True if the update requires the Algorithm to update the presentation layers. */ public abstract handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<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; + } } diff --git a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts index a122ee3ae6..e7ca94ed95 100644 --- a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts +++ b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts @@ -19,6 +19,7 @@ import { TagID } from "../../models"; import { IAlgorithm } from "./IAlgorithm"; import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import * as Unread from "../../../../Unread"; +import { EffectiveMembership, getEffectiveMembership } from "../../membership"; /** * 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 // insertion sort or similar on the limited set of changes. + const myUserId = MatrixClientPeg.get().getUserId(); + const tsCache: { [roomId: string]: number } = {}; const getLastTs = (r: Room) => { if (tsCache[r.roomId]) { @@ -50,13 +53,23 @@ export class RecentAlgorithm implements IAlgorithm { 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) { const ev = r.timeline[i]; 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 - if (ev.getSender() === MatrixClientPeg.get().getUserId() - || Unread.eventTriggersUnreadCount(ev)) { + if (ev.getSender() === myUserId || Unread.eventTriggersUnreadCount(ev)) { return ev.getTs(); } } diff --git a/src/stores/room-list/filters/CommunityFilterCondition.ts b/src/stores/room-list/filters/CommunityFilterCondition.ts index 9f7d8daaa3..45e65fb4f4 100644 --- a/src/stores/room-list/filters/CommunityFilterCondition.ts +++ b/src/stores/room-list/filters/CommunityFilterCondition.ts @@ -52,8 +52,6 @@ export class CommunityFilterCondition extends EventEmitter implements IFilterCon const beforeRoomIds = this.roomIds; this.roomIds = (await GroupStore.getGroupRooms(this.community.groupId)).map(r => r.roomId); 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); } }; diff --git a/src/stores/room-list/filters/NameFilterCondition.ts b/src/stores/room-list/filters/NameFilterCondition.ts index 12f147990d..6014a122f8 100644 --- a/src/stores/room-list/filters/NameFilterCondition.ts +++ b/src/stores/room-list/filters/NameFilterCondition.ts @@ -41,8 +41,6 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio public set search(val: string) { 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); } diff --git a/src/stores/room-list/previews/MessageEventPreview.ts b/src/stores/room-list/previews/MessageEventPreview.ts index 86ec4c539b..86cb51ef15 100644 --- a/src/stores/room-list/previews/MessageEventPreview.ts +++ b/src/stores/room-list/previews/MessageEventPreview.ts @@ -20,6 +20,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { _t } from "../../../languageHandler"; import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; import ReplyThread from "../../../components/views/elements/ReplyThread"; +import { sanitizedHtmlNodeInnerText } from "../../../HtmlUtils"; export class MessageEventPreview implements IPreview { public getTextFor(event: MatrixEvent, tagId?: TagID): string { @@ -36,14 +37,27 @@ export class MessageEventPreview implements IPreview { const msgtype = eventContent['msgtype']; if (!body || !msgtype) return null; // invalid event, no preview + const hasHtml = eventContent.format === "org.matrix.custom.html" && eventContent.formatted_body; + if (hasHtml) { + body = eventContent.formatted_body; + } + // XXX: Newer relations have a getRelation() function which is not compatible with replies. const mRelatesTo = event.getWireContent()['m.relates_to']; if (mRelatesTo && mRelatesTo['m.in_reply_to']) { // If this is a reply, get the real reply and use that - body = (ReplyThread.stripPlainReply(body) || '').trim(); + if (hasHtml) { + body = (ReplyThread.stripHTMLReply(body) || '').trim(); + } else { + body = (ReplyThread.stripPlainReply(body) || '').trim(); + } if (!body) return null; // invalid event, no preview } + if (hasHtml) { + body = sanitizedHtmlNodeInnerText(body); + } + if (msgtype === 'm.emote') { return _t("%(senderName)s %(emote)s", {senderName: getSenderName(event), emote: body}); } diff --git a/src/utils/MarkedExecution.ts b/src/utils/MarkedExecution.ts new file mode 100644 index 0000000000..b0b8fdf63d --- /dev/null +++ b/src/utils/MarkedExecution.ts @@ -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(); + } +} diff --git a/src/utils/pillify.js b/src/utils/pillify.js index f708ab7770..cb140c61a4 100644 --- a/src/utils/pillify.js +++ b/src/utils/pillify.js @@ -111,7 +111,7 @@ export function pillifyLinks(nodes, mxEvent, pills) { type={Pill.TYPE_AT_ROOM_MENTION} inMessage={true} room={room} - shouldShowPillAvatar={true} + shouldShowPillAvatar={shouldShowPillAvatar} />; ReactDOM.render(pill, pillContainer); diff --git a/src/utils/promise.ts b/src/utils/promise.ts index c5c1cb9a56..d3ae2c3d1b 100644 --- a/src/utils/promise.ts +++ b/src/utils/promise.ts @@ -15,7 +15,7 @@ limitations under the License. */ // 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); })); } diff --git a/test/components/views/messages/TextualBody-test.js b/test/components/views/messages/TextualBody-test.js index 07cd51edbd..1f0749aff5 100644 --- a/test/components/views/messages/TextualBody-test.js +++ b/test/components/views/messages/TextualBody-test.js @@ -205,8 +205,9 @@ describe("<TextualBody />", () => { expect(content.html()).toBe('<span class="mx_EventTile_body markdown-body" dir="auto">' + 'Hey <span>' + '<a class="mx_Pill mx_UserPill" title="@user:server">' + - '<img class="mx_BaseAvatar mx_BaseAvatar_image" src="mxc://avatar.url/image.png" ' + - 'style="width: 16px; height: 16px;" title="@member:domain.bla" alt="" aria-hidden="true">Member</a>' + + '<img src="mxc://avatar.url/image.png" style="width: 16px; height: 16px;" ' + + 'title="@member:domain.bla" alt="" aria-hidden="true" role="button" tabindex="0" ' + + 'class="mx_AccessibleButton mx_BaseAvatar mx_BaseAvatar_image">Member</a>' + '</span></span>'); }); }); diff --git a/test/components/views/rooms/RoomList-test.js b/test/components/views/rooms/RoomList-test.js index d0694a8437..e84f943708 100644 --- a/test/components/views/rooms/RoomList-test.js +++ b/test/components/views/rooms/RoomList-test.js @@ -1,7 +1,6 @@ import React from 'react'; import ReactTestUtils from 'react-dom/test-utils'; import ReactDOM from 'react-dom'; -import lolex from 'lolex'; 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 {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() { return '!' + Math.random().toString().slice(2, 10) + ':domain'; } +function waitForRoomListStoreUpdate() { + return new Promise((resolve) => { + RoomListStore.instance.once(LISTS_UPDATE_EVENT, () => resolve()); + }); +} describe('RoomList', () => { function createRoom(opts) { @@ -34,7 +40,6 @@ describe('RoomList', () => { let client = null; let root = null; const myUserId = '@me:domain'; - let clock = null; const movingRoomId = '!someroomid'; let movingRoom; @@ -43,25 +48,25 @@ describe('RoomList', () => { let myMember; let myOtherMember; - beforeEach(function() { + beforeEach(async function(done) { + RoomListStore2.TEST_MODE = true; + TestUtils.stubClient(); client = MatrixClientPeg.get(); client.credentials = {userId: myUserId}; //revert this to prototype method as the test-utils monkey-patches this to return a hardcoded value client.getUserId = MatrixClient.prototype.getUserId; - clock = lolex.install(); - DMRoomMap.makeShared(); parentDiv = document.createElement('div'); document.body.appendChild(parentDiv); - const RoomList = sdk.getComponent('views.rooms.RoomList'); + const RoomList = sdk.getComponent('views.rooms.RoomList2'); const WrappedRoomList = TestUtils.wrapInMatrixClientContext(RoomList); root = ReactDOM.render( <DragDropContext> - <WrappedRoomList searchFilter="" /> + <WrappedRoomList searchFilter="" onResize={() => {}} /> </DragDropContext> , parentDiv); ReactTestUtils.findRenderedComponentWithType(root, RoomList); @@ -102,23 +107,29 @@ describe('RoomList', () => { }); 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) { ReactDOM.unmountComponentAtNode(parentDiv); parentDiv.remove(); parentDiv = null; } - clock.uninstall(); + await RoomListLayoutStore.instance.resetLayouts(); + await RoomListStore.instance.resetStore(); done(); }); function expectRoomInSubList(room, subListTest) { - const RoomSubList = sdk.getComponent('structures.RoomSubList'); - const RoomTile = sdk.getComponent('views.rooms.RoomTile'); + const RoomSubList = sdk.getComponent('views.rooms.RoomSublist2'); + const RoomTile = sdk.getComponent('views.rooms.RoomTile2'); const subLists = ReactTestUtils.scryRenderedComponentsWithType(root, RoomSubList); const containingSubList = subLists.find(subListTest); @@ -140,20 +151,20 @@ describe('RoomList', () => { expect(expectedRoomTile.props.room).toBe(room); } - function expectCorrectMove(oldTag, newTag) { - const getTagSubListTest = (tag) => { - if (tag === undefined) return (s) => s.props.label.endsWith('Rooms'); - return (s) => s.props.tagName === tag; + function expectCorrectMove(oldTagId, newTagId) { + const getTagSubListTest = (tagId) => { + return (s) => s.props.tagId === tagId; }; // Default to finding the destination sublist with newTag - const destSubListTest = getTagSubListTest(newTag); - const srcSubListTest = getTagSubListTest(oldTag); + const destSubListTest = getTagSubListTest(newTagId); + const srcSubListTest = getTagSubListTest(oldTagId); // Set up the room that will be moved such that it has the correct state for a room in - // the section for oldTag - if (['m.favourite', 'm.lowpriority'].includes(oldTag)) movingRoom.tags = {[oldTag]: {}}; - if (oldTag === DefaultTagID.DM) { + // the section for oldTagId + if (oldTagId === DefaultTagID.Favourite || oldTagId === DefaultTagID.LowPriority) { + movingRoom.tags = {[oldTagId]: {}}; + } else if (oldTagId === DefaultTagID.DM) { // Mock inverse m.direct DMRoomMap.shared().roomToUser = { [movingRoom.roomId]: '@someotheruser:domain', @@ -162,17 +173,12 @@ describe('RoomList', () => { dis.dispatch({action: 'MatrixActions.sync', prevState: null, state: 'PREPARED', matrixClient: client}); - clock.runAll(); - expectRoomInSubList(movingRoom, srcSubListTest); 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); } @@ -269,6 +275,12 @@ describe('RoomList', () => { }; 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 dis.dispatch({action: 'select_tag', tag: '+group:domain'}, true); } @@ -277,17 +289,14 @@ describe('RoomList', () => { 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) => { return [movingRoom, otherRoom]; }; GroupStore._notifyListeners(); - // Run through RoomList debouncing - clock.runAll(); - - // By default, the test will - expectRoomInSubList(otherRoom, (s) => s.props.label.endsWith('Rooms')); + await waitForRoomListStoreUpdate(); + expectRoomInSubList(otherRoom, (s) => s.props.tagId === DefaultTagID.Untagged); }); itDoesCorrectOptimisticUpdatesForDraggedRoomTiles(); diff --git a/test/end-to-end-tests/src/usecases/accept-invite.js b/test/end-to-end-tests/src/usecases/accept-invite.js index 3f208cc1fc..d38fdcd0db 100644 --- a/test/end-to-end-tests/src/usecases/accept-invite.js +++ b/test/end-to-end-tests/src/usecases/accept-invite.js @@ -15,10 +15,12 @@ See the License for the specific language governing permissions and limitations under the License. */ +const {findSublist} = require("./create-room"); + module.exports = async function acceptInvite(session, name) { session.log.step(`accepts "${name}" invite`); - //TODO: brittle selector - const invitesHandles = await session.queryAll('.mx_RoomTile_name.mx_RoomTile_invite'); + const inviteSublist = await findSublist(session, "invites"); + const invitesHandles = await inviteSublist.$$(".mx_RoomTile2_name"); const invitesWithText = await Promise.all(invitesHandles.map(async (inviteHandle) => { const text = await session.innerText(inviteHandle); return {inviteHandle, text}; diff --git a/test/end-to-end-tests/src/usecases/create-room.js b/test/end-to-end-tests/src/usecases/create-room.js index 7e219fd159..24e42b92dd 100644 --- a/test/end-to-end-tests/src/usecases/create-room.js +++ b/test/end-to-end-tests/src/usecases/create-room.js @@ -16,21 +16,27 @@ limitations under the License. */ 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(); } +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) { session.log.step(`creates room "${roomName}"`); - const roomListHeaders = await session.queryAll('.mx_RoomSubList_labelContainer'); - const roomListHeaderLabels = await Promise.all(roomListHeaders.map(h => session.innerText(h))); - 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"); + const roomsSublist = await findSublist(session, "rooms"); + const addRoomButton = await roomsSublist.$(".mx_RoomSublist2_auxButton"); await addRoomButton.click(); 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) { session.log.step(`creates DM with ${JSON.stringify(invitees)}`); - const roomListHeaders = await session.queryAll('.mx_RoomSubList_labelContainer'); - const roomListHeaderLabels = await Promise.all(roomListHeaders.map(h => session.innerText(h))); - 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"); + const dmsSublist = await findSublist(session, "people"); + const startChatButton = await dmsSublist.$(".mx_RoomSublist2_auxButton"); await startChatButton.click(); const inviteesEditor = await session.query('.mx_InviteDialog_editor textarea'); @@ -83,4 +83,4 @@ async function createDm(session, invitees) { session.log.done(); } -module.exports = {openRoomDirectory, createRoom, createDm}; +module.exports = {openRoomDirectory, findSublist, createRoom, createDm}; diff --git a/yarn.lock b/yarn.lock index 98b42a0b29..f3dc163b00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1257,6 +1257,11 @@ resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.10.tgz#cc658ca319b6355399efc1f5b9e818f1a24bf999" 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@*": version "2.0.32" 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" integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA== +"@types/linkifyjs@^2.1.3": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@types/linkifyjs/-/linkifyjs-2.1.3.tgz#80195c3c88c5e75d9f660e3046ce4a42be2c2fa4" + integrity sha512-V3Xt9wgaOvDPXcpOy3dC8qXCxy3cs0Lr/Hqgd9Bi6m3sf/vpbpTtfmVR0LJklrqYEjaAmc7e3Xh/INT2rCAKjQ== + dependencies: + "@types/react" "*" + "@types/lodash@^4.14.152": version "4.14.155" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.155.tgz#e2b4514f46a261fd11542e47519c20ebce7bc23a" @@ -1367,6 +1379,13 @@ "@types/prop-types" "*" csstype "^2.2.0" +"@types/sanitize-html@^1.23.3": + version "1.23.3" + resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-1.23.3.tgz#26527783aba3bf195ad8a3c3e51bd3713526fc0d" + integrity sha512-Isg8N0ifKdDq6/kaNlIcWfapDXxxquMSk2XC5THsOICRyOIhQGds95XH75/PL/g9mExi4bL8otIqJM/Wo96WxA== + dependencies: + htmlparser2 "^4.1.0" + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" @@ -2494,7 +2513,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@^2.1.2, classnames@^2.2.5: +classnames@^2.1.2: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== @@ -3774,6 +3793,11 @@ fast-levenshtein@~2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= +fast-memoize@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/fast-memoize/-/fast-memoize-2.5.2.tgz#79e3bb6a4ec867ea40ba0e7146816f6cdce9b57e" + integrity sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw== + fb-watchman@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.1.tgz#fc84fb39d2709cf3ff6d743706157bb5708a8a85" @@ -6877,7 +6901,7 @@ prop-types-exact@^1.2.0: object.assign "^4.1.0" reflect.ownkeys "^0.2.0" -prop-types@15.x, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -7048,6 +7072,13 @@ rc@1.2.8, rc@^1.2.8: minimist "^1.2.0" strip-json-comments "~2.0.1" +re-resizable@^6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.5.2.tgz#7eb1928c673285d4dcf654211e47acb9a3801c3e" + integrity sha512-Pjo3ydkr/meTr6j3YZqyv+9fRS5UNOj5SaAI06gHFQ35BnpsZKmwNvupCnbo11gjQ1I62Uy+UzlHLO9xPQEuWQ== + dependencies: + fast-memoize "^2.5.1" + react-beautiful-dnd@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-4.0.1.tgz#3b0a49bf6be75af351176c904f012611dd292b81" @@ -7081,14 +7112,6 @@ react-dom@^16.9.0: prop-types "^15.6.2" scheduler "^0.19.1" -react-draggable@^4.0.3: - version "4.4.2" - resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.4.2.tgz#f3cefecee25f467f865144cda0d066e5f05f94a0" - integrity sha512-zLQs4R4bnBCGnCVTZiD8hPsHtkiJxgMpGDlRESM+EHQo8ysXhKJ2GKdJ8UxxLJdRVceX1j19jy+hQS2wHislPQ== - dependencies: - classnames "^2.2.5" - prop-types "^15.6.0" - react-focus-lock@^2.2.1: version "2.3.1" resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.3.1.tgz#9d5d85899773609c7eefa4fc54fff6a0f5f2fc47" @@ -7133,14 +7156,6 @@ react-redux@^5.0.6: react-is "^16.6.0" react-lifecycles-compat "^3.0.0" -react-resizable@^1.10.1: - version "1.10.1" - resolved "https://registry.yarnpkg.com/react-resizable/-/react-resizable-1.10.1.tgz#f0c2cf1d83b3470b87676ce6d6b02bbe3f4d8cd4" - integrity sha512-Jd/bKOKx6+19NwC4/aMLRu/J9/krfxlDnElP41Oc+oLiUWs/zwV1S9yBfBZRnqAwQb6vQ/HRSk3bsSWGSgVbpw== - dependencies: - prop-types "15.x" - react-draggable "^4.0.3" - react-test-renderer@^16.0.0-0, react-test-renderer@^16.9.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.13.1.tgz#de25ea358d9012606de51e012d9742e7f0deabc1"