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 @@
+
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 = {[P in Exclude] ? : never};
export type XOR = (T | U) extends object ? (Without & U) | (Without & T) : T | U;
+export type Writeable = { -readonly [P in keyof T]: T[P] };
diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts
index 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;
}
+ 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 ;
}
+export function sanitizedHtmlNodeInnerText(insaneHtml: string) {
+ const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
+ const contentDiv = document.createElement("div");
+ contentDiv.innerHTML = saneHtml;
+ return contentDiv.innerText;
+}
+
/**
* Tests if a URL from an untrusted source may be safely put into the DOM
* The biggest threat here is javascript: URIs.
@@ -136,7 +140,7 @@ export function sanitizedHtmlNode(insaneHtml) {
* other places we need to sanitise URLs.
* @return true if permitted, otherwise false
*/
-export function isUrlPermitted(inputUrl) {
+export function isUrlPermitted(inputUrl: string) {
try {
const parsed = url.parse(inputUrl);
if (!parsed.protocol) return false;
@@ -147,9 +151,9 @@ export function isUrlPermitted(inputUrl) {
}
}
-const transformTags = { // custom to matrix
+const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to matrix
// add blank targets to all hyperlinks except vector URLs
- 'a': function(tagName, attribs) {
+ 'a': function(tagName: string, attribs: sanitizeHtml.Attributes) {
if (attribs.href) {
attribs.target = '_blank'; // by default
@@ -162,7 +166,7 @@ const transformTags = { // custom to matrix
attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/
return { tagName, attribs };
},
- 'img': function(tagName, attribs) {
+ 'img': function(tagName: string, attribs: sanitizeHtml.Attributes) {
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
// because transformTags is used _before_ we filter by allowedSchemesByTag and
// we don't want to allow images with `https?` `src`s.
@@ -176,7 +180,7 @@ const transformTags = { // custom to matrix
);
return { tagName, attribs };
},
- 'code': function(tagName, attribs) {
+ 'code': function(tagName: string, attribs: sanitizeHtml.Attributes) {
if (typeof attribs.class !== 'undefined') {
// Filter out all classes other than ones starting with language- for syntax highlighting.
const classes = attribs.class.split(/\s/).filter(function(cl) {
@@ -186,7 +190,7 @@ const transformTags = { // custom to matrix
}
return { tagName, attribs };
},
- '*': function(tagName, attribs) {
+ '*': function(tagName: string, attribs: sanitizeHtml.Attributes) {
// Delete any style previously assigned, style is an allowedTag for font and span
// because attributes are stripped after transforming
delete attribs.style;
@@ -220,7 +224,7 @@ const transformTags = { // custom to matrix
},
};
-const sanitizeHtmlParams = {
+const sanitizeHtmlParams: sanitizeHtml.IOptions = {
allowedTags: [
'font', // custom to matrix for IRC-style font coloring
'del', // for markdown
@@ -247,16 +251,16 @@ const sanitizeHtmlParams = {
};
// this is the same as the above except with less rewriting
-const composerSanitizeHtmlParams = Object.assign({}, sanitizeHtmlParams);
-composerSanitizeHtmlParams.transformTags = {
- 'code': transformTags['code'],
- '*': transformTags['*'],
+const composerSanitizeHtmlParams: sanitizeHtml.IOptions = {
+ ...sanitizeHtmlParams,
+ transformTags: {
+ 'code': transformTags['code'],
+ '*': transformTags['*'],
+ },
};
-class BaseHighlighter {
- constructor(highlightClass, highlightLink) {
- this.highlightClass = highlightClass;
- this.highlightLink = highlightLink;
+abstract class BaseHighlighter {
+ constructor(public highlightClass: string, public highlightLink: string) {
}
/**
@@ -270,47 +274,49 @@ class BaseHighlighter {
* returns a list of results (strings for HtmlHighligher, react nodes for
* TextHighlighter).
*/
- applyHighlights(safeSnippet, safeHighlights) {
+ public applyHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
let lastOffset = 0;
let offset;
- let nodes = [];
+ let nodes: T[] = [];
const safeHighlight = safeHighlights[0];
while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) {
// handle preamble
if (offset > lastOffset) {
- var subSnippet = safeSnippet.substring(lastOffset, offset);
- nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights));
+ const subSnippet = safeSnippet.substring(lastOffset, offset);
+ nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
}
// do highlight. use the original string rather than safeHighlight
// to preserve the original casing.
const endOffset = offset + safeHighlight.length;
- nodes.push(this._processSnippet(safeSnippet.substring(offset, endOffset), true));
+ nodes.push(this.processSnippet(safeSnippet.substring(offset, endOffset), true));
lastOffset = endOffset;
}
// handle postamble
if (lastOffset !== safeSnippet.length) {
- subSnippet = safeSnippet.substring(lastOffset, undefined);
- nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights));
+ const subSnippet = safeSnippet.substring(lastOffset, undefined);
+ nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
}
return nodes;
}
- _applySubHighlights(safeSnippet, safeHighlights) {
+ private applySubHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
if (safeHighlights[1]) {
// recurse into this range to check for the next set of highlight matches
return this.applyHighlights(safeSnippet, safeHighlights.slice(1));
} else {
// no more highlights to be found, just return the unhighlighted string
- return [this._processSnippet(safeSnippet, false)];
+ return [this.processSnippet(safeSnippet, false)];
}
}
+
+ protected abstract processSnippet(snippet: string, highlight: boolean): T;
}
-class HtmlHighlighter extends BaseHighlighter {
+class HtmlHighlighter extends BaseHighlighter {
/* highlight the given snippet if required
*
* snippet: content of the span; must have been sanitised
@@ -318,28 +324,23 @@ class HtmlHighlighter extends BaseHighlighter {
*
* returns an HTML string
*/
- _processSnippet(snippet, highlight) {
+ protected processSnippet(snippet: string, highlight: boolean): string {
if (!highlight) {
// nothing required here
return snippet;
}
- let span = ""
- + snippet + "";
+ let span = `${snippet}`;
if (this.highlightLink) {
- span = ""
- +span+"";
+ span = `${span}`;
}
return span;
}
}
-class TextHighlighter extends BaseHighlighter {
- constructor(highlightClass, highlightLink) {
- super(highlightClass, highlightLink);
- this._key = 0;
- }
+class TextHighlighter extends BaseHighlighter {
+ private key = 0;
/* create a node to hold the given content
*
@@ -348,13 +349,12 @@ class TextHighlighter extends BaseHighlighter {
*
* returns a React node
*/
- _processSnippet(snippet, highlight) {
- const key = this._key++;
+ protected processSnippet(snippet: string, highlight: boolean): React.ReactNode {
+ const key = this.key++;
- let node =
-
- { snippet }
- ;
+ let node =
+ { snippet }
+ ;
if (highlight && this.highlightLink) {
node = { node };
@@ -364,6 +364,20 @@ class TextHighlighter extends BaseHighlighter {
}
}
+interface IContent {
+ format?: string;
+ formatted_body?: string;
+ body: string;
+}
+
+interface IOpts {
+ highlightLink?: string;
+ disableBigEmoji?: boolean;
+ stripReplyFallback?: boolean;
+ returnString?: boolean;
+ forComposerQuote?: boolean;
+ ref?: React.Ref;
+}
/* turn a matrix event body into html
*
@@ -378,7 +392,7 @@ class TextHighlighter extends BaseHighlighter {
* opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer
* opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString)
*/
-export function bodyToHtml(content, highlights, opts={}) {
+export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) {
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
let bodyHasEmoji = false;
@@ -387,9 +401,9 @@ export function bodyToHtml(content, highlights, opts={}) {
sanitizeParams = composerSanitizeHtmlParams;
}
- let strippedBody;
- let safeBody;
- let isDisplayedWithHtml;
+ let strippedBody: string;
+ let safeBody: string;
+ let isDisplayedWithHtml: boolean;
// XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
// to highlight HTML tags themselves. However, this does mean that we don't highlight textnodes which
// are interrupted by HTML tags (not that we did before) - e.g. foobar won't get highlighted
@@ -471,7 +485,7 @@ export function bodyToHtml(content, highlights, opts={}) {
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string} Linkified string
*/
-export function linkifyString(str, options = linkifyMatrix.options) {
+export function linkifyString(str: string, options = linkifyMatrix.options) {
return _linkifyString(str, options);
}
@@ -482,7 +496,7 @@ export function linkifyString(str, options = linkifyMatrix.options) {
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options
* @returns {object}
*/
-export function linkifyElement(element, options = linkifyMatrix.options) {
+export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options) {
return _linkifyElement(element, options);
}
@@ -493,7 +507,7 @@ export function linkifyElement(element, options = linkifyMatrix.options) {
* @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
* @returns {string}
*/
-export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.options) {
+export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) {
return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
}
@@ -504,7 +518,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.option
* @param {Node} node
* @returns {bool}
*/
-export function checkBlockNode(node) {
+export function checkBlockNode(node: Node) {
switch (node.nodeName) {
case "H1":
case "H2":
diff --git a/src/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;
+
+interface IState {
+ activeRef: Ref;
+ refs: Ref[];
+}
+
+interface IContext {
+ state: IState;
+ dispatch: Dispatch;
+}
+
+const RovingTabIndexContext = createContext({
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 = ({children, handleHomeEnd, onKeyDown}) => {
+ const [state, dispatch] = useReducer>(reducer, {
activeRef: null,
refs: [],
});
- const context = useMemo(() => ({state, dispatch}), [state]);
+ const context = useMemo(() => ({state, dispatch}), [state]);
const onKeyDownHandler = useCallback((ev) => {
let handled = false;
@@ -171,19 +201,17 @@ export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) =>
{ children({onKeyDownHandler}) }
;
};
-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(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 = ({children, inputRef}) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return children({onFocus, isActive, ref});
};
+interface IRovingAccessibleButtonProps extends React.ComponentProps {
+ inputRef?: Ref;
+}
+
+// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
+export const RovingAccessibleButton: React.FC = ({inputRef, ...props}) => {
+ const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
+ return ;
+};
+
diff --git a/src/accessibility/context_menu/ContextMenuButton.tsx b/src/accessibility/context_menu/ContextMenuButton.tsx
new file mode 100644
index 0000000000..c358155e10
--- /dev/null
+++ b/src/accessibility/context_menu/ContextMenuButton.tsx
@@ -0,0 +1,51 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+
+import AccessibleButton, {IProps as IAccessibleButtonProps} from "../../components/views/elements/AccessibleButton";
+
+interface IProps extends IAccessibleButtonProps {
+ label?: string;
+ // whether or not the context menu is currently open
+ isExpanded: boolean;
+}
+
+// Semantic component for representing the AccessibleButton which launches a
+export const ContextMenuButton: React.FC = ({
+ label,
+ isExpanded,
+ children,
+ onClick,
+ onContextMenu,
+ ...props
+}) => {
+ return (
+
+ { children }
+
+ );
+};
diff --git a/src/accessibility/context_menu/MenuGroup.tsx b/src/accessibility/context_menu/MenuGroup.tsx
new file mode 100644
index 0000000000..9334e17a18
--- /dev/null
+++ b/src/accessibility/context_menu/MenuGroup.tsx
@@ -0,0 +1,30 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+
+interface IProps extends React.HTMLAttributes {
+ label: string;
+}
+
+// Semantic component for representing a role=group for grouping menu radios/checkboxes
+export const MenuGroup: React.FC = ({children, label, ...props}) => {
+ return
+ { children }
+
;
+};
diff --git a/src/accessibility/context_menu/MenuItem.tsx b/src/accessibility/context_menu/MenuItem.tsx
new file mode 100644
index 0000000000..64233e51ad
--- /dev/null
+++ b/src/accessibility/context_menu/MenuItem.tsx
@@ -0,0 +1,35 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+
+import AccessibleButton from "../../components/views/elements/AccessibleButton";
+
+interface IProps extends React.ComponentProps {
+ label?: string;
+}
+
+// Semantic component for representing a role=menuitem
+export const MenuItem: React.FC = ({children, label, ...props}) => {
+ return (
+
+ { children }
+
+ );
+};
+
diff --git a/src/accessibility/context_menu/MenuItemCheckbox.tsx b/src/accessibility/context_menu/MenuItemCheckbox.tsx
new file mode 100644
index 0000000000..5eb8cc4819
--- /dev/null
+++ b/src/accessibility/context_menu/MenuItemCheckbox.tsx
@@ -0,0 +1,43 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+
+import AccessibleButton from "../../components/views/elements/AccessibleButton";
+
+interface IProps extends React.ComponentProps {
+ label?: string;
+ active: boolean;
+}
+
+// Semantic component for representing a role=menuitemcheckbox
+export const MenuItemCheckbox: React.FC = ({children, label, active, disabled, ...props}) => {
+ return (
+
+ { children }
+
+ );
+};
diff --git a/src/accessibility/context_menu/MenuItemRadio.tsx b/src/accessibility/context_menu/MenuItemRadio.tsx
new file mode 100644
index 0000000000..472f13ff14
--- /dev/null
+++ b/src/accessibility/context_menu/MenuItemRadio.tsx
@@ -0,0 +1,43 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+
+import AccessibleButton from "../../components/views/elements/AccessibleButton";
+
+interface IProps extends React.ComponentProps {
+ label?: string;
+ active: boolean;
+}
+
+// Semantic component for representing a role=menuitemradio
+export const MenuItemRadio: React.FC = ({children, label, active, disabled, ...props}) => {
+ return (
+
+ { children }
+
+ );
+};
diff --git a/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx b/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx
new file mode 100644
index 0000000000..d373f892c9
--- /dev/null
+++ b/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx
@@ -0,0 +1,64 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+
+import {Key} from "../../Keyboard";
+import StyledCheckbox from "../../components/views/elements/StyledCheckbox";
+
+interface IProps extends React.ComponentProps {
+ label?: string;
+ onChange(); // we handle keyup/down ourselves so lose the ChangeEvent
+ onClose(): void; // gets called after onChange on Key.ENTER
+}
+
+// Semantic component for representing a styled role=menuitemcheckbox
+export const StyledMenuItemCheckbox: React.FC = ({children, label, onChange, onClose, ...props}) => {
+ const onKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === Key.ENTER || e.key === Key.SPACE) {
+ e.stopPropagation();
+ e.preventDefault();
+ onChange();
+ // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
+ if (e.key === Key.ENTER) {
+ onClose();
+ }
+ }
+ };
+ const onKeyUp = (e: React.KeyboardEvent) => {
+ // prevent the input default handler as we handle it on keydown to match
+ // https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
+ if (e.key === Key.SPACE || e.key === Key.ENTER) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ };
+ return (
+
+ { children }
+
+ );
+};
diff --git a/src/accessibility/context_menu/StyledMenuItemRadio.tsx b/src/accessibility/context_menu/StyledMenuItemRadio.tsx
new file mode 100644
index 0000000000..5e5aa90a38
--- /dev/null
+++ b/src/accessibility/context_menu/StyledMenuItemRadio.tsx
@@ -0,0 +1,64 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+
+import {Key} from "../../Keyboard";
+import StyledRadioButton from "../../components/views/elements/StyledRadioButton";
+
+interface IProps extends React.ComponentProps {
+ label?: string;
+ onChange(); // we handle keyup/down ourselves so lose the ChangeEvent
+ onClose(): void; // gets called after onChange on Key.ENTER
+}
+
+// Semantic component for representing a styled role=menuitemradio
+export const StyledMenuItemRadio: React.FC = ({children, label, onChange, onClose, ...props}) => {
+ const onKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === Key.ENTER || e.key === Key.SPACE) {
+ e.stopPropagation();
+ e.preventDefault();
+ onChange();
+ // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
+ if (e.key === Key.ENTER) {
+ onClose();
+ }
+ }
+ };
+ const onKeyUp = (e: React.KeyboardEvent) => {
+ // prevent the input default handler as we handle it on keydown to match
+ // https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
+ if (e.key === Key.SPACE || e.key === Key.ENTER) {
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ };
+ return (
+
+ { children }
+
+ );
+};
diff --git a/src/components/structures/ContextMenu.js b/src/components/structures/ContextMenu.tsx
similarity index 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 {
+ 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> = {};
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 = (
-
+
);
}
return (
-
-
+
+
{ chevron }
{ props.children }
@@ -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
-export const ContextMenuButton = ({ label, isExpanded, children, onClick, onContextMenu, ...props }) => {
- const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
- return (
-
- { children }
-
- );
-};
-ContextMenuButton.propTypes = {
- ...AccessibleButton.propTypes,
- label: PropTypes.string,
- isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open
-};
-
-// Semantic component for representing a role=menuitem
-export const MenuItem = ({children, label, ...props}) => {
- const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
- return (
-
- { children }
-
- );
-};
-MenuItem.propTypes = {
- ...AccessibleButton.propTypes,
- label: PropTypes.string, // optional
- className: PropTypes.string, // optional
- onClick: PropTypes.func.isRequired,
-};
-
-// Semantic component for representing a role=group for grouping menu radios/checkboxes
-export const MenuGroup = ({children, label, ...props}) => {
- return
- { children }
-
;
-};
-MenuGroup.propTypes = {
- label: PropTypes.string.isRequired,
- className: PropTypes.string, // optional
-};
-
-// Semantic component for representing a role=menuitemcheckbox
-export const MenuItemCheckbox = ({children, label, active=false, disabled=false, ...props}) => {
- const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
- return (
-
- { children }
-
- );
-};
-MenuItemCheckbox.propTypes = {
- ...AccessibleButton.propTypes,
- label: PropTypes.string, // optional
- active: PropTypes.bool.isRequired,
- disabled: PropTypes.bool, // optional
- className: PropTypes.string, // optional
- onClick: PropTypes.func.isRequired,
-};
-
-// Semantic component for representing a role=menuitemradio
-export const MenuItemRadio = ({children, label, active=false, disabled=false, ...props}) => {
- const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
- return (
-
- { children }
-
- );
-};
-MenuItemRadio.propTypes = {
- ...AccessibleButton.propTypes,
- label: PropTypes.string, // optional
- active: PropTypes.bool.isRequired,
- disabled: PropTypes.bool, // optional
- className: PropTypes.string, // optional
- onClick: PropTypes.func.isRequired,
-};
-
// Placement method for to position context menu to right of elementRect with chevronOffset
-export const toRightOf = (elementRect, chevronOffset=12) => {
+export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => {
const left = elementRect.right + window.pageXOffset + 3;
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
top -= chevronOffset + 8; // where 8 is half the height of the chevron
@@ -448,8 +396,8 @@ export const toRightOf = (elementRect, chevronOffset=12) => {
};
// Placement method for to position context menu right-aligned and flowing to the left of elementRect
-export const aboveLeftOf = (elementRect, chevronFace="none") => {
- const menuOptions = { chevronFace };
+export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None) => {
+ const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.pageXOffset;
const buttonBottom = elementRect.bottom + window.pageYOffset;
@@ -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
+ "mx_RoomSublist2_headerText",
+ "mx_RoomTile2",
+ "mx_RoomSublist2_showNButton",
+];
+
export default class LeftPanel2 extends React.Component {
private listContainerRef: React.RefObject = 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 {
};
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(".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();
+
+ let lastTopHeader;
+ let firstBottomHeader;
for (const sublist of sublists) {
- const slRect = sublist.getBoundingClientRect();
-
const header = sublist.querySelector(".mx_RoomSublist2_stickable");
+ header.style.removeProperty("display"); // always clear display:none first
- if (slRect.top + headerHeight > bottom && !gotBottom) {
- header.classList.add("mx_RoomSublist2_headerContainer_sticky");
- header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom");
- header.style.width = `${headerStickyWidth}px`;
- header.style.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 {
}
};
+ private onEnter = () => {
+ const firstRoom = this.listContainerRef.current.querySelector(".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 {
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 {
private renderHeader(): React.ReactNode {
let breadcrumbs;
- if (this.state.showBreadcrumbs) {
+ if (this.state.showBreadcrumbs && !this.props.isMinimized) {
breadcrumbs = (
-
);
- // 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 (
-
+
{
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 {
);
}
+ private onScrollPrevent(e: React.UIEvent) {
+ // 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 {
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 = (
{_t("Show %(count)s more", {count: numMissing})}
@@ -496,14 +640,14 @@ export default class RoomSublist2 extends React.Component {
);
if (this.props.isMinimized) showMoreText = null;
showNButton = (
-
+
{/* set by CSS masking */}
{showMoreText}
-
+
);
- } 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 = (
@@ -512,19 +656,29 @@ export default class RoomSublist2 extends React.Component {
);
if (this.props.isMinimized) showLessText = null;
showNButton = (
-
+
{/* set by CSS masking */}
{showLessText}
-
+
);
}
// 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 {
// 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 = (
-
- {visibleTiles}
- {showNButton}
-
+
+
+
+ {visibleTiles}
+
+ {showNButton}
+
+
);
}
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;
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 = ({active, onClick, iconClassNam
};
export default class RoomTile2 extends React.Component {
+ private dispatcherRef: string;
+ private roomTileRef = createRef();
// TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
constructor(props: IProps) {
@@ -110,25 +131,54 @@ export default class RoomTile2 extends React.Component {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
// 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 = (
@@ -357,7 +442,6 @@ export default class RoomTile2 extends React.Component {
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 {
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 = (
-
+
{
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 = (
-
+
{text}
);
@@ -409,7 +494,7 @@ export default class RoomTile2 extends React.Component {
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 {
);
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 (
-
+
{({onFocus, isActive, ref}) =>
{
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)}
}
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 {
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")}
);
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 {
+ public render() {
+ return
+
+
+
;
+ }
+}
\ No newline at end of file
diff --git a/src/components/views/voip/CallPreview2.tsx b/src/components/views/voip/CallPreview2.tsx
new file mode 100644
index 0000000000..1f2caf5ef8
--- /dev/null
+++ b/src/components/views/voip/CallPreview2.tsx
@@ -0,0 +1,129 @@
+/*
+Copyright 2017, 2018 New Vector Ltd
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+
+import React from 'react';
+
+import CallView from "./CallView2";
+import RoomViewStore from '../../../stores/RoomViewStore';
+import CallHandler from '../../../CallHandler';
+import dis from '../../../dispatcher/dispatcher';
+import { ActionPayload } from '../../../dispatcher/payloads';
+import PersistentApp from "../elements/PersistentApp";
+import SettingsStore from "../../../settings/SettingsStore";
+
+interface IProps {
+ // A Conference Handler implementation
+ // Must have a function signature:
+ // getConferenceCallForRoom(roomId: string): MatrixCall
+ ConferenceHandler: any;
+}
+
+interface IState {
+ roomId: string;
+ activeCall: any;
+ newRoomListActive: boolean;
+}
+
+export default class CallPreview extends React.Component {
+ private roomStoreToken: any;
+ private dispatcherRef: string;
+ private settingsWatcherRef: string;
+
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ roomId: RoomViewStore.getRoomId(),
+ activeCall: CallHandler.getAnyActiveCall(),
+ newRoomListActive: SettingsStore.getValue("feature_new_room_list"),
+ };
+
+ this.settingsWatcherRef = SettingsStore.watchSetting("feature_new_room_list", null, (name, roomId, level, valAtLevel, newVal) => this.setState({
+ newRoomListActive: newVal,
+ }));
+ }
+
+ public componentDidMount() {
+ this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
+ this.dispatcherRef = dis.register(this.onAction);
+ }
+
+ public componentWillUnmount() {
+ if (this.roomStoreToken) {
+ this.roomStoreToken.remove();
+ }
+ dis.unregister(this.dispatcherRef);
+ SettingsStore.unwatchSetting(this.settingsWatcherRef);
+ }
+
+ private onRoomViewStoreUpdate = (payload) => {
+ if (RoomViewStore.getRoomId() === this.state.roomId) return;
+ this.setState({
+ roomId: RoomViewStore.getRoomId(),
+ });
+ };
+
+ private onAction = (payload: ActionPayload) => {
+ switch (payload.action) {
+ // listen for call state changes to prod the render method, which
+ // may hide the global CallView if the call it is tracking is dead
+ case 'call_state':
+ this.setState({
+ activeCall: CallHandler.getAnyActiveCall(),
+ });
+ break;
+ }
+ };
+
+ private onCallViewClick = () => {
+ const call = CallHandler.getAnyActiveCall();
+ if (call) {
+ dis.dispatch({
+ action: 'view_room',
+ room_id: call.groupRoomId || call.roomId,
+ });
+ }
+ };
+
+ public render() {
+ if (this.state.newRoomListActive) {
+ const callForRoom = CallHandler.getCallForRoom(this.state.roomId);
+ const showCall = (
+ this.state.activeCall &&
+ this.state.activeCall.call_state === 'connected' &&
+ !callForRoom
+ );
+
+ if (showCall) {
+ return (
+
+ );
+ }
+
+ return ;
+ }
+
+ return null;
+ }
+}
+
diff --git a/src/components/views/voip/CallView2.tsx b/src/components/views/voip/CallView2.tsx
new file mode 100644
index 0000000000..c80d82d395
--- /dev/null
+++ b/src/components/views/voip/CallView2.tsx
@@ -0,0 +1,200 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+
+import React, {createRef} from 'react';
+import Room from 'matrix-js-sdk/src/models/room';
+import dis from '../../../dispatcher/dispatcher';
+import CallHandler from '../../../CallHandler';
+import {MatrixClientPeg} from '../../../MatrixClientPeg';
+import { _t } from '../../../languageHandler';
+import AccessibleButton from '../elements/AccessibleButton';
+import VideoView from "./VideoView";
+import RoomAvatar from "../avatars/RoomAvatar";
+import PulsedAvatar from '../avatars/PulsedAvatar';
+
+interface IProps {
+ // js-sdk room object. If set, we will only show calls for the given
+ // room; if not, we will show any active call.
+ room?: Room;
+
+ // A Conference Handler implementation
+ // Must have a function signature:
+ // getConferenceCallForRoom(roomId: string): MatrixCall
+ ConferenceHandler?: any;
+
+ // maxHeight style attribute for the video panel
+ maxVideoHeight?: number;
+
+ // a callback which is called when the user clicks on the video div
+ onClick?: React.MouseEventHandler;
+
+ // a callback which is called when the content in the callview changes
+ // in a way that is likely to cause a resize.
+ onResize?: any;
+
+ // classname applied to view,
+ className?: string;
+
+ // Whether to show the hang up icon:W
+ showHangup?: boolean;
+}
+
+interface IState {
+ call: any;
+}
+
+export default class CallView extends React.Component {
+ private videoref: React.RefObject;
+ private dispatcherRef: string;
+ public call: any;
+
+ constructor(props: IProps) {
+ super(props);
+
+ this.state = {
+ // the call this view is displaying (if any)
+ call: null,
+ };
+
+ this.videoref = createRef();
+ }
+
+ public componentDidMount() {
+ this.dispatcherRef = dis.register(this.onAction);
+ this.showCall();
+ }
+
+ public componentWillUnmount() {
+ dis.unregister(this.dispatcherRef);
+ }
+
+ private onAction = (payload) => {
+ // don't filter out payloads for room IDs other than props.room because
+ // we may be interested in the conf 1:1 room
+ if (payload.action !== 'call_state') {
+ return;
+ }
+ this.showCall();
+ };
+
+ private showCall() {
+ let call;
+
+ if (this.props.room) {
+ const roomId = this.props.room.roomId;
+ call = CallHandler.getCallForRoom(roomId) ||
+ (this.props.ConferenceHandler ?
+ this.props.ConferenceHandler.getConferenceCallForRoom(roomId) :
+ null
+ );
+
+ if (this.call) {
+ this.setState({ call: call });
+ }
+ } else {
+ call = CallHandler.getAnyActiveCall();
+ // Ignore calls if we can't get the room associated with them.
+ // I think the underlying problem is that the js-sdk sends events
+ // for calls before it has made the rooms available in the store,
+ // although this isn't confirmed.
+ if (MatrixClientPeg.get().getRoom(call.roomId) === null) {
+ call = null;
+ }
+ this.setState({ call: call });
+ }
+
+ if (call) {
+ call.setLocalVideoElement(this.getVideoView().getLocalVideoElement());
+ call.setRemoteVideoElement(this.getVideoView().getRemoteVideoElement());
+ // always use a separate element for audio stream playback.
+ // this is to let us move CallView around the DOM without interrupting remote audio
+ // during playback, by having the audio rendered by a top-level element.
+ // rather than being rendered by the main remoteVideo 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 =
+
+
+
+
;
+ }
+}
+
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 {
+ 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
+
+
+
+
+
+
{caller}
+
{incomingCallText}
+
+
+
+
+
+
+
+
;
+ }
+}
+
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(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 {
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 {
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. Learn more.": "This room is bridging messages to the following platforms. Learn more.",
"This room isn’t bridging messages to any platforms. Learn more.": "This room isn’t bridging messages to any platforms. Learn more.",
"Bridges": "Bridges",
@@ -1199,14 +1205,16 @@
"Securely back up your keys to avoid losing them. Learn more.": "Securely back up your keys to avoid losing them. Learn more.",
"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 Create a new room.": "If you can't find the room you're looking for, ask for an invite or Create a new room.",
+ "Clear filter": "Clear filter",
+ "Search rooms": "Search rooms",
"You can't send any messages until you review and agree to our terms and conditions.": "You can't send any messages until you review and agree to our terms and conditions.",
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.",
"Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.",
@@ -2097,10 +2108,7 @@
"%(count)s Resend all or cancel all now. You can also select individual messages to resend or cancel.|one": "Resend message or cancel message 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 invite others or stop warning about the empty room?": "There's no one else here! Would you like to invite others or stop warning about the empty room?",
- "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 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 here now to %(foo)s".
@@ -105,7 +117,9 @@ function safeCounterpartTranslate(text, options) {
*
* @return a React 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 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 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 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