diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c9d11f02c8..e9ede862d2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,15 @@ - + - + + + diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 3c3807e33b..0ae59da09a 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -1,5 +1,8 @@ name: Develop on: + # These tests won't work for non-develop branches at the moment as they + # won't pull in the right versions of other repos, so they're only enabled + # on develop. push: branches: [develop] pull_request: diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.rst rename to CONTRIBUTING.md diff --git a/__mocks__/workerMock.js b/__mocks__/workerMock.js new file mode 100644 index 0000000000..6ee585673e --- /dev/null +++ b/__mocks__/workerMock.js @@ -0,0 +1 @@ +module.exports = jest.fn(); diff --git a/package.json b/package.json index 27c4f39a09..e80ed8dd5a 100644 --- a/package.json +++ b/package.json @@ -187,7 +187,8 @@ "\\$webapp/i18n/languages.json": "/__mocks__/languages.json", "decoderWorker\\.min\\.js": "/__mocks__/empty.js", "decoderWorker\\.min\\.wasm": "/__mocks__/empty.js", - "waveWorker\\.min\\.js": "/__mocks__/empty.js" + "waveWorker\\.min\\.js": "/__mocks__/empty.js", + "workers/(.+)\\.worker\\.ts": "/__mocks__/workerMock.js" }, "transformIgnorePatterns": [ "/node_modules/(?!matrix-js-sdk).+$" diff --git a/res/css/_components.scss b/res/css/_components.scss index 8f80f1bf97..4efc3f2316 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -120,6 +120,7 @@ @import "./views/elements/_AddressTile.scss"; @import "./views/elements/_DesktopBuildsNotice.scss"; @import "./views/elements/_DesktopCapturerSourcePicker.scss"; +@import "./views/elements/_DialPadBackspaceButton.scss"; @import "./views/elements/_DirectorySearchBox.scss"; @import "./views/elements/_Dropdown.scss"; @import "./views/elements/_EditableItemList.scss"; @@ -163,6 +164,7 @@ @import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MImageBody.scss"; +@import "./views/messages/_MImageReplyBody.scss"; @import "./views/messages/_MJitsiWidgetEvent.scss"; @import "./views/messages/_MNoticeBody.scss"; @import "./views/messages/_MStickerBody.scss"; @@ -212,6 +214,7 @@ @import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PresenceLabel.scss"; @import "./views/rooms/_ReplyPreview.scss"; +@import "./views/rooms/_ReplyTile.scss"; @import "./views/rooms/_RoomBreadcrumbs.scss"; @import "./views/rooms/_RoomHeader.scss"; @import "./views/rooms/_RoomList.scss"; diff --git a/res/css/structures/_TabbedView.scss b/res/css/structures/_TabbedView.scss index 39a8ebed32..833450a25b 100644 --- a/res/css/structures/_TabbedView.scss +++ b/res/css/structures/_TabbedView.scss @@ -1,6 +1,7 @@ /* Copyright 2017 Travis Ralston Copyright 2019 New Vector Ltd +Copyright 2021 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. @@ -20,7 +21,6 @@ limitations under the License. padding: 0 0 0 16px; display: flex; flex-direction: column; - position: absolute; top: 0; bottom: 0; left: 0; @@ -28,11 +28,93 @@ limitations under the License. margin-top: 8px; } +.mx_TabbedView_tabsOnLeft { + flex-direction: column; + position: absolute; + + .mx_TabbedView_tabLabels { + width: 170px; + max-width: 170px; + position: fixed; + } + + .mx_TabbedView_tabPanel { + margin-left: 240px; // 170px sidebar + 70px padding + flex-direction: column; + } + + .mx_TabbedView_tabLabel_active { + background-color: $tab-label-active-bg-color; + color: $tab-label-active-fg-color; + } + + .mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before { + background-color: $tab-label-active-icon-bg-color; + } + + .mx_TabbedView_maskedIcon { + width: 16px; + height: 16px; + margin-left: 8px; + margin-right: 16px; + } + + .mx_TabbedView_maskedIcon::before { + mask-size: 16px; + width: 16px; + height: 16px; + } +} + +.mx_TabbedView_tabsOnTop { + flex-direction: column; + + .mx_TabbedView_tabLabels { + display: flex; + margin-bottom: 8px; + } + + .mx_TabbedView_tabLabel { + padding-left: 0px; + padding-right: 52px; + + .mx_TabbedView_tabLabel_text { + font-size: 15px; + color: $tertiary-fg-color; + } + } + + .mx_TabbedView_tabPanel { + flex-direction: row; + } + + .mx_TabbedView_tabLabel_active { + color: $accent-color; + .mx_TabbedView_tabLabel_text { + color: $accent-color; + } + } + + .mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before { + background-color: $accent-color; + } + + .mx_TabbedView_maskedIcon { + width: 22px; + height: 22px; + margin-left: 0px; + margin-right: 8px; + } + + .mx_TabbedView_maskedIcon::before { + mask-size: 22px; + width: inherit; + height: inherit; + } +} + .mx_TabbedView_tabLabels { - width: 170px; - max-width: 170px; color: $tab-label-fg-color; - position: fixed; } .mx_TabbedView_tabLabel { @@ -46,43 +128,25 @@ limitations under the License. position: relative; } -.mx_TabbedView_tabLabel_active { - background-color: $tab-label-active-bg-color; - color: $tab-label-active-fg-color; -} - .mx_TabbedView_maskedIcon { - margin-left: 8px; - margin-right: 16px; - width: 16px; - height: 16px; display: inline-block; } .mx_TabbedView_maskedIcon::before { display: inline-block; - background-color: $tab-label-icon-bg-color; + background-color: $icon-button-color; mask-repeat: no-repeat; - mask-size: 16px; - width: 16px; - height: 16px; mask-position: center; content: ''; } -.mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before { - background-color: $tab-label-active-icon-bg-color; -} - .mx_TabbedView_tabLabel_text { vertical-align: middle; } .mx_TabbedView_tabPanel { - margin-left: 240px; // 170px sidebar + 70px padding flex-grow: 1; display: flex; - flex-direction: column; min-height: 0; // firefox } diff --git a/res/css/views/audio_messages/_PlaybackContainer.scss b/res/css/views/audio_messages/_PlaybackContainer.scss index fd01864bba..5548f6198e 100644 --- a/res/css/views/audio_messages/_PlaybackContainer.scss +++ b/res/css/views/audio_messages/_PlaybackContainer.scss @@ -49,4 +49,8 @@ limitations under the License. padding-right: 6px; // with the fixed width this ends up as a visual 8px most of the time, as intended. padding-left: 8px; // isolate from recording circle / play control } + + &.mx_VoiceMessagePrimaryContainer_noWaveform { + max-width: 162px; // with all the padding this results in 185px wide + } } diff --git a/res/css/views/dialogs/_InviteDialog.scss b/res/css/views/dialogs/_InviteDialog.scss index c01b43c1c4..9fc4b7a15c 100644 --- a/res/css/views/dialogs/_InviteDialog.scss +++ b/res/css/views/dialogs/_InviteDialog.scss @@ -14,6 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_InviteDialog_transferWrapper .mx_Dialog { + padding-bottom: 16px; +} + .mx_InviteDialog_addressBar { display: flex; flex-direction: row; @@ -286,16 +290,41 @@ limitations under the License. } } -.mx_InviteDialog { +.mx_InviteDialog_other { // Prevent the dialog from jumping around randomly when elements change. height: 600px; padding-left: 20px; // the design wants some padding on the left - display: flex; + + .mx_InviteDialog_userSections { + height: calc(100% - 115px); // mx_InviteDialog's height minus some for the upper and lower elements + } +} + +.mx_InviteDialog_content { + height: calc(100% - 36px); // full height minus the size of the header + overflow: hidden; +} + +.mx_InviteDialog_transfer { + width: 496px; + height: 466px; flex-direction: column; .mx_InviteDialog_content { - overflow: hidden; - height: 100%; + flex-direction: column; + + .mx_TabbedView { + height: calc(100% - 60px); + } + overflow: visible; + } + + .mx_InviteDialog_addressBar { + margin-top: 8px; + } + + input[type="checkbox"] { + margin-right: 8px; } } @@ -303,7 +332,6 @@ limitations under the License. margin-top: 4px; overflow-y: auto; padding: 0 45px 4px 0; - height: calc(100% - 115px); // mx_InviteDialog's height minus some for the upper and lower elements } .mx_InviteDialog_hasFooter .mx_InviteDialog_userSections { @@ -318,6 +346,74 @@ limitations under the License. padding: 0; } +.mx_InviteDialog_dialPad .mx_InviteDialog_dialPadField { + border-top: 0; + border-left: 0; + border-right: 0; + border-radius: 0; + margin-top: 0; + border-color: $quaternary-fg-color; + + input { + font-size: 18px; + font-weight: 600; + padding-top: 0; + } +} + +.mx_InviteDialog_dialPad .mx_InviteDialog_dialPadField:focus-within { + border-color: $accent-color; +} + +.mx_InviteDialog_dialPadField .mx_Field_postfix { + /* Remove border separator between postfix and field content */ + border-left: none; +} + +.mx_InviteDialog_dialPad { + width: 224px; + margin-top: 16px; + margin-left: auto; + margin-right: auto; +} + +.mx_InviteDialog_dialPad .mx_DialPad { + row-gap: 16px; + column-gap: 48px; + + margin-left: auto; + margin-right: auto; +} + +.mx_InviteDialog_transferConsultConnect { + padding-top: 16px; + /* This wants a drop shadow the full width of the dialog, so relative-position it + * and make it wider, then compensate with padding + */ + position: relative; + width: 496px; + left: -24px; + padding-left: 24px; + padding-right: 24px; + border-top: 1px solid $message-body-panel-bg-color; + + display: flex; + flex-direction: row; + align-items: center; +} + +.mx_InviteDialog_transferConsultConnect_pushRight { + margin-left: auto; +} + +.mx_InviteDialog_userDirectoryIcon::before { + mask-image: url('$(res)/img/voip/tab-userdirectory.svg'); +} + +.mx_InviteDialog_dialPadIcon::before { + mask-image: url('$(res)/img/voip/tab-dialpad.svg'); +} + .mx_InviteDialog_multiInviterError { > h4 { font-size: $font-15px; diff --git a/res/css/views/elements/_DialPadBackspaceButton.scss b/res/css/views/elements/_DialPadBackspaceButton.scss new file mode 100644 index 0000000000..40e4af7025 --- /dev/null +++ b/res/css/views/elements/_DialPadBackspaceButton.scss @@ -0,0 +1,40 @@ +/* +Copyright 2021 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_DialPadBackspaceButton { + position: relative; + height: 28px; + width: 28px; + + &::before { + /* force this element to appear on the DOM */ + content: ""; + + background-color: #8D97A5; + width: inherit; + height: inherit; + top: 0px; + left: 0px; + position: absolute; + display: inline-block; + vertical-align: middle; + + mask-image: url('$(res)/img/element-icons/call/delete.svg'); + mask-position: 8px; + mask-size: 20px; + mask-repeat: no-repeat; + } +} diff --git a/res/css/views/elements/_ReplyThread.scss b/res/css/views/elements/_ReplyThread.scss index bf44a11728..af8ca956ba 100644 --- a/res/css/views/elements/_ReplyThread.scss +++ b/res/css/views/elements/_ReplyThread.scss @@ -18,20 +18,46 @@ limitations under the License. margin-top: 0; } -.mx_ReplyThread .mx_DateSeparator { - font-size: 1em !important; - margin-top: 0; - margin-bottom: 0; - padding-bottom: 1px; - bottom: -5px; -} - .mx_ReplyThread_show { cursor: pointer; } blockquote.mx_ReplyThread { margin-left: 0; + margin-right: 0; + margin-bottom: 8px; padding-left: 10px; - border-left: 4px solid $blockquote-bar-color; + border-left: 4px solid $button-bg-color; + + &.mx_ReplyThread_color1 { + border-left-color: $username-variant1-color; + } + + &.mx_ReplyThread_color2 { + border-left-color: $username-variant2-color; + } + + &.mx_ReplyThread_color3 { + border-left-color: $username-variant3-color; + } + + &.mx_ReplyThread_color4 { + border-left-color: $username-variant4-color; + } + + &.mx_ReplyThread_color5 { + border-left-color: $username-variant5-color; + } + + &.mx_ReplyThread_color6 { + border-left-color: $username-variant6-color; + } + + &.mx_ReplyThread_color7 { + border-left-color: $username-variant7-color; + } + + &.mx_ReplyThread_color8 { + border-left-color: $username-variant8-color; + } } diff --git a/res/css/views/messages/_MFileBody.scss b/res/css/views/messages/_MFileBody.scss index c215d69ec2..b91c461ce5 100644 --- a/res/css/views/messages/_MFileBody.scss +++ b/res/css/views/messages/_MFileBody.scss @@ -83,12 +83,12 @@ limitations under the License. mask-size: cover; mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); background-color: $message-body-panel-icon-fg-color; - width: 13px; + width: 15px; height: 15px; position: absolute; top: 8px; - left: 9px; + left: 8px; } } diff --git a/res/css/views/messages/_MImageReplyBody.scss b/res/css/views/messages/_MImageReplyBody.scss new file mode 100644 index 0000000000..70c53f8c9c --- /dev/null +++ b/res/css/views/messages/_MImageReplyBody.scss @@ -0,0 +1,37 @@ +/* +Copyright 2020 Tulir Asokan + +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_MImageReplyBody { + display: flex; + + .mx_MImageBody_thumbnail_container { + flex: 1; + margin-right: 4px; + } + + .mx_MImageReplyBody_info { + flex: 1; + + .mx_MImageReplyBody_sender { + grid-area: sender; + } + + .mx_MImageReplyBody_filename { + grid-area: filename; + } + } +} + diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss index 5e61c3b8a3..97190807ca 100644 --- a/res/css/views/rooms/_IRCLayout.scss +++ b/res/css/views/rooms/_IRCLayout.scss @@ -198,8 +198,9 @@ $irc-line-height: $font-18px; .mx_ReplyThread { margin: 0; .mx_SenderProfile { + order: unset; + max-width: unset; width: unset; - max-width: var(--name-width); background: transparent; } diff --git a/res/css/views/rooms/_ReplyPreview.scss b/res/css/views/rooms/_ReplyPreview.scss index 10f8e21e43..c1fe1d9a8b 100644 --- a/res/css/views/rooms/_ReplyPreview.scss +++ b/res/css/views/rooms/_ReplyPreview.scss @@ -29,12 +29,16 @@ limitations under the License. } .mx_ReplyPreview_header { - margin: 12px; + margin: 8px; color: $primary-fg-color; font-weight: 400; opacity: 0.4; } +.mx_ReplyPreview_tile { + margin: 0 8px; +} + .mx_ReplyPreview_title { float: left; } @@ -42,6 +46,7 @@ limitations under the License. .mx_ReplyPreview_cancel { float: right; cursor: pointer; + display: flex; } .mx_ReplyPreview_clear { diff --git a/res/css/views/rooms/_ReplyTile.scss b/res/css/views/rooms/_ReplyTile.scss new file mode 100644 index 0000000000..c8f76ee995 --- /dev/null +++ b/res/css/views/rooms/_ReplyTile.scss @@ -0,0 +1,123 @@ +/* +Copyright 2020 Tulir Asokan + +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_ReplyTile { + padding-top: 2px; + padding-bottom: 2px; + font-size: $font-14px; + position: relative; + line-height: $font-16px; + + &.mx_ReplyTile_audio .mx_MFileBody_info_icon::before { + mask-image: url("$(res)/img/element-icons/speaker.svg"); + } + + &.mx_ReplyTile_video .mx_MFileBody_info_icon::before { + mask-image: url("$(res)/img/element-icons/call/video-call.svg"); + } + + .mx_MFileBody { + .mx_MFileBody_info { + margin: 5px 0; + } + + .mx_MFileBody_download { + display: none; + } + } +} + +.mx_ReplyTile > a { + display: flex; + flex-direction: column; + text-decoration: none; + color: $primary-fg-color; +} + +.mx_ReplyTile .mx_RedactedBody { + padding: 4px 0 2px 20px; + + &::before { + height: 13px; + width: 13px; + top: 5px; + } +} + +// We do reply size limiting with CSS to avoid duplicating the TextualBody component. +.mx_ReplyTile .mx_EventTile_content { + $reply-lines: 2; + $line-height: $font-22px; + + pointer-events: none; + + text-overflow: ellipsis; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: $reply-lines; + line-height: $line-height; + + .mx_EventTile_body.mx_EventTile_bigEmoji { + line-height: $line-height !important; + // Override the big emoji override + font-size: $font-14px !important; + } + + // Hide line numbers + .mx_EventTile_lineNumbers { + display: none; + } + + // Hack to cut content in
 tags too
+    .mx_EventTile_pre_container > pre {
+        overflow: hidden;
+        text-overflow: ellipsis;
+        display: -webkit-box;
+        -webkit-box-orient: vertical;
+        -webkit-line-clamp: $reply-lines;
+        padding: 4px;
+    }
+
+    .markdown-body blockquote,
+    .markdown-body dl,
+    .markdown-body ol,
+    .markdown-body p,
+    .markdown-body pre,
+    .markdown-body table,
+    .markdown-body ul {
+        margin-bottom: 4px;
+    }
+}
+
+.mx_ReplyTile.mx_ReplyTile_info {
+    padding-top: 0;
+}
+
+.mx_ReplyTile .mx_SenderProfile {
+    color: $primary-fg-color;
+    font-size: $font-14px;
+    display: inline-block; /* anti-zalgo, with overflow hidden */
+    overflow: hidden;
+    cursor: pointer;
+    padding-left: 0; /* left gutter */
+    padding-bottom: 0;
+    padding-top: 0;
+    margin: 0;
+    line-height: $font-17px;
+    /* the next three lines, along with overflow hidden, truncate long display names */
+    white-space: nowrap;
+    text-overflow: ellipsis;
+}
diff --git a/res/css/views/voip/_DialPad.scss b/res/css/views/voip/_DialPad.scss
index 483b131bfe..eefd2e9ba5 100644
--- a/res/css/views/voip/_DialPad.scss
+++ b/res/css/views/voip/_DialPad.scss
@@ -16,11 +16,21 @@ limitations under the License.
 
 .mx_DialPad {
     display: grid;
+    row-gap: 16px;
+    column-gap: 0px;
+    margin-top: 24px;
+    margin-left: auto;
+    margin-right: auto;
+
+    /* squeeze the dial pad buttons together horizontally */
     grid-template-columns: repeat(3, 1fr);
-    gap: 16px;
 }
 
 .mx_DialPad_button {
+    display: flex;
+    flex-direction: column;
+    justify-content: center;
+
     width: 40px;
     height: 40px;
     background-color: $dialpad-button-bg-color;
@@ -29,10 +39,19 @@ limitations under the License.
     font-weight: 600;
     text-align: center;
     vertical-align: middle;
-    line-height: 40px;
+    margin-left: auto;
+    margin-right: auto;
 }
 
-.mx_DialPad_deleteButton, .mx_DialPad_dialButton {
+.mx_DialPad_button .mx_DialPad_buttonSubText {
+    font-size: 8px;
+}
+
+.mx_DialPad_dialButton {
+    /* Always show the dial button in the center grid column */
+    grid-column: 2;
+    background-color: $accent-color;
+
     &::before {
         content: '';
         display: inline-block;
@@ -42,21 +61,7 @@ limitations under the License.
         mask-repeat: no-repeat;
         mask-size: 20px;
         mask-position: center;
-        background-color: $primary-bg-color;
-    }
-}
-
-.mx_DialPad_deleteButton {
-    background-color: $notice-primary-color;
-    &::before {
-        mask-image: url('$(res)/img/element-icons/call/delete.svg');
-        mask-position: 9px; // delete icon is right-heavy so have to be slightly to the left to look centered
-    }
-}
-
-.mx_DialPad_dialButton {
-    background-color: $accent-color;
-    &::before {
+        background-color: #FFF; // on all themes
         mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
     }
 }
diff --git a/res/css/views/voip/_DialPadContextMenu.scss b/res/css/views/voip/_DialPadContextMenu.scss
index 31327113cf..0019994e72 100644
--- a/res/css/views/voip/_DialPadContextMenu.scss
+++ b/res/css/views/voip/_DialPadContextMenu.scss
@@ -14,10 +14,40 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+.mx_DialPadContextMenu_dialPad .mx_DialPad {
+    row-gap: 16px;
+    column-gap: 32px;
+}
+
+.mx_DialPadContextMenuWrapper {
+    padding: 15px;
+}
+
 .mx_DialPadContextMenu_header {
-    margin-top: 12px;
-    margin-left: 12px;
-    margin-right: 12px;
+    border: none;
+    margin-top: 32px;
+    margin-left: 20px;
+    margin-right: 20px;
+
+    /* a separator between the input line and the dial buttons */
+    border-bottom: 1px solid $quaternary-fg-color;
+    transition: border-bottom 0.25s;
+}
+
+.mx_DialPadContextMenu_cancel {
+    float: right;
+    mask: url('$(res)/img/feather-customised/cancel.svg');
+    mask-repeat: no-repeat;
+    mask-position: center;
+    mask-size: cover;
+    width: 14px;
+    height: 14px;
+    background-color: $dialog-close-fg-color;
+    cursor: pointer;
+}
+
+.mx_DialPadContextMenu_header:focus-within {
+    border-bottom: 1px solid $accent-color;
 }
 
 .mx_DialPadContextMenu_title {
@@ -30,7 +60,6 @@ limitations under the License.
     height: 1.5em;
     font-size: 18px;
     font-weight: 600;
-    max-width: 150px;
     border: none;
     margin: 0px;
 }
@@ -38,7 +67,7 @@ limitations under the License.
     font-size: 18px;
     font-weight: 600;
     overflow: hidden;
-    max-width: 150px;
+    max-width: 185px;
     text-align: left;
     direction: rtl;
     padding: 8px 0px;
@@ -48,13 +77,3 @@ limitations under the License.
 .mx_DialPadContextMenu_dialPad {
     margin: 16px;
 }
-
-.mx_DialPadContextMenu_horizSep {
-    position: relative;
-    &::before {
-        content: '';
-        position: absolute;
-        width: 100%;
-        border-bottom: 1px solid $input-darker-bg-color;
-    }
-}
diff --git a/res/css/views/voip/_DialPadModal.scss b/res/css/views/voip/_DialPadModal.scss
index f9d7673a38..b8042f77ae 100644
--- a/res/css/views/voip/_DialPadModal.scss
+++ b/res/css/views/voip/_DialPadModal.scss
@@ -19,14 +19,23 @@ limitations under the License.
 }
 
 .mx_DialPadModal {
-    width: 192px;
-    height: 368px;
+    width: 292px;
+    height: 370px;
+    padding: 16px 0px 0px 0px;
 }
 
 .mx_DialPadModal_header {
-    margin-top: 12px;
-    margin-left: 12px;
-    margin-right: 12px;
+    margin-top: 32px;
+    margin-left: 40px;
+    margin-right: 40px;
+
+    /* a separator between the input line and the dial buttons */
+    border-bottom: 1px solid $quaternary-fg-color;
+    transition: border-bottom 0.25s;
+}
+
+.mx_DialPadModal_header:focus-within {
+    border-bottom: 1px solid $accent-color;
 }
 
 .mx_DialPadModal_title {
@@ -45,11 +54,18 @@ limitations under the License.
     height: 14px;
     background-color: $dialog-close-fg-color;
     cursor: pointer;
+    margin-right: 16px;
 }
 
 .mx_DialPadModal_field {
     border: none;
     margin: 0px;
+    height: 30px;
+}
+
+.mx_DialPadModal_field .mx_Field_postfix {
+    /* Remove border separator between postfix and field content */
+    border-left: none;
 }
 
 .mx_DialPadModal_field input {
@@ -62,13 +78,3 @@ limitations under the License.
     margin-right: 16px;
     margin-top: 16px;
 }
-
-.mx_DialPadModal_horizSep {
-    position: relative;
-    &::before {
-        content: '';
-        position: absolute;
-        width: 100%;
-        border-bottom: 1px solid $input-darker-bg-color;
-    }
-}
diff --git a/res/img/element-icons/speaker.svg b/res/img/element-icons/speaker.svg
new file mode 100644
index 0000000000..fd811d2cda
--- /dev/null
+++ b/res/img/element-icons/speaker.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/res/img/voip/tab-dialpad.svg b/res/img/voip/tab-dialpad.svg
new file mode 100644
index 0000000000..b7add0addb
--- /dev/null
+++ b/res/img/voip/tab-dialpad.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/voip/tab-userdirectory.svg b/res/img/voip/tab-userdirectory.svg
new file mode 100644
index 0000000000..792ded7be4
--- /dev/null
+++ b/res/img/voip/tab-userdirectory.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss
index 57cbc7efa9..74b33fbd02 100644
--- a/res/themes/dark/css/_dark.scss
+++ b/res/themes/dark/css/_dark.scss
@@ -118,7 +118,7 @@ $voipcall-plinth-color: #394049;
 // ********************
 
 $theme-button-bg-color: #e3e8f0;
-$dialpad-button-bg-color: #6F7882;
+$dialpad-button-bg-color: #394049;
 
 $roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons
 $roomlist-filter-active-bg-color: $bg-color;
diff --git a/src/@types/worker-loader.d.ts b/src/@types/worker-loader.d.ts
new file mode 100644
index 0000000000..a8f5d8e9a4
--- /dev/null
+++ b/src/@types/worker-loader.d.ts
@@ -0,0 +1,23 @@
+/*
+Copyright 2021 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.
+*/
+
+declare module "*.worker.ts" {
+    class WebpackWorker extends Worker {
+        constructor();
+    }
+
+    export default WebpackWorker;
+}
diff --git a/src/AddThreepid.js b/src/AddThreepid.js
index eb822c6d75..ab291128a7 100644
--- a/src/AddThreepid.js
+++ b/src/AddThreepid.js
@@ -248,7 +248,7 @@ export default class AddThreepid {
 
     /**
      * Takes a phone number verification code as entered by the user and validates
-     * it with the ID server, then if successful, adds the phone number.
+     * it with the identity server, then if successful, adds the phone number.
      * @param {string} msisdnToken phone number verification code as entered by the user
      * @return {Promise} Resolves if the phone number was added. Rejects with an object
      * with a "message" property which contains a human-readable message detailing why
diff --git a/src/Avatar.ts b/src/Avatar.ts
index 4c4bd1c265..198d4162a0 100644
--- a/src/Avatar.ts
+++ b/src/Avatar.ts
@@ -21,7 +21,7 @@ import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
 
 import DMRoomMap from './utils/DMRoomMap';
 import { mediaFromMxc } from "./customisations/Media";
-import SettingsStore from "./settings/SettingsStore";
+import SpaceStore from "./stores/SpaceStore";
 
 // Not to be used for BaseAvatar urls as that has similar default avatar fallback already
 export function avatarUrlForMember(
@@ -153,7 +153,7 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi
     }
 
     // space rooms cannot be DMs so skip the rest
-    if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return null;
+    if (SpaceStore.spacesEnabled && room.isSpaceRoom()) return null;
 
     let otherMember = null;
     const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
diff --git a/src/BlurhashEncoder.ts b/src/BlurhashEncoder.ts
new file mode 100644
index 0000000000..2aee370fe9
--- /dev/null
+++ b/src/BlurhashEncoder.ts
@@ -0,0 +1,60 @@
+/*
+Copyright 2021 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 { defer, IDeferred } from "matrix-js-sdk/src/utils";
+
+// @ts-ignore - `.ts` is needed here to make TS happy
+import BlurhashWorker from "./workers/blurhash.worker.ts";
+
+interface IBlurhashWorkerResponse {
+    seq: number;
+    blurhash: string;
+}
+
+export class BlurhashEncoder {
+    private static internalInstance = new BlurhashEncoder();
+
+    public static get instance(): BlurhashEncoder {
+        return BlurhashEncoder.internalInstance;
+    }
+
+    private readonly worker: Worker;
+    private seq = 0;
+    private pendingDeferredMap = new Map>();
+
+    constructor() {
+        this.worker = new BlurhashWorker();
+        this.worker.onmessage = this.onMessage;
+    }
+
+    private onMessage = (ev: MessageEvent) => {
+        const { seq, blurhash } = ev.data;
+        const deferred = this.pendingDeferredMap.get(seq);
+        if (deferred) {
+            this.pendingDeferredMap.delete(seq);
+            deferred.resolve(blurhash);
+        }
+    };
+
+    public getBlurhash(imageData: ImageData): Promise {
+        const seq = this.seq++;
+        const deferred = defer();
+        this.pendingDeferredMap.set(seq, deferred);
+        this.worker.postMessage({ seq, imageData });
+        return deferred.promise;
+    }
+}
+
diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx
index 6e1e6ce83a..f90854ee64 100644
--- a/src/CallHandler.tsx
+++ b/src/CallHandler.tsx
@@ -154,7 +154,7 @@ export default class CallHandler extends EventEmitter {
     private supportsPstnProtocol = null;
     private pstnSupportPrefixed = null; // True if the server only support the prefixed pstn protocol
     private supportsSipNativeVirtual = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native
-    private pstnSupportCheckTimer: NodeJS.Timeout; // number actually because we're in the browser
+    private pstnSupportCheckTimer: number;
     // For rooms we've been invited to, true if they're from virtual user, false if we've checked and they aren't.
     private invitedRoomsAreVirtual = new Map();
     private invitedRoomCheckInProgress = false;
@@ -394,7 +394,7 @@ export default class CallHandler extends EventEmitter {
     }
 
     private setCallListeners(call: MatrixCall) {
-        let mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
+        let mappedRoomId = this.roomIdForCall(call);
 
         call.on(CallEvent.Error, (err: CallError) => {
             if (!this.matchesCallForThisRoom(call)) return;
@@ -871,6 +871,12 @@ export default class CallHandler extends EventEmitter {
             case Action.DialNumber:
                 this.dialNumber(payload.number);
                 break;
+            case Action.TransferCallToMatrixID:
+                this.startTransferToMatrixID(payload.call, payload.destination, payload.consultFirst);
+                break;
+            case Action.TransferCallToPhoneNumber:
+                this.startTransferToPhoneNumber(payload.call, payload.destination, payload.consultFirst);
+                break;
         }
     };
 
@@ -905,6 +911,48 @@ export default class CallHandler extends EventEmitter {
         });
     }
 
+    private async startTransferToPhoneNumber(call: MatrixCall, destination: string, consultFirst: boolean) {
+        const results = await this.pstnLookup(destination);
+        if (!results || results.length === 0 || !results[0].userid) {
+            Modal.createTrackedDialog('', '', ErrorDialog, {
+                title: _t("Unable to transfer call"),
+                description: _t("There was an error looking up the phone number"),
+            });
+            return;
+        }
+
+        await this.startTransferToMatrixID(call, results[0].userid, consultFirst);
+    }
+
+    private async startTransferToMatrixID(call: MatrixCall, destination: string, consultFirst: boolean) {
+        if (consultFirst) {
+            const dmRoomId = await ensureDMExists(MatrixClientPeg.get(), destination);
+
+            dis.dispatch({
+                action: 'place_call',
+                type: call.type,
+                room_id: dmRoomId,
+                transferee: call,
+            });
+            dis.dispatch({
+                action: 'view_room',
+                room_id: dmRoomId,
+                should_peek: false,
+                joining: false,
+            });
+        } else {
+            try {
+                await call.transfer(destination);
+            } catch (e) {
+                console.log("Failed to transfer call", e);
+                Modal.createTrackedDialog('Failed to transfer call', '', ErrorDialog, {
+                    title: _t('Transfer Failed'),
+                    description: _t('Failed to transfer call'),
+                });
+            }
+        }
+    }
+
     setActiveCallRoomId(activeCallRoomId: string) {
         logger.info("Setting call in room " + activeCallRoomId + " active");
 
diff --git a/src/ContentMessages.tsx b/src/ContentMessages.tsx
index 0ab193081b..0c65a7bd35 100644
--- a/src/ContentMessages.tsx
+++ b/src/ContentMessages.tsx
@@ -17,7 +17,6 @@ limitations under the License.
 */
 
 import React from "react";
-import { encode } from "blurhash";
 import { MatrixClient } from "matrix-js-sdk/src/client";
 
 import dis from './dispatcher/dispatcher';
@@ -28,7 +27,6 @@ import RoomViewStore from './stores/RoomViewStore';
 import encrypt from "browser-encrypt-attachment";
 import extractPngChunks from "png-chunks-extract";
 import Spinner from "./components/views/elements/Spinner";
-
 import { Action } from "./dispatcher/actions";
 import CountlyAnalytics from "./CountlyAnalytics";
 import {
@@ -39,7 +37,8 @@ import {
     UploadStartedPayload,
 } from "./dispatcher/payloads/UploadPayload";
 import { IUpload } from "./models/IUpload";
-import { IImageInfo } from "matrix-js-sdk/src/@types/partials";
+import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials";
+import { BlurhashEncoder } from "./BlurhashEncoder";
 
 const MAX_WIDTH = 800;
 const MAX_HEIGHT = 600;
@@ -85,10 +84,6 @@ interface IThumbnail {
     thumbnail: Blob;
 }
 
-interface IAbortablePromise extends Promise {
-    abort(): void;
-}
-
 /**
  * Create a thumbnail for a image DOM element.
  * The image will be smaller than MAX_WIDTH and MAX_HEIGHT.
@@ -107,55 +102,62 @@ interface IAbortablePromise extends Promise {
  * @return {Promise} A promise that resolves with an object with an info key
  *  and a thumbnail key.
  */
-function createThumbnail(
+async function createThumbnail(
     element: ThumbnailableElement,
     inputWidth: number,
     inputHeight: number,
     mimeType: string,
 ): Promise {
-    return new Promise((resolve) => {
-        let targetWidth = inputWidth;
-        let targetHeight = inputHeight;
-        if (targetHeight > MAX_HEIGHT) {
-            targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
-            targetHeight = MAX_HEIGHT;
-        }
-        if (targetWidth > MAX_WIDTH) {
-            targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
-            targetWidth = MAX_WIDTH;
-        }
+    let targetWidth = inputWidth;
+    let targetHeight = inputHeight;
+    if (targetHeight > MAX_HEIGHT) {
+        targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight));
+        targetHeight = MAX_HEIGHT;
+    }
+    if (targetWidth > MAX_WIDTH) {
+        targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth));
+        targetWidth = MAX_WIDTH;
+    }
 
-        const canvas = document.createElement("canvas");
+    let canvas: HTMLCanvasElement | OffscreenCanvas;
+    if (window.OffscreenCanvas) {
+        canvas = new window.OffscreenCanvas(targetWidth, targetHeight);
+    } else {
+        canvas = document.createElement("canvas");
         canvas.width = targetWidth;
         canvas.height = targetHeight;
-        const context = canvas.getContext("2d");
-        context.drawImage(element, 0, 0, targetWidth, targetHeight);
-        const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
-        const blurhash = encode(
-            imageData.data,
-            imageData.width,
-            imageData.height,
-            // use 4 components on the longer dimension, if square then both
-            imageData.width >= imageData.height ? 4 : 3,
-            imageData.height >= imageData.width ? 4 : 3,
-        );
-        canvas.toBlob(function(thumbnail) {
-            resolve({
-                info: {
-                    thumbnail_info: {
-                        w: targetWidth,
-                        h: targetHeight,
-                        mimetype: thumbnail.type,
-                        size: thumbnail.size,
-                    },
-                    w: inputWidth,
-                    h: inputHeight,
-                    [BLURHASH_FIELD]: blurhash,
-                },
-                thumbnail,
-            });
-        }, mimeType);
-    });
+    }
+
+    const context = canvas.getContext("2d");
+    context.drawImage(element, 0, 0, targetWidth, targetHeight);
+
+    let thumbnailPromise: Promise;
+
+    if (window.OffscreenCanvas) {
+        thumbnailPromise = (canvas as OffscreenCanvas).convertToBlob({ type: mimeType });
+    } else {
+        thumbnailPromise = new Promise(resolve => (canvas as HTMLCanvasElement).toBlob(resolve, mimeType));
+    }
+
+    const imageData = context.getImageData(0, 0, targetWidth, targetHeight);
+    // thumbnailPromise and blurhash promise are being awaited concurrently
+    const blurhash = await BlurhashEncoder.instance.getBlurhash(imageData);
+    const thumbnail = await thumbnailPromise;
+
+    return {
+        info: {
+            thumbnail_info: {
+                w: targetWidth,
+                h: targetHeight,
+                mimetype: thumbnail.type,
+                size: thumbnail.size,
+            },
+            w: inputWidth,
+            h: inputHeight,
+            [BLURHASH_FIELD]: blurhash,
+        },
+        thumbnail,
+    };
 }
 
 /**
@@ -333,7 +335,7 @@ export function uploadFile(
     roomId: string,
     file: File | Blob,
     progressHandler?: any, // TODO: Types
-): Promise<{url?: string, file?: any}> { // TODO: Types
+): IAbortablePromise<{url?: string, file?: any}> { // TODO: Types
     let canceled = false;
     if (matrixClient.isRoomEncrypted(roomId)) {
         // If the room is encrypted then encrypt the file before uploading it.
@@ -365,8 +367,8 @@ export function uploadFile(
                 encryptInfo.mimetype = file.type;
             }
             return { "file": encryptInfo };
-        });
-        (prom as IAbortablePromise).abort = () => {
+        }) as IAbortablePromise<{ file: any }>;
+        prom.abort = () => {
             canceled = true;
             if (uploadPromise) matrixClient.cancelUpload(uploadPromise);
         };
@@ -379,8 +381,8 @@ export function uploadFile(
             if (canceled) throw new UploadCanceledError();
             // If the attachment isn't encrypted then include the URL directly.
             return { url };
-        });
-        (promise1 as any).abort = () => {
+        }) as IAbortablePromise<{ url: string }>;
+        promise1.abort = () => {
             canceled = true;
             matrixClient.cancelUpload(basePromise);
         };
@@ -551,10 +553,10 @@ export default class ContentMessages {
                 content.msgtype = 'm.file';
                 resolve();
             }
-        });
+        }) as IAbortablePromise;
 
         // create temporary abort handler for before the actual upload gets passed off to js-sdk
-        (prom as IAbortablePromise).abort = () => {
+        prom.abort = () => {
             upload.canceled = true;
         };
 
@@ -583,9 +585,7 @@ export default class ContentMessages {
             // XXX: upload.promise must be the promise that
             // is returned by uploadFile as it has an abort()
             // method hacked onto it.
-            upload.promise = uploadFile(
-                matrixClient, roomId, file, onProgress,
-            );
+            upload.promise = uploadFile(matrixClient, roomId, file, onProgress);
             return upload.promise.then(function(result) {
                 content.file = result.file;
                 content.url = result.url;
diff --git a/src/CountlyAnalytics.ts b/src/CountlyAnalytics.ts
index a75c578536..72b0462bcd 100644
--- a/src/CountlyAnalytics.ts
+++ b/src/CountlyAnalytics.ts
@@ -364,8 +364,8 @@ export default class CountlyAnalytics {
 
     private initTime = CountlyAnalytics.getTimestamp();
     private firstPage = true;
-    private heartbeatIntervalId: NodeJS.Timeout;
-    private activityIntervalId: NodeJS.Timeout;
+    private heartbeatIntervalId: number;
+    private activityIntervalId: number;
     private trackTime = true;
     private lastBeat: number;
     private storedDuration = 0;
diff --git a/src/DecryptionFailureTracker.ts b/src/DecryptionFailureTracker.ts
index d40574a6db..df306a54f5 100644
--- a/src/DecryptionFailureTracker.ts
+++ b/src/DecryptionFailureTracker.ts
@@ -46,8 +46,8 @@ export class DecryptionFailureTracker {
     };
 
     // Set to an interval ID when `start` is called
-    public checkInterval: NodeJS.Timeout = null;
-    public trackInterval: NodeJS.Timeout = null;
+    public checkInterval: number = null;
+    public trackInterval: number = null;
 
     // Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`.
     static TRACK_INTERVAL_MS = 60000;
diff --git a/src/IdentityAuthClient.js b/src/IdentityAuthClient.js
index 31a5021317..447c5edd30 100644
--- a/src/IdentityAuthClient.js
+++ b/src/IdentityAuthClient.js
@@ -127,7 +127,7 @@ export default class IdentityAuthClient {
             await this._matrixClient.getIdentityAccount(token);
         } catch (e) {
             if (e.errcode === "M_TERMS_NOT_SIGNED") {
-                console.log("Identity Server requires new terms to be agreed to");
+                console.log("Identity server requires new terms to be agreed to");
                 await startTermsFlow([new Service(
                     SERVICE_TYPES.IS,
                     identityServerUrl,
diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts
index 063c5f4cad..e9364b1b47 100644
--- a/src/MatrixClientPeg.ts
+++ b/src/MatrixClientPeg.ts
@@ -17,8 +17,8 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { ICreateClientOpts } from 'matrix-js-sdk/src/matrix';
-import { MatrixClient } from 'matrix-js-sdk/src/client';
+import { ICreateClientOpts, PendingEventOrdering } from 'matrix-js-sdk/src/matrix';
+import { IStartClientOpts, MatrixClient } from 'matrix-js-sdk/src/client';
 import { MemoryStore } from 'matrix-js-sdk/src/store/memory';
 import * as utils from 'matrix-js-sdk/src/utils';
 import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
@@ -47,25 +47,8 @@ export interface IMatrixClientCreds {
     freshLogin?: boolean;
 }
 
-// TODO: Move this to the js-sdk
-export interface IOpts {
-    initialSyncLimit?: number;
-    pendingEventOrdering?: "detached" | "chronological";
-    lazyLoadMembers?: boolean;
-    clientWellKnownPollPeriod?: number;
-}
-
 export interface IMatrixClientPeg {
-    opts: IOpts;
-
-    /**
-     * Sets the script href passed to the IndexedDB web worker
-     * If set, a separate web worker will be started to run the IndexedDB
-     * queries on.
-     *
-     * @param {string} script href to the script to be passed to the web worker
-     */
-    setIndexedDbWorkerScript(script: string): void;
+    opts: IStartClientOpts;
 
     /**
      * Return the server name of the user's homeserver
@@ -127,7 +110,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
     // client is started in 'start'. These can be altered
     // at any time up to after the 'will_start_client'
     // event is finished processing.
-    public opts: IOpts = {
+    public opts: IStartClientOpts = {
         initialSyncLimit: 20,
     };
 
@@ -141,10 +124,6 @@ class _MatrixClientPeg implements IMatrixClientPeg {
     constructor() {
     }
 
-    public setIndexedDbWorkerScript(script: string): void {
-        createMatrixClient.indexedDbWorkerScript = script;
-    }
-
     public get(): MatrixClient {
         return this.matrixClient;
     }
@@ -231,7 +210,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
 
         const opts = utils.deepCopy(this.opts);
         // the react sdk doesn't work without this, so don't allow
-        opts.pendingEventOrdering = "detached";
+        opts.pendingEventOrdering = PendingEventOrdering.Detached;
         opts.lazyLoadMembers = true;
         opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
 
diff --git a/src/Rooms.ts b/src/Rooms.ts
index b27d00e804..6e2fd4d3a2 100644
--- a/src/Rooms.ts
+++ b/src/Rooms.ts
@@ -84,10 +84,8 @@ export function guessAndSetDMRoom(room: Room, isDirect: boolean): Promise
                    this room as a DM room
  * @returns {object} A promise
  */
-export function setDMRoom(roomId: string, userId: string): Promise {
-    if (MatrixClientPeg.get().isGuest()) {
-        return Promise.resolve();
-    }
+export async function setDMRoom(roomId: string, userId: string): Promise {
+    if (MatrixClientPeg.get().isGuest()) return;
 
     const mDirectEvent = MatrixClientPeg.get().getAccountData('m.direct');
     let dmRoomMap = {};
@@ -116,7 +114,7 @@ export function setDMRoom(roomId: string, userId: string): Promise {
         dmRoomMap[userId] = roomList;
     }
 
-    return MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap);
+    await MatrixClientPeg.get().setAccountData('m.direct', dmRoomMap);
 }
 
 /**
diff --git a/src/Searching.js b/src/Searching.ts
similarity index 79%
rename from src/Searching.js
rename to src/Searching.ts
index d0666b1760..37f85efa77 100644
--- a/src/Searching.js
+++ b/src/Searching.ts
@@ -1,5 +1,5 @@
 /*
-Copyright 2019 The Matrix.org Foundation C.I.C.
+Copyright 2019 - 2021 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,26 +14,42 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+import {
+    IResultRoomEvents,
+    ISearchRequestBody,
+    ISearchResponse,
+    ISearchResult,
+    ISearchResults,
+    SearchOrderBy,
+} from "matrix-js-sdk/src/@types/search";
+import { IRoomEventFilter } from "matrix-js-sdk/src/filter";
+import { EventType } from "matrix-js-sdk/src/@types/event";
+
+import { ISearchArgs } from "./indexing/BaseEventIndexManager";
 import EventIndexPeg from "./indexing/EventIndexPeg";
 import { MatrixClientPeg } from "./MatrixClientPeg";
+import { SearchResult } from "matrix-js-sdk/src/models/search-result";
 
 const SEARCH_LIMIT = 10;
 
-async function serverSideSearch(term, roomId = undefined) {
+async function serverSideSearch(
+    term: string,
+    roomId: string = undefined,
+): Promise<{ response: ISearchResponse, query: ISearchRequestBody }> {
     const client = MatrixClientPeg.get();
 
-    const filter = {
+    const filter: IRoomEventFilter = {
         limit: SEARCH_LIMIT,
     };
 
     if (roomId !== undefined) filter.rooms = [roomId];
 
-    const body = {
+    const body: ISearchRequestBody = {
         search_categories: {
             room_events: {
                 search_term: term,
                 filter: filter,
-                order_by: "recent",
+                order_by: SearchOrderBy.Recent,
                 event_context: {
                     before_limit: 1,
                     after_limit: 1,
@@ -45,31 +61,26 @@ async function serverSideSearch(term, roomId = undefined) {
 
     const response = await client.search({ body: body });
 
-    const result = {
-        response: response,
-        query: body,
-    };
-
-    return result;
+    return { response, query: body };
 }
 
-async function serverSideSearchProcess(term, roomId = undefined) {
+async function serverSideSearchProcess(term: string, roomId: string = undefined): Promise {
     const client = MatrixClientPeg.get();
     const result = await serverSideSearch(term, roomId);
 
     // The js-sdk method backPaginateRoomEventsSearch() uses _query internally
-    // so we're reusing the concept here since we wan't to delegate the
+    // so we're reusing the concept here since we want to delegate the
     // pagination back to backPaginateRoomEventsSearch() in some cases.
-    const searchResult = {
+    const searchResults: ISearchResults = {
         _query: result.query,
         results: [],
         highlights: [],
     };
 
-    return client.processRoomEventsSearch(searchResult, result.response);
+    return client.processRoomEventsSearch(searchResults, result.response);
 }
 
-function compareEvents(a, b) {
+function compareEvents(a: ISearchResult, b: ISearchResult): number {
     const aEvent = a.result;
     const bEvent = b.result;
 
@@ -79,7 +90,7 @@ function compareEvents(a, b) {
     return 0;
 }
 
-async function combinedSearch(searchTerm) {
+async function combinedSearch(searchTerm: string): Promise {
     const client = MatrixClientPeg.get();
 
     // Create two promises, one for the local search, one for the
@@ -111,10 +122,10 @@ async function combinedSearch(searchTerm) {
     // returns since that one can be either a server-side one, a local one or a
     // fake one to fetch the remaining cached events. See the docs for
     // combineEvents() for an explanation why we need to cache events.
-    const emptyResult = {
+    const emptyResult: ISeshatSearchResults = {
         seshatQuery: localQuery,
         _query: serverQuery,
-        serverSideNextBatch: serverResponse.next_batch,
+        serverSideNextBatch: serverResponse.search_categories.room_events.next_batch,
         cachedEvents: [],
         oldestEventFrom: "server",
         results: [],
@@ -125,7 +136,7 @@ async function combinedSearch(searchTerm) {
     const combinedResult = combineResponses(emptyResult, localResponse, serverResponse.search_categories.room_events);
 
     // Let the client process the combined result.
-    const response = {
+    const response: ISearchResponse = {
         search_categories: {
             room_events: combinedResult,
         },
@@ -139,10 +150,14 @@ async function combinedSearch(searchTerm) {
     return result;
 }
 
-async function localSearch(searchTerm, roomId = undefined, processResult = true) {
+async function localSearch(
+    searchTerm: string,
+    roomId: string = undefined,
+    processResult = true,
+): Promise<{ response: IResultRoomEvents, query: ISearchArgs }> {
     const eventIndex = EventIndexPeg.get();
 
-    const searchArgs = {
+    const searchArgs: ISearchArgs = {
         search_term: searchTerm,
         before_limit: 1,
         after_limit: 1,
@@ -167,11 +182,18 @@ async function localSearch(searchTerm, roomId = undefined, processResult = true)
     return result;
 }
 
-async function localSearchProcess(searchTerm, roomId = undefined) {
+export interface ISeshatSearchResults extends ISearchResults {
+    seshatQuery?: ISearchArgs;
+    cachedEvents?: ISearchResult[];
+    oldestEventFrom?: "local" | "server";
+    serverSideNextBatch?: string;
+}
+
+async function localSearchProcess(searchTerm: string, roomId: string = undefined): Promise {
     const emptyResult = {
         results: [],
         highlights: [],
-    };
+    } as ISeshatSearchResults;
 
     if (searchTerm === "") return emptyResult;
 
@@ -179,7 +201,7 @@ async function localSearchProcess(searchTerm, roomId = undefined) {
 
     emptyResult.seshatQuery = result.query;
 
-    const response = {
+    const response: ISearchResponse = {
         search_categories: {
             room_events: result.response,
         },
@@ -192,7 +214,7 @@ async function localSearchProcess(searchTerm, roomId = undefined) {
     return processedResult;
 }
 
-async function localPagination(searchResult) {
+async function localPagination(searchResult: ISeshatSearchResults): Promise {
     const eventIndex = EventIndexPeg.get();
 
     const searchArgs = searchResult.seshatQuery;
@@ -221,10 +243,10 @@ async function localPagination(searchResult) {
     return result;
 }
 
-function compareOldestEvents(firstResults, secondResults) {
+function compareOldestEvents(firstResults: ISearchResult[], secondResults: ISearchResult[]): number {
     try {
-        const oldestFirstEvent = firstResults.results[firstResults.results.length - 1].result;
-        const oldestSecondEvent = secondResults.results[secondResults.results.length - 1].result;
+        const oldestFirstEvent = firstResults[firstResults.length - 1].result;
+        const oldestSecondEvent = secondResults[secondResults.length - 1].result;
 
         if (oldestFirstEvent.origin_server_ts <= oldestSecondEvent.origin_server_ts) {
             return -1;
@@ -236,7 +258,12 @@ function compareOldestEvents(firstResults, secondResults) {
     }
 }
 
-function combineEventSources(previousSearchResult, response, a, b) {
+function combineEventSources(
+    previousSearchResult: ISeshatSearchResults,
+    response: IResultRoomEvents,
+    a: ISearchResult[],
+    b: ISearchResult[],
+): void {
     // Merge event sources and sort the events.
     const combinedEvents = a.concat(b).sort(compareEvents);
     // Put half of the events in the response, and cache the other half.
@@ -353,8 +380,12 @@ function combineEventSources(previousSearchResult, response, a, b) {
  * different event sources.
  *
  */
-function combineEvents(previousSearchResult, localEvents = undefined, serverEvents = undefined) {
-    const response = {};
+function combineEvents(
+    previousSearchResult: ISeshatSearchResults,
+    localEvents: IResultRoomEvents = undefined,
+    serverEvents: IResultRoomEvents = undefined,
+): IResultRoomEvents {
+    const response = {} as IResultRoomEvents;
 
     const cachedEvents = previousSearchResult.cachedEvents;
     let oldestEventFrom = previousSearchResult.oldestEventFrom;
@@ -364,7 +395,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
         // This is a first search call, combine the events from the server and
         // the local index. Note where our oldest event came from, we shall
         // fetch the next batch of events from the other source.
-        if (compareOldestEvents(localEvents, serverEvents) < 0) {
+        if (compareOldestEvents(localEvents.results, serverEvents.results) < 0) {
             oldestEventFrom = "local";
         }
 
@@ -375,7 +406,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
         // meaning that our oldest event was on the server.
         // Change the source of the oldest event if our local event is older
         // than the cached one.
-        if (compareOldestEvents(localEvents, cachedEvents) < 0) {
+        if (compareOldestEvents(localEvents.results, cachedEvents) < 0) {
             oldestEventFrom = "local";
         }
         combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents);
@@ -384,7 +415,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
         // meaning that our oldest event was in the local index.
         // Change the source of the oldest event if our server event is older
         // than the cached one.
-        if (compareOldestEvents(serverEvents, cachedEvents) < 0) {
+        if (compareOldestEvents(serverEvents.results, cachedEvents) < 0) {
             oldestEventFrom = "server";
         }
         combineEventSources(previousSearchResult, response, serverEvents.results, cachedEvents);
@@ -412,7 +443,11 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven
  * @return {object} A response object that combines the events from the
  * different event sources.
  */
-function combineResponses(previousSearchResult, localEvents = undefined, serverEvents = undefined) {
+function combineResponses(
+    previousSearchResult: ISeshatSearchResults,
+    localEvents: IResultRoomEvents = undefined,
+    serverEvents: IResultRoomEvents = undefined,
+): IResultRoomEvents {
     // Combine our events first.
     const response = combineEvents(previousSearchResult, localEvents, serverEvents);
 
@@ -454,42 +489,51 @@ function combineResponses(previousSearchResult, localEvents = undefined, serverE
     return response;
 }
 
-function restoreEncryptionInfo(searchResultSlice = []) {
+interface IEncryptedSeshatEvent {
+    curve25519Key: string;
+    ed25519Key: string;
+    algorithm: string;
+    forwardingCurve25519KeyChain: string[];
+}
+
+function restoreEncryptionInfo(searchResultSlice: SearchResult[] = []): void {
     for (let i = 0; i < searchResultSlice.length; i++) {
         const timeline = searchResultSlice[i].context.getTimeline();
 
         for (let j = 0; j < timeline.length; j++) {
-            const ev = timeline[j];
+            const mxEv = timeline[j];
+            const ev = mxEv.event as IEncryptedSeshatEvent;
 
-            if (ev.event.curve25519Key) {
-                ev.makeEncrypted(
-                    "m.room.encrypted",
-                    { algorithm: ev.event.algorithm },
-                    ev.event.curve25519Key,
-                    ev.event.ed25519Key,
+            if (ev.curve25519Key) {
+                mxEv.makeEncrypted(
+                    EventType.RoomMessageEncrypted,
+                    { algorithm: ev.algorithm },
+                    ev.curve25519Key,
+                    ev.ed25519Key,
                 );
-                ev.forwardingCurve25519KeyChain = ev.event.forwardingCurve25519KeyChain;
+                // @ts-ignore
+                mxEv.forwardingCurve25519KeyChain = ev.forwardingCurve25519KeyChain;
 
-                delete ev.event.curve25519Key;
-                delete ev.event.ed25519Key;
-                delete ev.event.algorithm;
-                delete ev.event.forwardingCurve25519KeyChain;
+                delete ev.curve25519Key;
+                delete ev.ed25519Key;
+                delete ev.algorithm;
+                delete ev.forwardingCurve25519KeyChain;
             }
         }
     }
 }
 
-async function combinedPagination(searchResult) {
+async function combinedPagination(searchResult: ISeshatSearchResults): Promise {
     const eventIndex = EventIndexPeg.get();
     const client = MatrixClientPeg.get();
 
     const searchArgs = searchResult.seshatQuery;
     const oldestEventFrom = searchResult.oldestEventFrom;
 
-    let localResult;
-    let serverSideResult;
+    let localResult: IResultRoomEvents;
+    let serverSideResult: ISearchResponse;
 
-    // Fetch events from the local index if we have a token for itand if it's
+    // Fetch events from the local index if we have a token for it and if it's
     // the local indexes turn or the server has exhausted its results.
     if (searchArgs.next_batch && (!searchResult.serverSideNextBatch || oldestEventFrom === "server")) {
         localResult = await eventIndex.search(searchArgs);
@@ -502,7 +546,7 @@ async function combinedPagination(searchResult) {
         serverSideResult = await client.search(body);
     }
 
-    let serverEvents;
+    let serverEvents: IResultRoomEvents;
 
     if (serverSideResult) {
         serverEvents = serverSideResult.search_categories.room_events;
@@ -532,8 +576,8 @@ async function combinedPagination(searchResult) {
     return result;
 }
 
-function eventIndexSearch(term, roomId = undefined) {
-    let searchPromise;
+function eventIndexSearch(term: string, roomId: string = undefined): Promise {
+    let searchPromise: Promise;
 
     if (roomId !== undefined) {
         if (MatrixClientPeg.get().isRoomEncrypted(roomId)) {
@@ -554,7 +598,7 @@ function eventIndexSearch(term, roomId = undefined) {
     return searchPromise;
 }
 
-function eventIndexSearchPagination(searchResult) {
+function eventIndexSearchPagination(searchResult: ISeshatSearchResults): Promise {
     const client = MatrixClientPeg.get();
 
     const seshatQuery = searchResult.seshatQuery;
@@ -580,7 +624,7 @@ function eventIndexSearchPagination(searchResult) {
     }
 }
 
-export function searchPagination(searchResult) {
+export function searchPagination(searchResult: ISearchResults): Promise {
     const eventIndex = EventIndexPeg.get();
     const client = MatrixClientPeg.get();
 
@@ -590,7 +634,7 @@ export function searchPagination(searchResult) {
     else return eventIndexSearchPagination(searchResult);
 }
 
-export default function eventSearch(term, roomId = undefined) {
+export default function eventSearch(term: string, roomId: string = undefined): Promise {
     const eventIndex = EventIndexPeg.get();
 
     if (eventIndex === null) return serverSideSearchProcess(term, roomId);
diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx
index ef24fb8e48..95341705bf 100644
--- a/src/TextForEvent.tsx
+++ b/src/TextForEvent.tsx
@@ -32,7 +32,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
 // any text to display at all. For this reason they return deferred values
 // to avoid the expense of looking up translations when they're not needed.
 
-function textForMemberEvent(ev): () => string | null {
+function textForMemberEvent(ev: MatrixEvent): () => string | null {
     // XXX: SYJS-16 "sender is sometimes null for join messages"
     const senderName = ev.sender ? ev.sender.name : ev.getSender();
     const targetName = ev.target ? ev.target.name : ev.getStateKey();
@@ -127,7 +127,7 @@ function textForMemberEvent(ev): () => string | null {
     }
 }
 
-function textForTopicEvent(ev): () => string | null {
+function textForTopicEvent(ev: MatrixEvent): () => string | null {
     const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
     return () => _t('%(senderDisplayName)s changed the topic to "%(topic)s".', {
         senderDisplayName,
@@ -135,7 +135,7 @@ function textForTopicEvent(ev): () => string | null {
     });
 }
 
-function textForRoomNameEvent(ev): () => string | null {
+function textForRoomNameEvent(ev: MatrixEvent): () => string | null {
     const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
 
     if (!ev.getContent().name || ev.getContent().name.trim().length === 0) {
@@ -154,12 +154,12 @@ function textForRoomNameEvent(ev): () => string | null {
     });
 }
 
-function textForTombstoneEvent(ev): () => string | null {
+function textForTombstoneEvent(ev: MatrixEvent): () => string | null {
     const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
     return () => _t('%(senderDisplayName)s upgraded this room.', { senderDisplayName });
 }
 
-function textForJoinRulesEvent(ev): () => string | null {
+function textForJoinRulesEvent(ev: MatrixEvent): () => string | null {
     const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
     switch (ev.getContent().join_rule) {
         case "public":
@@ -179,7 +179,7 @@ function textForJoinRulesEvent(ev): () => string | null {
     }
 }
 
-function textForGuestAccessEvent(ev): () => string | null {
+function textForGuestAccessEvent(ev: MatrixEvent): () => string | null {
     const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
     switch (ev.getContent().guest_access) {
         case "can_join":
@@ -195,7 +195,7 @@ function textForGuestAccessEvent(ev): () => string | null {
     }
 }
 
-function textForRelatedGroupsEvent(ev): () => string | null {
+function textForRelatedGroupsEvent(ev: MatrixEvent): () => string | null {
     const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
     const groups = ev.getContent().groups || [];
     const prevGroups = ev.getPrevContent().groups || [];
@@ -225,7 +225,7 @@ function textForRelatedGroupsEvent(ev): () => string | null {
     }
 }
 
-function textForServerACLEvent(ev): () => string | null {
+function textForServerACLEvent(ev: MatrixEvent): () => string | null {
     const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
     const prevContent = ev.getPrevContent();
     const current = ev.getContent();
@@ -255,7 +255,7 @@ function textForServerACLEvent(ev): () => string | null {
     return getText;
 }
 
-function textForMessageEvent(ev): () => string | null {
+function textForMessageEvent(ev: MatrixEvent): () => string | null {
     return () => {
         const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
         let message = senderDisplayName + ': ' + ev.getContent().body;
@@ -268,7 +268,7 @@ function textForMessageEvent(ev): () => string | null {
     };
 }
 
-function textForCanonicalAliasEvent(ev): () => string | null {
+function textForCanonicalAliasEvent(ev: MatrixEvent): () => string | null {
     const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
     const oldAlias = ev.getPrevContent().alias;
     const oldAltAliases = ev.getPrevContent().alt_aliases || [];
@@ -682,7 +682,7 @@ for (const evType of ALL_RULE_TYPES) {
     stateHandlers[evType] = textForMjolnirEvent;
 }
 
-export function hasText(ev): boolean {
+export function hasText(ev: MatrixEvent): boolean {
     const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
     return Boolean(handler?.(ev));
 }
diff --git a/src/autocomplete/Autocompleter.ts b/src/autocomplete/Autocompleter.ts
index 7ab2ae70ea..acc7846510 100644
--- a/src/autocomplete/Autocompleter.ts
+++ b/src/autocomplete/Autocompleter.ts
@@ -27,8 +27,8 @@ import EmojiProvider from './EmojiProvider';
 import NotifProvider from './NotifProvider';
 import { timeout } from "../utils/promise";
 import AutocompleteProvider, { ICommand } from "./AutocompleteProvider";
-import SettingsStore from "../settings/SettingsStore";
 import SpaceProvider from "./SpaceProvider";
+import SpaceStore from "../stores/SpaceStore";
 
 export interface ISelectionRange {
     beginning?: boolean; // whether the selection is in the first block of the editor or not
@@ -58,8 +58,7 @@ const PROVIDERS = [
     DuckDuckGoProvider,
 ];
 
-// as the spaces feature is device configurable only, and toggling it refreshes the page, we can do this here
-if (SettingsStore.getValue("feature_spaces")) {
+if (SpaceStore.spacesEnabled) {
     PROVIDERS.push(SpaceProvider);
 } else {
     PROVIDERS.push(CommunityProvider);
diff --git a/src/autocomplete/RoomProvider.tsx b/src/autocomplete/RoomProvider.tsx
index 7865a76daa..37ddf2c387 100644
--- a/src/autocomplete/RoomProvider.tsx
+++ b/src/autocomplete/RoomProvider.tsx
@@ -28,7 +28,7 @@ import { PillCompletion } from './Components';
 import { makeRoomPermalink } from "../utils/permalinks/Permalinks";
 import { ICompletion, ISelectionRange } from "./Autocompleter";
 import RoomAvatar from '../components/views/avatars/RoomAvatar';
-import SettingsStore from "../settings/SettingsStore";
+import SpaceStore from "../stores/SpaceStore";
 
 const ROOM_REGEX = /\B#\S*/g;
 
@@ -59,7 +59,8 @@ export default class RoomProvider extends AutocompleteProvider {
         const cli = MatrixClientPeg.get();
         let rooms = cli.getVisibleRooms();
 
-        if (SettingsStore.getValue("feature_spaces")) {
+        // if spaces are enabled then filter them out here as they get their own autocomplete provider
+        if (SpaceStore.spacesEnabled) {
             rooms = rooms.filter(r => !r.isSpaceRoom());
         }
 
diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js
index 9ff830f66a..61ae1882df 100644
--- a/src/components/structures/InteractiveAuth.js
+++ b/src/components/structures/InteractiveAuth.js
@@ -54,7 +54,7 @@ export default class InteractiveAuthComponent extends React.Component {
         //      * emailSid {string} If email auth was performed, the sid of
         //            the auth session.
         //      * clientSecret {string} The client secret used in auth
-        //            sessions with the ID server.
+        //            sessions with the identity server.
         onAuthFinished: PropTypes.func.isRequired,
 
         // Inputs provided by the user to the auth process
diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx
index 89fa8db376..6c086ed17c 100644
--- a/src/components/structures/LoggedInView.tsx
+++ b/src/components/structures/LoggedInView.tsx
@@ -63,6 +63,7 @@ import ToastContainer from './ToastContainer';
 import MyGroups from "./MyGroups";
 import UserView from "./UserView";
 import GroupView from "./GroupView";
+import SpaceStore from "../../stores/SpaceStore";
 
 // We need to fetch each pinned message individually (if we don't already have it)
 // so each pinned message may trigger a request. Limit the number per room for sanity.
@@ -631,7 +632,7 @@ class LoggedInView extends React.Component {
                 >
                     
                     
- { SettingsStore.getValue("feature_spaces") ? : null } + { SpaceStore.spacesEnabled ? : null } { private pageChanging: boolean; private tokenLogin?: boolean; private accountPassword?: string; - private accountPasswordTimer?: NodeJS.Timeout; + private accountPasswordTimer?: number; private focusComposer: boolean; private subTitleStatus: string; private prevWindowWidth: number; @@ -561,7 +561,7 @@ export default class MatrixChat extends React.PureComponent { switch (payload.action) { case 'MatrixActions.accountData': // XXX: This is a collection of several hacks to solve a minor problem. We want to - // update our local state when the ID server changes, but don't want to put that in + // update our local state when the identity server changes, but don't want to put that in // the js-sdk as we'd be then dictating how all consumers need to behave. However, // this component is already bloated and we probably don't want this tiny logic in // here, but there's no better place in the react-sdk for it. Additionally, we're @@ -1099,7 +1099,7 @@ export default class MatrixChat extends React.PureComponent { private leaveRoomWarnings(roomId: string) { const roomToLeave = MatrixClientPeg.get().getRoom(roomId); - const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom(); + const isSpace = SpaceStore.spacesEnabled && roomToLeave?.isSpaceRoom(); // Show a warning if there are additional complications. const warnings = []; @@ -1137,7 +1137,7 @@ export default class MatrixChat extends React.PureComponent { const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const warnings = this.leaveRoomWarnings(roomId); - const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom(); + const isSpace = SpaceStore.spacesEnabled && roomToLeave?.isSpaceRoom(); Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, { title: isSpace ? _t("Leave space") : _t("Leave room"), description: ( @@ -1687,7 +1687,7 @@ export default class MatrixChat extends React.PureComponent { const type = screen === "start_sso" ? "sso" : "cas"; PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin()); } else if (screen === 'groups') { - if (SettingsStore.getValue("feature_spaces")) { + if (SpaceStore.spacesEnabled) { dis.dispatch({ action: "view_home_page" }); return; } @@ -1774,7 +1774,7 @@ export default class MatrixChat extends React.PureComponent { subAction: params.action, }); } else if (screen.indexOf('group/') === 0) { - if (SettingsStore.getValue("feature_spaces")) { + if (SpaceStore.spacesEnabled) { dis.dispatch({ action: "view_home_page" }); return; } diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index 63027ab627..2a3448b017 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -48,6 +48,7 @@ import NotificationPanel from "./NotificationPanel"; import ResizeNotifier from "../../utils/ResizeNotifier"; import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard"; import { throttle } from 'lodash'; +import SpaceStore from "../../stores/SpaceStore"; interface IProps { room?: Room; // if showing panels for a given room, this is set @@ -107,7 +108,7 @@ export default class RightPanel extends React.Component { return RightPanelPhases.GroupMemberList; } return rps.groupPanelPhase; - } else if (SettingsStore.getValue("feature_spaces") && this.props.room?.isSpaceRoom() + } else if (SpaceStore.spacesEnabled && this.props.room?.isSpaceRoom() && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase) ) { return RightPanelPhases.SpaceMemberList; diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx index b1974d6c0a..aa5baaf8c2 100644 --- a/src/components/structures/RoomDirectory.tsx +++ b/src/components/structures/RoomDirectory.tsx @@ -16,6 +16,9 @@ limitations under the License. */ import React from "react"; +import { IFieldType, IInstance, IProtocol, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client"; +import { Visibility } from "matrix-js-sdk/src/@types/partials"; +import { IRoomDirectoryOptions } from "matrix-js-sdk/src/@types/requests"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import dis from "../../dispatcher/dispatcher"; @@ -25,7 +28,7 @@ import { _t } from '../../languageHandler'; import SdkConfig from '../../SdkConfig'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import Analytics from '../../Analytics'; -import { ALL_ROOMS, IFieldType, IInstance, IProtocol, Protocols } from "../views/directory/NetworkDropdown"; +import NetworkDropdown, { ALL_ROOMS, Protocols } from "../views/directory/NetworkDropdown"; import SettingsStore from "../../settings/SettingsStore"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; import GroupStore from "../../stores/GroupStore"; @@ -40,7 +43,6 @@ import ErrorDialog from "../views/dialogs/ErrorDialog"; import QuestionDialog from "../views/dialogs/QuestionDialog"; import BaseDialog from "../views/dialogs/BaseDialog"; import DirectorySearchBox from "../views/elements/DirectorySearchBox"; -import NetworkDropdown from "../views/directory/NetworkDropdown"; import ScrollPanel from "./ScrollPanel"; import Spinner from "../views/elements/Spinner"; import { ActionPayload } from "../../dispatcher/payloads"; @@ -61,7 +63,7 @@ interface IProps extends IDialogProps { } interface IState { - publicRooms: IRoom[]; + publicRooms: IPublicRoomsChunkRoom[]; loading: boolean; protocolsLoading: boolean; error?: string; @@ -72,35 +74,12 @@ interface IState { communityName?: string; } -/* eslint-disable camelcase */ -interface IRoom { - room_id: string; - name?: string; - avatar_url?: string; - topic?: string; - canonical_alias?: string; - aliases?: string[]; - world_readable: boolean; - guest_can_join: boolean; - num_joined_members: number; -} - -interface IPublicRoomsRequest { - limit?: number; - since?: string; - server?: string; - filter?: object; - include_all_networks?: boolean; - third_party_instance_id?: string; -} -/* eslint-enable camelcase */ - @replaceableComponent("structures.RoomDirectory") export default class RoomDirectory extends React.Component { private readonly startTime: number; private unmounted = false; private nextBatch: string = null; - private filterTimeout: NodeJS.Timeout; + private filterTimeout: number; private protocols: Protocols; constructor(props) { @@ -253,7 +232,7 @@ export default class RoomDirectory extends React.Component { // remember the next batch token when we sent the request // too. If it's changed, appending to the list will corrupt it. const nextBatch = this.nextBatch; - const opts: IPublicRoomsRequest = { limit: 20 }; + const opts: IRoomDirectoryOptions = { limit: 20 }; if (roomServer != MatrixClientPeg.getHomeserverName()) { opts.server = roomServer; } @@ -326,7 +305,7 @@ export default class RoomDirectory extends React.Component { * HS admins to do this through the RoomSettings interface, but * this needs SPEC-417. */ - private removeFromDirectory(room: IRoom) { + private removeFromDirectory(room: IPublicRoomsChunkRoom) { const alias = getDisplayAliasForRoom(room); const name = room.name || alias || _t('Unnamed room'); @@ -346,7 +325,7 @@ export default class RoomDirectory extends React.Component { const modal = Modal.createDialog(Spinner); let step = _t('remove %(name)s from the directory.', { name: name }); - MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => { + MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, Visibility.Private).then(() => { if (!alias) return; step = _t('delete the address.'); return MatrixClientPeg.get().deleteAlias(alias); @@ -368,7 +347,7 @@ export default class RoomDirectory extends React.Component { }); } - private onRoomClicked = (room: IRoom, ev: ButtonEvent) => { + private onRoomClicked = (room: IPublicRoomsChunkRoom, ev: ButtonEvent) => { // If room was shift-clicked, remove it from the room directory if (ev.shiftKey && !this.state.selectedCommunityId) { ev.preventDefault(); @@ -481,17 +460,17 @@ export default class RoomDirectory extends React.Component { } }; - private onPreviewClick = (ev: ButtonEvent, room: IRoom) => { + private onPreviewClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => { this.showRoom(room, null, false, true); ev.stopPropagation(); }; - private onViewClick = (ev: ButtonEvent, room: IRoom) => { + private onViewClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => { this.showRoom(room); ev.stopPropagation(); }; - private onJoinClick = (ev: ButtonEvent, room: IRoom) => { + private onJoinClick = (ev: ButtonEvent, room: IPublicRoomsChunkRoom) => { this.showRoom(room, null, true); ev.stopPropagation(); }; @@ -509,7 +488,7 @@ export default class RoomDirectory extends React.Component { this.showRoom(null, alias, autoJoin); } - private showRoom(room: IRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) { + private showRoom(room: IPublicRoomsChunkRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) { this.onFinished(); const payload: ActionPayload = { action: 'view_room', @@ -558,7 +537,7 @@ export default class RoomDirectory extends React.Component { dis.dispatch(payload); } - private createRoomCells(room: IRoom) { + private createRoomCells(room: IPublicRoomsChunkRoom) { const client = MatrixClientPeg.get(); const clientRoom = client.getRoom(room.room_id); const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join"; @@ -854,6 +833,6 @@ export default class RoomDirectory extends React.Component { // Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom // but works with the objects we get from the public room list -function getDisplayAliasForRoom(room: IRoom) { +function getDisplayAliasForRoom(room: IPublicRoomsChunkRoom) { return getDisplayAliasForAliasSet(room.canonical_alias, room.aliases); } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 8e0b8a5f4a..2fe694a435 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -25,8 +25,8 @@ import React, { createRef } from 'react'; import classNames from 'classnames'; import { IRecommendedVersion, NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { SearchResult } from "matrix-js-sdk/src/models/search-result"; import { EventSubscription } from "fbemitter"; +import { ISearchResults } from 'matrix-js-sdk/src/@types/search'; import shouldHideEvent from '../../shouldHideEvent'; import { _t } from '../../languageHandler'; @@ -89,6 +89,7 @@ import RoomStatusBar from "./RoomStatusBar"; import MessageComposer from '../views/rooms/MessageComposer'; import JumpToBottomButton from "../views/rooms/JumpToBottomButton"; import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar"; +import SpaceStore from "../../stores/SpaceStore"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -133,12 +134,7 @@ export interface IState { searching: boolean; searchTerm?: string; searchScope?: SearchScope; - searchResults?: XOR<{}, { - count: number; - highlights: string[]; - results: SearchResult[]; - next_batch: string; // eslint-disable-line camelcase - }>; + searchResults?: XOR<{}, ISearchResults>; searchHighlights?: string[]; searchInProgress?: boolean; callState?: CallState; @@ -921,6 +917,7 @@ export default class RoomView extends React.Component { // called when state.room is first initialised (either at initial load, // after a successful peek, or after we join the room). private onRoomLoaded = (room: Room) => { + if (this.unmounted) return; // Attach a widget store listener only when we get a room WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); this.onWidgetLayoutChange(); // provoke an update @@ -935,9 +932,9 @@ export default class RoomView extends React.Component { }; private async calculateRecommendedVersion(room: Room) { - this.setState({ - upgradeRecommendation: await room.getRecommendedVersion(), - }); + const upgradeRecommendation = await room.getRecommendedVersion(); + if (this.unmounted) return; + this.setState({ upgradeRecommendation }); } private async loadMembersIfJoined(room: Room) { @@ -1027,23 +1024,19 @@ export default class RoomView extends React.Component { }; private async updateE2EStatus(room: Room) { - if (!this.context.isRoomEncrypted(room.roomId)) { - return; - } - if (!this.context.isCryptoEnabled()) { - // If crypto is not currently enabled, we aren't tracking devices at all, - // so we don't know what the answer is. Let's error on the safe side and show - // a warning for this case. - this.setState({ - e2eStatus: E2EStatus.Warning, - }); - return; + if (!this.context.isRoomEncrypted(room.roomId)) return; + + // If crypto is not currently enabled, we aren't tracking devices at all, + // so we don't know what the answer is. Let's error on the safe side and show + // a warning for this case. + let e2eStatus = E2EStatus.Warning; + if (this.context.isCryptoEnabled()) { + /* At this point, the user has encryption on and cross-signing on */ + e2eStatus = await shieldStatusForRoom(this.context, room); } - /* At this point, the user has encryption on and cross-signing on */ - this.setState({ - e2eStatus: await shieldStatusForRoom(this.context, room), - }); + if (this.unmounted) return; + this.setState({ e2eStatus }); } private onAccountData = (event: MatrixEvent) => { @@ -1137,7 +1130,7 @@ export default class RoomView extends React.Component { if (this.state.searchResults.next_batch) { debuglog("requesting more search results"); - const searchPromise = searchPagination(this.state.searchResults); + const searchPromise = searchPagination(this.state.searchResults as ISearchResults); return this.handleSearchResult(searchPromise); } else { debuglog("no more search results"); @@ -1753,10 +1746,8 @@ export default class RoomView extends React.Component { } const myMembership = this.state.room.getMyMembership(); - if (myMembership === "invite" - // SpaceRoomView handles invites itself - && (!SettingsStore.getValue("feature_spaces") || !this.state.room.isSpaceRoom()) - ) { + // SpaceRoomView handles invites itself + if (myMembership === "invite" && (!SpaceStore.spacesEnabled || !this.state.room.isSpaceRoom())) { if (this.state.joining || this.state.rejecting) { return ( @@ -1887,7 +1878,7 @@ export default class RoomView extends React.Component { room={this.state.room} /> ); - if (!this.state.canPeek && (!SettingsStore.getValue("feature_spaces") || !this.state.room?.isSpaceRoom())) { + if (!this.state.canPeek && (!SpaceStore.spacesEnabled || !this.state.room?.isSpaceRoom())) { return (
{ previewBar } diff --git a/src/components/structures/ScrollPanel.tsx b/src/components/structures/ScrollPanel.tsx index df885575df..1d16755106 100644 --- a/src/components/structures/ScrollPanel.tsx +++ b/src/components/structures/ScrollPanel.tsx @@ -187,7 +187,7 @@ export default class ScrollPanel extends React.Component { private fillRequestWhileRunning: boolean; private scrollState: IScrollState; private preventShrinkingState: IPreventShrinkingState; - private unfillDebouncer: NodeJS.Timeout; + private unfillDebouncer: number; private bottomGrowth: number; private pages: number; private heightUpdateInProgress: boolean; diff --git a/src/components/structures/SpaceRoomDirectory.tsx b/src/components/structures/SpaceRoomDirectory.tsx index a08fbb5098..27539a5c3c 100644 --- a/src/components/structures/SpaceRoomDirectory.tsx +++ b/src/components/structures/SpaceRoomDirectory.tsx @@ -18,6 +18,7 @@ import React, { ReactNode, useMemo, useState } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; +import { ISpaceSummaryRoom, ISpaceSummaryEvent } from "matrix-js-sdk/src/@types/spaces"; import classNames from "classnames"; import { sortBy } from "lodash"; @@ -52,36 +53,6 @@ interface IHierarchyProps { showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void; } -/* eslint-disable camelcase */ -export interface ISpaceSummaryRoom { - canonical_alias?: string; - aliases: string[]; - avatar_url?: string; - guest_can_join: boolean; - name?: string; - num_joined_members: number; - room_id: string; - topic?: string; - world_readable: boolean; - num_refs: number; - room_type: string; -} - -export interface ISpaceSummaryEvent { - room_id: string; - event_id: string; - origin_server_ts: number; - type: string; - state_key: string; - content: { - order?: string; - suggested?: boolean; - auto_join?: boolean; - via?: string[]; - }; -} -/* eslint-enable camelcase */ - interface ITileProps { room: ISpaceSummaryRoom; suggested?: boolean; diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 24b460284f..0ee68a9578 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -62,7 +62,6 @@ import IconizedContextMenu, { import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { BetaPill } from "../views/beta/BetaCard"; import { UserTab } from "../views/dialogs/UserSettingsDialog"; -import SettingsStore from "../../settings/SettingsStore"; import Modal from "../../Modal"; import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog"; import SdkConfig from "../../SdkConfig"; @@ -178,7 +177,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => const [busy, setBusy] = useState(false); - const spacesEnabled = SettingsStore.getValue("feature_spaces"); + const spacesEnabled = SpaceStore.spacesEnabled; const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave && space.getJoinRule() !== JoinRule.Public; @@ -854,7 +853,7 @@ export default class SpaceRoomView extends React.PureComponent { private renderBody() { switch (this.state.phase) { case Phase.Landing: - if (this.state.myMembership === "join" && SettingsStore.getValue("feature_spaces")) { + if (this.state.myMembership === "join" && SpaceStore.spacesEnabled) { return ; } else { return void; } interface IState { @@ -62,6 +70,10 @@ export default class TabbedView extends React.Component { }; } + static defaultProps = { + tabLocation: TabLocation.LEFT, + }; + private _getActiveTabIndex() { if (!this.state || !this.state.activeTabIndex) return 0; return this.state.activeTabIndex; @@ -75,6 +87,7 @@ export default class TabbedView extends React.Component { private _setActiveTab(tab: Tab) { const idx = this.props.tabs.indexOf(tab); if (idx !== -1) { + if (this.props.onChange) this.props.onChange(tab.id); this.setState({ activeTabIndex: idx }); } else { console.error("Could not find tab " + tab.label + " in tabs"); @@ -119,8 +132,14 @@ export default class TabbedView extends React.Component { const labels = this.props.tabs.map(tab => this._renderTabLabel(tab)); const panel = this._renderTabPanel(this.props.tabs[this._getActiveTabIndex()]); + const tabbedViewClasses = classNames({ + 'mx_TabbedView': true, + 'mx_TabbedView_tabsOnLeft': this.props.tabLocation == TabLocation.LEFT, + 'mx_TabbedView_tabsOnTop': this.props.tabLocation == TabLocation.TOP, + }); + return ( -
+
{labels}
diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index bd90b637d6..e7352daf12 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -1055,6 +1055,8 @@ class TimelinePanel extends React.Component { { windowLimit: this.props.timelineCap }); const onLoaded = () => { + if (this.unmounted) return; + // clear the timeline min-height when // (re)loading the timeline if (this.messagePanel.current) { @@ -1096,6 +1098,8 @@ class TimelinePanel extends React.Component { }; const onError = (error) => { + if (this.unmounted) return; + this.setState({ timelineLoading: false }); console.error( `Error loading timeline panel at ${eventId}: ${error}`, diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index d85817486b..34575ba582 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -90,7 +90,7 @@ export default class UserMenu extends React.Component { }; OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); - if (SettingsStore.getValue("feature_spaces")) { + if (SpaceStore.spacesEnabled) { SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); } @@ -115,7 +115,7 @@ export default class UserMenu extends React.Component { if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); this.tagStoreRef.remove(); - if (SettingsStore.getValue("feature_spaces")) { + if (SpaceStore.spacesEnabled) { SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); } MatrixClientPeg.get().removeListener("Room", this.onRoom); diff --git a/src/components/views/audio_messages/RecordingPlayback.tsx b/src/components/views/audio_messages/RecordingPlayback.tsx index a0dea1c6db..7d9312f369 100644 --- a/src/components/views/audio_messages/RecordingPlayback.tsx +++ b/src/components/views/audio_messages/RecordingPlayback.tsx @@ -17,15 +17,18 @@ limitations under the License. import { Playback, PlaybackState } from "../../../voice/Playback"; import React, { ReactNode } from "react"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; -import PlaybackWaveform from "./PlaybackWaveform"; import PlayPauseButton from "./PlayPauseButton"; import PlaybackClock from "./PlaybackClock"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { TileShape } from "../rooms/EventTile"; +import PlaybackWaveform from "./PlaybackWaveform"; interface IProps { // Playback instance to render. Cannot change during component lifecycle: create // an all-new component instead. playback: Playback; + + tileShape?: TileShape; } interface IState { @@ -50,15 +53,22 @@ export default class RecordingPlayback extends React.PureComponent { this.setState({ playbackPhase: ev }); }; public render(): ReactNode { - return
+ const shapeClass = !this.isWaveformable ? 'mx_VoiceMessagePrimaryContainer_noWaveform' : ''; + return
- + { this.isWaveformable && }
; } } diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index 4b1ecec740..d9af2c2b77 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -41,7 +41,7 @@ import CaptchaForm from "./CaptchaForm"; * one HS whilst beign a guest on another). * loginType: the login type of the auth stage being attempted * authSessionId: session id from the server - * clientSecret: The client secret in use for ID server auth sessions + * clientSecret: The client secret in use for identity server auth sessions * stageParams: params from the server for the stage being attempted * errorText: error message from a previous attempt to authenticate * submitAuthDict: a function which will be called with the new auth dict @@ -54,8 +54,8 @@ import CaptchaForm from "./CaptchaForm"; * Defined keys for stages are: * m.login.email.identity: * * emailSid: string representing the sid of the active - * verification session from the ID server, or - * null if no session is active. + * verification session from the identity server, + * or null if no session is active. * fail: a function which should be called with an error object if an * error occurred during the auth stage. This will cause the auth * session to be failed and the process to go back to the start. diff --git a/src/components/views/beta/BetaCard.tsx b/src/components/views/beta/BetaCard.tsx index 3127e1a915..ec662d831b 100644 --- a/src/components/views/beta/BetaCard.tsx +++ b/src/components/views/beta/BetaCard.tsx @@ -105,7 +105,7 @@ const BetaCard = ({ title: titleOverride, featureId }: IProps) => {
- { extraSettings &&
+ { extraSettings && value &&
{ extraSettings.map(key => ( )) } diff --git a/src/components/views/context_menus/CallContextMenu.tsx b/src/components/views/context_menus/CallContextMenu.tsx index 428e18ed30..76e1670669 100644 --- a/src/components/views/context_menus/CallContextMenu.tsx +++ b/src/components/views/context_menus/CallContextMenu.tsx @@ -53,7 +53,7 @@ export default class CallContextMenu extends React.Component { onTransferClick = () => { Modal.createTrackedDialog( 'Transfer Call', '', InviteDialog, { kind: KIND_CALL_TRANSFER, call: this.props.call }, - /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, + /*className=*/"mx_InviteDialog_transferWrapper", /*isPriority=*/false, /*isStatic=*/true, ); this.props.onFinished(); }; diff --git a/src/components/views/context_menus/DialpadContextMenu.tsx b/src/components/views/context_menus/DialpadContextMenu.tsx index 28a73ba8d4..39dfd50795 100644 --- a/src/components/views/context_menus/DialpadContextMenu.tsx +++ b/src/components/views/context_menus/DialpadContextMenu.tsx @@ -15,11 +15,11 @@ limitations under the License. */ import React from 'react'; -import { _t } from '../../../languageHandler'; +import AccessibleButton from "../elements/AccessibleButton"; import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import Field from "../elements/Field"; -import Dialpad from '../voip/DialPad'; +import DialPad from '../voip/DialPad'; import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps extends IContextMenuProps { @@ -45,24 +45,29 @@ export default class DialpadContextMenu extends React.Component this.setState({ value: this.state.value + digit }); }; + onCancelClick = () => { + this.props.onFinished(); + }; + onChange = (ev) => { this.setState({ value: ev.target.value }); }; render() { return -
+
- {_t("Dial pad")} + +
+
+ +
+
+
- -
-
-
-
; } diff --git a/src/components/views/dialogs/ForwardDialog.tsx b/src/components/views/dialogs/ForwardDialog.tsx index ba06436ae2..839ca6da2f 100644 --- a/src/components/views/dialogs/ForwardDialog.tsx +++ b/src/components/views/dialogs/ForwardDialog.tsx @@ -43,6 +43,7 @@ import QueryMatcher from "../../../autocomplete/QueryMatcher"; import TruncatedList from "../elements/TruncatedList"; import EntityTile from "../rooms/EntityTile"; import BaseAvatar from "../avatars/BaseAvatar"; +import SpaceStore from "../../../stores/SpaceStore"; const AVATAR_SIZE = 30; @@ -180,7 +181,7 @@ const ForwardDialog: React.FC = ({ matrixClient: cli, event, permalinkCr const [query, setQuery] = useState(""); const lcQuery = query.toLowerCase(); - const spacesEnabled = useFeatureEnabled("feature_spaces"); + const spacesEnabled = SpaceStore.spacesEnabled; const flairEnabled = useFeatureEnabled(UIFeature.Flair); const previewLayout = useSettingValue("layout"); diff --git a/src/components/views/dialogs/IntegrationsImpossibleDialog.js b/src/components/views/dialogs/IntegrationsImpossibleDialog.js index 2cf9daa7ea..30b6904f27 100644 --- a/src/components/views/dialogs/IntegrationsImpossibleDialog.js +++ b/src/components/views/dialogs/IntegrationsImpossibleDialog.js @@ -46,7 +46,7 @@ export default class IntegrationsImpossibleDialog extends React.Component {

{_t( - "Your %(brand)s doesn't allow you to use an Integration Manager to do this. " + + "Your %(brand)s doesn't allow you to use an integration manager to do this. " + "Please contact an admin.", { brand }, )} diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index 1df5f35ae9..58bab511bf 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -32,7 +32,6 @@ import Modal from "../../../Modal"; import { humanizeTime } from "../../../utils/humanize"; import createRoom, { canEncryptToAllUsers, - ensureDMExists, findDMForUser, privateShouldBeEncrypted, } from "../../../createRoom"; @@ -64,9 +63,15 @@ import { copyPlaintext, selectText } from "../../../utils/strings"; import * as ContextMenu from "../../structures/ContextMenu"; import { toRightOf } from "../../structures/ContextMenu"; import GenericTextContextMenu from "../context_menus/GenericTextContextMenu"; +import { TransferCallPayload } from '../../../dispatcher/payloads/TransferCallPayload'; +import Field from '../elements/Field'; +import TabbedView, { Tab, TabLocation } from '../../structures/TabbedView'; +import Dialpad from '../voip/DialPad'; import QuestionDialog from "./QuestionDialog"; import Spinner from "../elements/Spinner"; import BaseDialog from "./BaseDialog"; +import DialPadBackspaceButton from "../elements/DialPadBackspaceButton"; +import SpaceStore from "../../../stores/SpaceStore"; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ @@ -79,11 +84,19 @@ interface IRecentUser { export const KIND_DM = "dm"; export const KIND_INVITE = "invite"; +// NB. This dialog needs the 'mx_InviteDialog_transferWrapper' wrapper class to have the correct +// padding on the bottom (because all modals have 24px padding on all sides), so this needs to +// be passed when creating the modal export const KIND_CALL_TRANSFER = "call_transfer"; const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked +enum TabId { + UserDirectory = 'users', + DialPad = 'dialpad', +} + // This is the interface that is expected by various components in the Invite Dialog and RoomInvite. // It is a bit awkward because it also matches the RoomMember class from the js-sdk with some extra support // for 3PIDs/email addresses. @@ -109,11 +122,11 @@ export abstract class Member { class DirectoryMember extends Member { private readonly _userId: string; - private readonly displayName: string; - private readonly avatarUrl: string; + private readonly displayName?: string; + private readonly avatarUrl?: string; // eslint-disable-next-line camelcase - constructor(userDirResult: { user_id: string, display_name: string, avatar_url: string }) { + constructor(userDirResult: { user_id: string, display_name?: string, avatar_url?: string }) { super(); this._userId = userDirResult.user_id; this.displayName = userDirResult.display_name; @@ -356,6 +369,8 @@ interface IInviteDialogState { canUseIdentityServer: boolean; tryingIdentityServer: boolean; consultFirst: boolean; + dialPadValue: string; + currentTabId: TabId; // These two flags are used for the 'Go' button to communicate what is going on. busy: boolean; @@ -370,7 +385,7 @@ export default class InviteDialog extends React.PureComponent void; - private debounceTimer: NodeJS.Timeout = null; // actually number because we're in the browser + private debounceTimer: number = null; // actually number because we're in the browser private editorRef = createRef(); private unmounted = false; @@ -407,6 +422,8 @@ export default class InviteDialog extends React.PureComponent { - this.convertFilter(); - const targets = this.convertFilter(); - const targetIds = targets.map(t => t.userId); - if (targetIds.length > 1) { - this.setState({ - errorText: _t("A call can only be transferred to a single user."), - }); - } - - if (this.state.consultFirst) { - const dmRoomId = await ensureDMExists(MatrixClientPeg.get(), targetIds[0]); - - dis.dispatch({ - action: 'place_call', - type: this.props.call.type, - room_id: dmRoomId, - transferee: this.props.call, - }); - dis.dispatch({ - action: 'view_room', - room_id: dmRoomId, - should_peek: false, - joining: false, - }); - this.props.onFinished(); - } else { - this.setState({ busy: true }); - try { - await this.props.call.transfer(targetIds[0]); - this.setState({ busy: false }); - this.props.onFinished(); - } catch (e) { + if (this.state.currentTabId == TabId.UserDirectory) { + this.convertFilter(); + const targets = this.convertFilter(); + const targetIds = targets.map(t => t.userId); + if (targetIds.length > 1) { this.setState({ - busy: false, - errorText: _t("Failed to transfer call"), + errorText: _t("A call can only be transferred to a single user."), }); + return; } + + dis.dispatch({ + action: Action.TransferCallToMatrixID, + call: this.props.call, + destination: targetIds[0], + consultFirst: this.state.consultFirst, + } as TransferCallPayload); + } else { + dis.dispatch({ + action: Action.TransferCallToPhoneNumber, + call: this.props.call, + destination: this.state.dialPadValue, + consultFirst: this.state.consultFirst, + } as TransferCallPayload); } + this.props.onFinished(); }; private onKeyDown = (e) => { @@ -827,6 +832,10 @@ export default class InviteDialog extends React.PureComponent { + this.props.onFinished([]); + }; + private updateSuggestions = async (term) => { MatrixClientPeg.get().searchUserDirectory({ term }).then(async r => { if (term !== this.state.filterText) { @@ -962,11 +971,14 @@ export default class InviteDialog extends React.PureComponent { if (!this.state.busy) { let filterText = this.state.filterText; - const targets = this.state.targets.map(t => t); // cheap clone for mutation + let targets = this.state.targets.map(t => t); // cheap clone for mutation const idx = targets.indexOf(member); if (idx >= 0) { targets.splice(idx, 1); } else { + if (this.props.kind === KIND_CALL_TRANSFER && targets.length > 0) { + targets = []; + } targets.push(member); filterText = ""; // clear the filter when the user accepts a suggestion } @@ -1189,6 +1201,11 @@ export default class InviteDialog extends React.PureComponent ( )); @@ -1201,8 +1218,9 @@ export default class InviteDialog extends React.PureComponent 0)} autoComplete="off" + placeholder={hasPlaceholder ? _t("Search") : null} /> ); return ( @@ -1249,6 +1267,28 @@ export default class InviteDialog extends React.PureComponent { + ev.preventDefault(); + this.transferCall(); + }; + + private onDialChange = ev => { + this.setState({ dialPadValue: ev.currentTarget.value }); + }; + + private onDigitPress = digit => { + this.setState({ dialPadValue: this.state.dialPadValue + digit }); + }; + + private onDeletePress = () => { + if (this.state.dialPadValue.length === 0) return; + this.setState({ dialPadValue: this.state.dialPadValue.slice(0, -1) }); + }; + + private onTabChange = (tabId: TabId) => { + this.setState({ currentTabId: tabId }); + }; + private async onLinkClick(e) { e.preventDefault(); selectText(e.target); @@ -1278,12 +1318,16 @@ export default class InviteDialog extends React.PureComponent; const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer); + const hasSelection = this.state.targets.length > 0 + || (this.state.filterText && this.state.filterText.includes('@')); + const cli = MatrixClientPeg.get(); const userId = cli.getUserId(); if (this.props.kind === KIND_DM) { @@ -1364,7 +1408,7 @@ export default class InviteDialog extends React.PureComponent; } else if (this.props.kind === KIND_INVITE) { const room = MatrixClientPeg.get()?.getRoom(this.props.roomId); - const isSpace = SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom(); + const isSpace = SpaceStore.spacesEnabled && room?.isSpaceRoom(); title = isSpace ? _t("Invite to %(spaceName)s", { spaceName: room.name || _t("Unnamed Space"), @@ -1421,23 +1465,116 @@ export default class InviteDialog extends React.PureComponent + + consultConnectSection =

+ + {_t("Cancel")} + + + {_t("Transfer")} +
; } else { console.error("Unknown kind of InviteDialog: " + this.props.kind); } - const hasSelection = this.state.targets.length > 0 - || (this.state.filterText && this.state.filterText.includes('@')); + const goButton = this.props.kind == KIND_CALL_TRANSFER ? null : + {buttonText} + ; + + const usersSection = +

{helpText}

+
+ {this.renderEditor()} +
+ {goButton} + {spinner} +
+
+ {keySharingWarning} + {this.renderIdentityServerWarning()} +
{this.state.errorText}
+
+ {this.renderSection('recents')} + {this.renderSection('suggestions')} + {extraSection} +
+ {footer} +
; + + let dialogContent; + if (this.props.kind === KIND_CALL_TRANSFER) { + const tabs = []; + tabs.push(new Tab( + TabId.UserDirectory, _td("User Directory"), 'mx_InviteDialog_userDirectoryIcon', usersSection, + )); + + const backspaceButton = ( + + ); + + // Only show the backspace button if the field has content + let dialPadField; + if (this.state.dialPadValue.length !== 0) { + dialPadField = ; + } else { + dialPadField = ; + } + + const dialPadSection =
+
+ {dialPadField} +
+ +
; + tabs.push(new Tab(TabId.DialPad, _td("Dial pad"), 'mx_InviteDialog_dialPadIcon', dialPadSection)); + dialogContent = + + {consultConnectSection} + ; + } else { + dialogContent = + {usersSection} + {consultConnectSection} + ; + } + return (
-

{helpText}

-
- {this.renderEditor()} -
- - {buttonText} - - {spinner} -
-
- {keySharingWarning} - {this.renderIdentityServerWarning()} -
{this.state.errorText}
-
- {this.renderSection('recents')} - {this.renderSection('suggestions')} - {extraSection} -
- {footer} + {dialogContent}
); diff --git a/src/components/views/dialogs/ShareDialog.tsx b/src/components/views/dialogs/ShareDialog.tsx index a3443ada02..85e9c6f192 100644 --- a/src/components/views/dialogs/ShareDialog.tsx +++ b/src/components/views/dialogs/ShareDialog.tsx @@ -35,7 +35,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import BaseDialog from "./BaseDialog"; -import GenericTextContextMenu from "../context_menus/GenericTextContextMenu.js"; +import GenericTextContextMenu from "../context_menus/GenericTextContextMenu"; const socials = [ { diff --git a/src/components/views/dialogs/TermsDialog.tsx b/src/components/views/dialogs/TermsDialog.tsx index afa732033f..58126f77c3 100644 --- a/src/components/views/dialogs/TermsDialog.tsx +++ b/src/components/views/dialogs/TermsDialog.tsx @@ -90,9 +90,9 @@ export default class TermsDialog extends React.PureComponent{_t("Identity Server")}
({host})
; + return
{_t("Identity server")}
({host})
; case SERVICE_TYPES.IM: - return
{_t("Integration Manager")}
({host})
; + return
{_t("Integration manager")}
({host})
; } } diff --git a/src/components/views/dialogs/VerificationRequestDialog.tsx b/src/components/views/dialogs/VerificationRequestDialog.tsx index 4d3123c274..65b7f71dbd 100644 --- a/src/components/views/dialogs/VerificationRequestDialog.tsx +++ b/src/components/views/dialogs/VerificationRequestDialog.tsx @@ -21,7 +21,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import BaseDialog from "./BaseDialog"; import EncryptionPanel from "../right_panel/EncryptionPanel"; -import { User } from 'matrix-js-sdk'; +import { User } from 'matrix-js-sdk/src/models/user'; interface IProps { verificationRequest: VerificationRequest; diff --git a/src/components/views/directory/NetworkDropdown.tsx b/src/components/views/directory/NetworkDropdown.tsx index 0492168f36..e4a967fbdc 100644 --- a/src/components/views/directory/NetworkDropdown.tsx +++ b/src/components/views/directory/NetworkDropdown.tsx @@ -17,6 +17,7 @@ limitations under the License. import React, { useEffect, useState } from "react"; import { MatrixError } from "matrix-js-sdk/src/http-api"; +import { IProtocol } from "matrix-js-sdk/src/client"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { instanceForInstanceId } from '../../../utils/DirectoryUtils'; @@ -83,30 +84,6 @@ const validServer = withValidation({ ], }); -/* eslint-disable camelcase */ -export interface IFieldType { - regexp: string; - placeholder: string; -} - -export interface IInstance { - desc: string; - icon?: string; - fields: object; - network_id: string; - // XXX: this is undocumented but we rely on it. - instance_id: string; -} - -export interface IProtocol { - user_fields: string[]; - location_fields: string[]; - icon: string; - field_types: Record; - instances: IInstance[]; -} -/* eslint-enable camelcase */ - export type Protocols = Record; interface IProps { diff --git a/src/components/views/elements/AppPermission.js b/src/components/views/elements/AppPermission.js index 152d3c6b95..c1f370b626 100644 --- a/src/components/views/elements/AppPermission.js +++ b/src/components/views/elements/AppPermission.js @@ -114,7 +114,7 @@ export default class AppPermission extends React.Component { // Due to i18n limitations, we can't dedupe the code for variables in these two messages. const warning = this.state.isWrapped - ? _t("Using this widget may share data with %(widgetDomain)s & your Integration Manager.", + ? _t("Using this widget may share data with %(widgetDomain)s & your integration manager.", { widgetDomain: this.state.widgetDomain }, { helpIcon: () => warningTooltip }) : _t("Using this widget may share data with %(widgetDomain)s.", { widgetDomain: this.state.widgetDomain }, { helpIcon: () => warningTooltip }); diff --git a/src/components/views/elements/DialPadBackspaceButton.tsx b/src/components/views/elements/DialPadBackspaceButton.tsx new file mode 100644 index 0000000000..69f0fcb39a --- /dev/null +++ b/src/components/views/elements/DialPadBackspaceButton.tsx @@ -0,0 +1,31 @@ +/* +Copyright 2021 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 * as React from "react"; +import AccessibleButton from "./AccessibleButton"; + +interface IProps { + // Callback for when the button is pressed + onBackspacePress: () => void; +} + +export default class DialPadBackspaceButton extends React.PureComponent { + render() { + return
+ +
; + } +} diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx index 35d9909f66..16263e5204 100644 --- a/src/components/views/elements/ImageView.tsx +++ b/src/components/views/elements/ImageView.tsx @@ -33,6 +33,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { normalizeWheelEvent } from "../../../utils/Mouse"; +import { IDialogProps } from '../dialogs/IDialogProps'; // Max scale to keep gaps around the image const MAX_SCALE = 0.95; @@ -43,14 +44,13 @@ const ZOOM_COEFFICIENT = 0.0025; // If we have moved only this much we can zoom const ZOOM_DISTANCE = 10; -interface IProps { +interface IProps extends IDialogProps { src: string; // the source of the image being displayed name?: string; // the main title ('name') for the image link?: string; // the link (if any) applied to the name of the image width?: number; // width of the image src in pixels height?: number; // height of the image src in pixels fileSize?: number; // size of the image src in bytes - onFinished(): void; // callback when the lightbox is dismissed // the event (if any) that the Image is displaying. Used for event-specific stuff like // redactions, senders, timestamps etc. Other descriptors are taken from the explicit diff --git a/src/components/views/elements/MiniAvatarUploader.tsx b/src/components/views/elements/MiniAvatarUploader.tsx index 83fc1ebefd..47bcd845ba 100644 --- a/src/components/views/elements/MiniAvatarUploader.tsx +++ b/src/components/views/elements/MiniAvatarUploader.tsx @@ -32,7 +32,7 @@ interface IProps { hasAvatar: boolean; noAvatarLabel?: string; hasAvatarLabel?: string; - setAvatarUrl(url: string): Promise; + setAvatarUrl(url: string): Promise; } const MiniAvatarUploader: React.FC = ({ hasAvatar, hasAvatarLabel, noAvatarLabel, setAvatarUrl, children }) => { diff --git a/src/components/views/elements/ReplyThread.js b/src/components/views/elements/ReplyThread.js index 2047de6c58..89427515e2 100644 --- a/src/components/views/elements/ReplyThread.js +++ b/src/components/views/elements/ReplyThread.js @@ -1,7 +1,6 @@ /* -Copyright 2017 New Vector Ltd +Copyright 2017 - 2021 The Matrix.org Foundation C.I.C. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -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. @@ -16,22 +15,23 @@ See the License for the specific language governing permissions and limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import PropTypes from 'prop-types'; import dis from '../../../dispatcher/dispatcher'; -import { wantsDateSeparator } from '../../../DateUtils'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import SettingsStore from "../../../settings/SettingsStore"; import { LayoutPropType } from "../../../settings/Layout"; import escapeHtml from "escape-html"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { getUserNameColorClass } from "../../../utils/FormattingUtils"; import { Action } from "../../../dispatcher/actions"; import sanitizeHtml from "sanitize-html"; -import { UIFeature } from "../../../settings/UIFeature"; import { PERMITTED_URL_SCHEMES } from "../../../HtmlUtils"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Spinner from './Spinner'; +import ReplyTile from "../rooms/ReplyTile"; +import Pill from './Pill'; // This component does no cycle detection, simply because the only way to make such a cycle would be to // craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would @@ -69,10 +69,7 @@ export default class ReplyThread extends React.Component { }; this.unmounted = false; - this.context.on("Event.replaced", this.onEventReplaced); this.room = this.context.getRoom(this.props.parentEv.getRoomId()); - this.room.on("Room.redaction", this.onRoomRedaction); - this.room.on("Room.redactionCancelled", this.onRoomRedaction); this.onQuoteClick = this.onQuoteClick.bind(this); this.canCollapse = this.canCollapse.bind(this); @@ -238,36 +235,8 @@ export default class ReplyThread extends React.Component { componentWillUnmount() { this.unmounted = true; - this.context.removeListener("Event.replaced", this.onEventReplaced); - if (this.room) { - this.room.removeListener("Room.redaction", this.onRoomRedaction); - this.room.removeListener("Room.redactionCancelled", this.onRoomRedaction); - } } - updateForEventId = (eventId) => { - if (this.state.events.some(event => event.getId() === eventId)) { - this.forceUpdate(); - } - }; - - onEventReplaced = (ev) => { - if (this.unmounted) return; - - // If one of the events we are rendering gets replaced, force a re-render - this.updateForEventId(ev.getId()); - }; - - onRoomRedaction = (ev) => { - if (this.unmounted) return; - - const eventId = ev.getAssociatedId(); - if (!eventId) return; - - // If one of the events we are rendering gets redacted, force a re-render - this.updateForEventId(eventId); - }; - async initialize() { const { parentEv } = this.props; // at time of making this component we checked that props.parentEv has a parentEventId @@ -337,6 +306,10 @@ export default class ReplyThread extends React.Component { dis.fire(Action.FocusSendMessageComposer); } + getReplyThreadColorClass(ev) { + return getUserNameColorClass(ev.getSender()).replace("Username", "ReplyThread"); + } + render() { let header = null; @@ -349,9 +322,8 @@ export default class ReplyThread extends React.Component { ; } else if (this.state.loadedEv) { const ev = this.state.loadedEv; - const Pill = sdk.getComponent('elements.Pill'); const room = this.context.getRoom(ev.getRoomId()); - header =
+ header =
{ _t('In reply to ', {}, { 'a': (sub) => { sub }, @@ -367,33 +339,15 @@ export default class ReplyThread extends React.Component { }
; } else if (this.state.loading) { - const Spinner = sdk.getComponent("elements.Spinner"); header = ; } - const EventTile = sdk.getComponent('views.rooms.EventTile'); - const DateSeparator = sdk.getComponent('messages.DateSeparator'); const evTiles = this.state.events.map((ev) => { - let dateSep = null; - - if (wantsDateSeparator(this.props.parentEv.getDate(), ev.getDate())) { - dateSep = ; - } - - return
- { dateSep } - +
; }); diff --git a/src/components/views/messages/MFileBody.js b/src/components/views/messages/MFileBody.js index d8d832d15d..9236c77e8d 100644 --- a/src/components/views/messages/MFileBody.js +++ b/src/components/views/messages/MFileBody.js @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016, 2018, 2021 The Matrix.org Foundation C.I.C. +Copyright 2015 - 2021 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. @@ -24,6 +24,7 @@ import AccessibleButton from "../elements/AccessibleButton"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { mediaFromContent } from "../../../customisations/Media"; import ErrorDialog from "../dialogs/ErrorDialog"; +import { TileShape } from "../rooms/EventTile"; let downloadIconUrl; // cached copy of the download.svg asset for the sandboxed iframe later on @@ -89,6 +90,35 @@ function computedStyle(element) { return cssText; } +/** + * Extracts a human readable label for the file attachment to use as + * link text. + * + * @param {Object} content The "content" key of the matrix event. + * @param {boolean} withSize Whether to include size information. Default true. + * @return {string} the human readable link text for the attachment. + */ +export function presentableTextForFile(content, withSize = true) { + let linkText = _t("Attachment"); + if (content.body && content.body.length > 0) { + // The content body should be the name of the file including a + // file extension. + linkText = content.body; + } + + if (content.info && content.info.size && withSize) { + // If we know the size of the file then add it as human readable + // string to the end of the link text so that the user knows how + // big a file they are downloading. + // The content.info also contains a MIME-type but we don't display + // it since it is "ugly", users generally aren't aware what it + // means and the type of the attachment can usually be inferrered + // from the file extension. + linkText += ' (' + filesize(content.info.size) + ')'; + } + return linkText; +} + @replaceableComponent("views.messages.MFileBody") export default class MFileBody extends React.Component { static propTypes = { @@ -119,35 +149,6 @@ export default class MFileBody extends React.Component { this._dummyLink = createRef(); } - /** - * Extracts a human readable label for the file attachment to use as - * link text. - * - * @param {Object} content The "content" key of the matrix event. - * @param {boolean} withSize Whether to include size information. Default true. - * @return {string} the human readable link text for the attachment. - */ - presentableTextForFile(content, withSize = true) { - let linkText = _t("Attachment"); - if (content.body && content.body.length > 0) { - // The content body should be the name of the file including a - // file extension. - linkText = content.body; - } - - if (content.info && content.info.size && withSize) { - // If we know the size of the file then add it as human readable - // string to the end of the link text so that the user knows how - // big a file they are downloading. - // The content.info also contains a MIME-type but we don't display - // it since it is "ugly", users generally aren't aware what it - // means and the type of the attachment can usually be inferrered - // from the file extension. - linkText += ' (' + filesize(content.info.size) + ')'; - } - return linkText; - } - _getContentUrl() { const media = mediaFromContent(this.props.mxEvent.getContent()); return media.srcHttp; @@ -161,7 +162,7 @@ export default class MFileBody extends React.Component { render() { const content = this.props.mxEvent.getContent(); - const text = this.presentableTextForFile(content); + const text = presentableTextForFile(content); const isEncrypted = content.file !== undefined; const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment"); const contentUrl = this._getContentUrl(); @@ -173,7 +174,9 @@ export default class MFileBody extends React.Component { placeholder = (
- {this.presentableTextForFile(content, false)} + + { presentableTextForFile(content, false) } +
); } @@ -306,7 +309,7 @@ export default class MFileBody extends React.Component { // If the attachment is not encrypted then we check whether we // are being displayed in the room timeline or in a list of // files in the right hand side of the screen. - if (this.props.tileShape === "file_grid") { + if (this.props.tileShape === TileShape.FileGrid) { return ( {placeholder} diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.tsx similarity index 77% rename from src/components/views/messages/MImageBody.js rename to src/components/views/messages/MImageBody.tsx index 6da4aa1494..96c8652aee 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.tsx @@ -16,13 +16,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef } from 'react'; -import PropTypes from 'prop-types'; +import React, { ComponentProps, createRef } from 'react'; import { Blurhash } from "react-blurhash"; import MFileBody from './MFileBody'; import Modal from '../../../Modal'; -import * as sdk from '../../../index'; import { decryptFile } from '../../../utils/DecryptFile'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; @@ -31,36 +29,49 @@ import InlineSpinner from '../elements/InlineSpinner'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { mediaFromContent } from "../../../customisations/Media"; import { BLURHASH_FIELD } from "../../../ContentMessages"; +import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; +import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent'; +import ImageView from '../elements/ImageView'; +import { SyncState } from 'matrix-js-sdk/src/sync.api'; + +export interface IProps { + /* the MatrixEvent to show */ + mxEvent: MatrixEvent; + /* called when the image has loaded */ + onHeightChanged(): void; + + /* the maximum image height to use */ + maxImageHeight?: number; + + /* the permalinkCreator */ + permalinkCreator?: RoomPermalinkCreator; +} + +interface IState { + decryptedUrl?: string; + decryptedThumbnailUrl?: string; + decryptedBlob?: Blob; + error; + imgError: boolean; + imgLoaded: boolean; + loadedImageDimensions?: { + naturalWidth: number; + naturalHeight: number; + }; + hover: boolean; + showImage: boolean; +} @replaceableComponent("views.messages.MImageBody") -export default class MImageBody extends React.Component { - static propTypes = { - /* the MatrixEvent to show */ - mxEvent: PropTypes.object.isRequired, - - /* called when the image has loaded */ - onHeightChanged: PropTypes.func.isRequired, - - /* the maximum image height to use */ - maxImageHeight: PropTypes.number, - - /* the permalinkCreator */ - permalinkCreator: PropTypes.object, - }; - +export default class MImageBody extends React.Component { static contextType = MatrixClientContext; + private unmounted = true; + private image = createRef(); - constructor(props) { + constructor(props: IProps) { super(props); - this.onImageError = this.onImageError.bind(this); - this.onImageLoad = this.onImageLoad.bind(this); - this.onImageEnter = this.onImageEnter.bind(this); - this.onImageLeave = this.onImageLeave.bind(this); - this.onClientSync = this.onClientSync.bind(this); - this.onClick = this.onClick.bind(this); - this._isGif = this._isGif.bind(this); - this.state = { decryptedUrl: null, decryptedThumbnailUrl: null, @@ -72,12 +83,10 @@ export default class MImageBody extends React.Component { hover: false, showImage: SettingsStore.getValue("showImages"), }; - - this._image = createRef(); } // FIXME: factor this out and apply it to MVideoBody and MAudioBody too! - onClientSync(syncState, prevState) { + private onClientSync = (syncState: SyncState, prevState: SyncState): void => { if (this.unmounted) return; // Consider the client reconnected if there is no error with syncing. // This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP. @@ -88,15 +97,15 @@ export default class MImageBody extends React.Component { imgError: false, }); } - } + }; - showImage() { + protected showImage(): void { localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true"); this.setState({ showImage: true }); - this._downloadImage(); + this.downloadImage(); } - onClick(ev) { + protected onClick = (ev: React.MouseEvent): void => { if (ev.button === 0 && !ev.metaKey) { ev.preventDefault(); if (!this.state.showImage) { @@ -104,12 +113,11 @@ export default class MImageBody extends React.Component { return; } - const content = this.props.mxEvent.getContent(); - const httpUrl = this._getContentUrl(); - const ImageView = sdk.getComponent("elements.ImageView"); - const params = { + const content = this.props.mxEvent.getContent(); + const httpUrl = this.getContentUrl(); + const params: Omit, "onFinished"> = { src: httpUrl, - name: content.body && content.body.length > 0 ? content.body : _t('Attachment'), + name: content.body?.length > 0 ? content.body : _t('Attachment'), mxEvent: this.props.mxEvent, permalinkCreator: this.props.permalinkCreator, }; @@ -122,58 +130,54 @@ export default class MImageBody extends React.Component { Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true); } - } + }; - _isGif() { + private isGif = (): boolean => { const content = this.props.mxEvent.getContent(); - return ( - content && - content.info && - content.info.mimetype === "image/gif" - ); - } + return content.info?.mimetype === "image/gif"; + }; - onImageEnter(e) { + private onImageEnter = (e: React.MouseEvent): void => { this.setState({ hover: true }); - if (!this.state.showImage || !this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { + if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { return; } - const imgElement = e.target; - imgElement.src = this._getContentUrl(); - } + const imgElement = e.currentTarget; + imgElement.src = this.getContentUrl(); + }; - onImageLeave(e) { + private onImageLeave = (e: React.MouseEvent): void => { this.setState({ hover: false }); - if (!this.state.showImage || !this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { + if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { return; } - const imgElement = e.target; - imgElement.src = this._getThumbUrl(); - } + const imgElement = e.currentTarget; + imgElement.src = this.getThumbUrl(); + }; - onImageError() { + private onImageError = (): void => { this.setState({ imgError: true, }); - } + }; - onImageLoad() { + private onImageLoad = (): void => { this.props.onHeightChanged(); let loadedImageDimensions; - if (this._image.current) { - const { naturalWidth, naturalHeight } = this._image.current; + if (this.image.current) { + const { naturalWidth, naturalHeight } = this.image.current; // this is only used as a fallback in case content.info.w/h is missing loadedImageDimensions = { naturalWidth, naturalHeight }; } this.setState({ imgLoaded: true, loadedImageDimensions }); - } + }; - _getContentUrl() { + protected getContentUrl(): string { const media = mediaFromContent(this.props.mxEvent.getContent()); if (media.isEncrypted) { return this.state.decryptedUrl; @@ -182,7 +186,7 @@ export default class MImageBody extends React.Component { } } - _getThumbUrl() { + protected getThumbUrl(): string { // FIXME: we let images grow as wide as you like, rather than capped to 800x600. // So either we need to support custom timeline widths here, or reimpose the cap, otherwise the // thumbnail resolution will be unnecessarily reduced. @@ -190,7 +194,7 @@ export default class MImageBody extends React.Component { const thumbWidth = 800; const thumbHeight = 600; - const content = this.props.mxEvent.getContent(); + const content = this.props.mxEvent.getContent(); const media = mediaFromContent(content); if (media.isEncrypted) { @@ -218,7 +222,7 @@ export default class MImageBody extends React.Component { // - If there's no sizing info in the event, default to thumbnail const info = content.info; if ( - this._isGif() || + this.isGif() || window.devicePixelRatio === 1.0 || (!info || !info.w || !info.h || !info.size) ) { @@ -253,7 +257,7 @@ export default class MImageBody extends React.Component { } } - _downloadImage() { + private downloadImage(): void { const content = this.props.mxEvent.getContent(); if (content.file !== undefined && this.state.decryptedUrl === null) { let thumbnailPromise = Promise.resolve(null); @@ -297,7 +301,7 @@ export default class MImageBody extends React.Component { if (showImage) { // Don't download anything becaue we don't want to display anything. - this._downloadImage(); + this.downloadImage(); this.setState({ showImage: true }); } @@ -312,7 +316,6 @@ export default class MImageBody extends React.Component { componentWillUnmount() { this.unmounted = true; this.context.removeListener('sync', this.onClientSync); - this._afterComponentWillUnmount(); if (this.state.decryptedUrl) { URL.revokeObjectURL(this.state.decryptedUrl); @@ -322,12 +325,12 @@ export default class MImageBody extends React.Component { } } - // To be overridden by subclasses (e.g. MStickerBody) for further - // cleanup after componentWillUnmount - _afterComponentWillUnmount() { - } - - _messageContent(contentUrl, thumbUrl, content) { + protected messageContent( + contentUrl: string, + thumbUrl: string, + content: IMediaEventContent, + forcedHeight?: number, + ): JSX.Element { let infoWidth; let infoHeight; @@ -348,7 +351,7 @@ export default class MImageBody extends React.Component { imageElement = ; } else { imageElement = ( - {content.body} - const maxHeight = Math.min(this.props.maxImageHeight || 600, infoHeight); + const maxHeight = forcedHeight || Math.min((this.props.maxImageHeight || 600), infoHeight); // The maximum width of the thumbnail, as dictated by its natural // maximum height. const maxWidth = infoWidth * maxHeight / infoHeight; @@ -382,7 +385,7 @@ export default class MImageBody extends React.Component { // which has the same width as the timeline // mx_MImageBody_thumbnail resizes img to exactly container size img = ( - {content.body}; + img = ; showPlaceholder = false; // because we're hiding the image, so don't show the placeholder. } - if (this._isGif() && !SettingsStore.getValue("autoplayGifsAndVideos") && !this.state.hover) { + if (this.isGif() && !SettingsStore.getValue("autoplayGifsAndVideos") && !this.state.hover) { gifLabel =

GIF

; } const thumbnail = ( -
+
{ /* Calculate aspect ratio, using %padding will size _container correctly */ } -
+
{ showPlaceholder &&
{children} ; } // Overidden by MStickerBody - getPlaceholder(width, height) { + protected getPlaceholder(width: number, height: number): JSX.Element { const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD]; if (blurhash) return ; return
@@ -443,17 +446,17 @@ export default class MImageBody extends React.Component { } // Overidden by MStickerBody - getTooltip() { + protected getTooltip(): JSX.Element { return null; } // Overidden by MStickerBody - getFileBody() { + protected getFileBody(): JSX.Element { return ; } render() { - const content = this.props.mxEvent.getContent(); + const content = this.props.mxEvent.getContent(); if (this.state.error !== null) { return ( @@ -464,15 +467,15 @@ export default class MImageBody extends React.Component { ); } - const contentUrl = this._getContentUrl(); + const contentUrl = this.getContentUrl(); let thumbUrl; - if (this._isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) { + if (this.isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) { thumbUrl = contentUrl; } else { - thumbUrl = this._getThumbUrl(); + thumbUrl = this.getThumbUrl(); } - const thumbnail = this._messageContent(contentUrl, thumbUrl, content); + const thumbnail = this.messageContent(contentUrl, thumbUrl, content); const fileBody = this.getFileBody(); return @@ -482,16 +485,18 @@ export default class MImageBody extends React.Component { } } -export class HiddenImagePlaceholder extends React.PureComponent { - static propTypes = { - hover: PropTypes.bool, - }; +interface PlaceholderIProps { + hover?: boolean; + maxWidth?: number; +} +export class HiddenImagePlaceholder extends React.PureComponent { render() { + const maxWidth = this.props.maxWidth ? this.props.maxWidth + "px" : null; let className = 'mx_HiddenImagePlaceholder'; if (this.props.hover) className += ' mx_HiddenImagePlaceholder_hover'; return ( -
+
{_t("Show image")} diff --git a/src/components/views/messages/MImageReplyBody.tsx b/src/components/views/messages/MImageReplyBody.tsx new file mode 100644 index 0000000000..44acf18004 --- /dev/null +++ b/src/components/views/messages/MImageReplyBody.tsx @@ -0,0 +1,62 @@ +/* +Copyright 2020-2021 Tulir Asokan + +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 MImageBody from "./MImageBody"; +import { presentableTextForFile } from "./MFileBody"; +import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; +import SenderProfile from "./SenderProfile"; + +const FORCED_IMAGE_HEIGHT = 44; + +export default class MImageReplyBody extends MImageBody { + public onClick = (ev: React.MouseEvent): void => { + ev.preventDefault(); + }; + + public wrapImage(contentUrl: string, children: JSX.Element): JSX.Element { + return children; + } + + // Don't show "Download this_file.png ..." + public getFileBody(): JSX.Element { + return presentableTextForFile(this.props.mxEvent.getContent()); + } + + render() { + if (this.state.error !== null) { + return super.render(); + } + + const content = this.props.mxEvent.getContent(); + + const contentUrl = this.getContentUrl(); + const thumbnail = this.messageContent(contentUrl, this.getThumbUrl(), content, FORCED_IMAGE_HEIGHT); + const fileBody = this.getFileBody(); + const sender = ; + + return
+ { thumbnail } +
+
{ sender }
+
{ fileBody }
+
+
; + } +} diff --git a/src/components/views/messages/MVoiceMessageBody.tsx b/src/components/views/messages/MVoiceMessageBody.tsx index 2edd42f2e4..bec224dd2d 100644 --- a/src/components/views/messages/MVoiceMessageBody.tsx +++ b/src/components/views/messages/MVoiceMessageBody.tsx @@ -25,9 +25,11 @@ import { mediaFromContent } from "../../../customisations/Media"; import { decryptFile } from "../../../utils/DecryptFile"; import RecordingPlayback from "../audio_messages/RecordingPlayback"; import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; +import { TileShape } from "../rooms/EventTile"; interface IProps { mxEvent: MatrixEvent; + tileShape?: TileShape; } interface IState { @@ -103,7 +105,7 @@ export default class MVoiceMessageBody extends React.PureComponent - +
); diff --git a/src/components/views/messages/MessageEvent.js b/src/components/views/messages/MessageEvent.js index 52a0b9ad08..cd071ebb34 100644 --- a/src/components/views/messages/MessageEvent.js +++ b/src/components/views/messages/MessageEvent.js @@ -42,11 +42,15 @@ export default class MessageEvent extends React.Component { onHeightChanged: PropTypes.func, /* the shape of the tile, used */ - tileShape: PropTypes.string, + tileShape: PropTypes.string, // TODO: Use TileShape enum /* the maximum image height to use, if the event is an image */ maxImageHeight: PropTypes.number, + /* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */ + overrideBodyTypes: PropTypes.object, + overrideEventTypes: PropTypes.object, + /* the permalinkCreator */ permalinkCreator: PropTypes.object, }; @@ -74,9 +78,12 @@ export default class MessageEvent extends React.Component { 'm.file': sdk.getComponent('messages.MFileBody'), 'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'), 'm.video': sdk.getComponent('messages.MVideoBody'), + + ...(this.props.overrideBodyTypes || {}), }; const evTypes = { 'm.sticker': sdk.getComponent('messages.MStickerBody'), + ...(this.props.overrideEventTypes || {}), }; const content = this.props.mxEvent.getContent(); @@ -113,7 +120,7 @@ export default class MessageEvent extends React.Component { } } - return ; + /> : null; } } diff --git a/src/components/views/messages/SenderProfile.tsx b/src/components/views/messages/SenderProfile.tsx index bdae9cec4a..d4b74db6d0 100644 --- a/src/components/views/messages/SenderProfile.tsx +++ b/src/components/views/messages/SenderProfile.tsx @@ -15,12 +15,14 @@ */ import React from 'react'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { MsgType } from "matrix-js-sdk/src/@types/event"; + import Flair from '../elements/Flair'; import FlairStore from '../../../stores/FlairStore'; import { getUserNameColorClass } from '../../../utils/FormattingUtils'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; interface IProps { mxEvent: MatrixEvent; @@ -36,7 +38,7 @@ interface IState { @replaceableComponent("views.messages.SenderProfile") export default class SenderProfile extends React.Component { static contextType = MatrixClientContext; - private unmounted: boolean; + private unmounted = false; constructor(props: IProps) { super(props); @@ -49,8 +51,7 @@ export default class SenderProfile extends React.Component { } componentDidMount() { - this.unmounted = false; - this._updateRelatedGroups(); + this.updateRelatedGroups(); if (this.state.userGroups.length === 0) { this.getPublicisedGroups(); @@ -64,35 +65,29 @@ export default class SenderProfile extends React.Component { this.context.removeListener('RoomState.events', this.onRoomStateEvents); } - async getPublicisedGroups() { - if (!this.unmounted) { - const userGroups = await FlairStore.getPublicisedGroupsCached( - this.context, this.props.mxEvent.getSender(), - ); - this.setState({ userGroups }); - } + private async getPublicisedGroups() { + const userGroups = await FlairStore.getPublicisedGroupsCached(this.context, this.props.mxEvent.getSender()); + if (this.unmounted) return; + this.setState({ userGroups }); } - onRoomStateEvents = event => { - if (event.getType() === 'm.room.related_groups' && - event.getRoomId() === this.props.mxEvent.getRoomId() - ) { - this._updateRelatedGroups(); + private onRoomStateEvents = (event: MatrixEvent) => { + if (event.getType() === 'm.room.related_groups' && event.getRoomId() === this.props.mxEvent.getRoomId()) { + this.updateRelatedGroups(); } }; - _updateRelatedGroups() { - if (this.unmounted) return; + private updateRelatedGroups() { const room = this.context.getRoom(this.props.mxEvent.getRoomId()); if (!room) return; const relatedGroupsEvent = room.currentState.getStateEvents('m.room.related_groups', ''); this.setState({ - relatedGroups: relatedGroupsEvent ? relatedGroupsEvent.getContent().groups || [] : [], + relatedGroups: relatedGroupsEvent?.getContent().groups || [], }); } - _getDisplayedGroups(userGroups, relatedGroups) { + private getDisplayedGroups(userGroups?: string[], relatedGroups?: string[]) { let displayedGroups = userGroups || []; if (relatedGroups && relatedGroups.length > 0) { displayedGroups = relatedGroups.filter((groupId) => { @@ -113,7 +108,7 @@ export default class SenderProfile extends React.Component { const displayName = mxEvent.sender?.rawDisplayName || mxEvent.getSender() || ""; const mxid = mxEvent.sender?.userId || mxEvent.getSender() || ""; - if (msgtype === 'm.emote') { + if (msgtype === MsgType.Emote) { return null; // emote message must include the name so don't duplicate it } @@ -128,7 +123,7 @@ export default class SenderProfile extends React.Component { let flair; if (this.props.enableFlair) { - const displayedGroups = this._getDisplayedGroups( + const displayedGroups = this.getDisplayedGroups( this.state.userGroups, this.state.relatedGroups, ); diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index e9d80d49c5..fc3814136d 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -69,6 +69,7 @@ import RoomName from "../elements/RoomName"; import { mediaFromMxc } from "../../../customisations/Media"; import UIStore from "../../../stores/UIStore"; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; +import SpaceStore from "../../../stores/SpaceStore"; export interface IDevice { deviceId: string; @@ -728,7 +729,7 @@ const MuteToggleButton: React.FC = ({ member, room, powerLevels, // if muting self, warn as it may be irreversible if (target === cli.getUserId()) { try { - if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()))) return; + if (!(await warnSelfDemote(SpaceStore.spacesEnabled && room?.isSpaceRoom()))) return; } catch (e) { console.error("Failed to warn about self demotion: ", e); return; @@ -817,7 +818,7 @@ const RoomAdminToolsContainer: React.FC = ({ if (canAffectUser && me.powerLevel >= kickPowerLevel) { kickButton = ; } - if (me.powerLevel >= redactPowerLevel && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) { + if (me.powerLevel >= redactPowerLevel && (!SpaceStore.spacesEnabled || !room.isSpaceRoom())) { redactButton = ( ); @@ -1096,7 +1097,7 @@ const PowerLevelEditor: React.FC<{ } else if (myUserId === target) { // If we are changing our own PL it can only ever be decreasing, which we cannot reverse. try { - if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()))) return; + if (!(await warnSelfDemote(SpaceStore.spacesEnabled && room?.isSpaceRoom()))) return; } catch (e) { console.error("Failed to warn about self demotion: ", e); } @@ -1326,10 +1327,10 @@ const BasicUserInfo: React.FC<{ if (!isRoomEncrypted) { if (!cryptoEnabled) { text = _t("This client does not support end-to-end encryption."); - } else if (room && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) { + } else if (room && (!SpaceStore.spacesEnabled || !room.isSpaceRoom())) { text = _t("Messages in this room are not end-to-end encrypted."); } - } else if (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom()) { + } else if (!SpaceStore.spacesEnabled || !room.isSpaceRoom()) { text = _t("Messages in this room are end-to-end encrypted."); } @@ -1405,7 +1406,7 @@ const BasicUserInfo: React.FC<{ canInvite={roomPermissions.canInvite} isIgnored={isIgnored} member={member as RoomMember} - isSpace={SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()} + isSpace={SpaceStore.spacesEnabled && room?.isSpaceRoom()} /> { adminToolsContainer } @@ -1568,7 +1569,7 @@ const UserInfo: React.FC = ({ previousPhase = RightPanelPhases.RoomMemberInfo; refireParams = { member: member }; } else if (room) { - previousPhase = previousPhase = SettingsStore.getValue("feature_spaces") && room.isSpaceRoom() + previousPhase = previousPhase = SpaceStore.spacesEnabled && room.isSpaceRoom() ? RightPanelPhases.SpaceMemberList : RightPanelPhases.RoomMemberList; } @@ -1617,7 +1618,7 @@ const UserInfo: React.FC = ({ } let scopeHeader; - if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) { + if (SpaceStore.spacesEnabled && room?.isSpaceRoom()) { scopeHeader =
diff --git a/src/components/views/room_settings/RoomPublishSetting.tsx b/src/components/views/room_settings/RoomPublishSetting.tsx index 5b6858abf5..1cc83dea9e 100644 --- a/src/components/views/room_settings/RoomPublishSetting.tsx +++ b/src/components/views/room_settings/RoomPublishSetting.tsx @@ -15,6 +15,7 @@ limitations under the License. */ import React from "react"; +import { Visibility } from "matrix-js-sdk/src/@types/partials"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import { _t } from "../../../languageHandler"; @@ -50,7 +51,7 @@ export default class RoomPublishSetting extends React.PureComponent { // Roll back the local echo on the change this.setState({ isRoomPublished: valueBefore }); diff --git a/src/components/views/rooms/Autocomplete.tsx b/src/components/views/rooms/Autocomplete.tsx index 8fbecbe722..6b5edcf91b 100644 --- a/src/components/views/rooms/Autocomplete.tsx +++ b/src/components/views/rooms/Autocomplete.tsx @@ -55,7 +55,7 @@ interface IState { export default class Autocomplete extends React.PureComponent { autocompleter: Autocompleter; queryRequested: string; - debounceCompletionsRequest: NodeJS.Timeout; + debounceCompletionsRequest: number; private containerRef = createRef(); constructor(props) { diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 7cceef4a86..63ac8ff375 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -27,7 +27,6 @@ import { _t } from '../../../languageHandler'; import { hasText } from "../../../TextForEvent"; import * as sdk from "../../../index"; import dis from '../../../dispatcher/dispatcher'; -import SettingsStore from "../../../settings/SettingsStore"; import { Layout } from "../../../settings/Layout"; import { formatTime } from "../../../DateUtils"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; @@ -54,6 +53,7 @@ import TooltipButton from '../elements/TooltipButton'; import ReadReceiptMarker from "./ReadReceiptMarker"; import MessageActionBar from "../messages/MessageActionBar"; import ReactionsRow from '../messages/ReactionsRow'; +import { getEventDisplayInfo } from '../../../utils/EventUtils'; const eventTileTypes = { [EventType.RoomMessage]: 'messages.MessageEvent', @@ -192,8 +192,7 @@ export interface IReadReceiptProps { export enum TileShape { Notif = "notif", FileGrid = "file_grid", - Reply = "reply", - ReplyPreview = "reply_preview", + Pinned = "pinned", } interface IProps { @@ -847,35 +846,9 @@ export default class EventTile extends React.Component { }; render() { - //console.info("EventTile showUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview); + const msgtype = this.props.mxEvent.getContent().msgtype; + const { tileHandler, isBubbleMessage, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent); - const content = this.props.mxEvent.getContent(); - const msgtype = content.msgtype; - const eventType = this.props.mxEvent.getType(); - - let tileHandler = getHandlerTile(this.props.mxEvent); - - // Info messages are basically information about commands processed on a room - let isBubbleMessage = eventType.startsWith("m.key.verification") || - (eventType === EventType.RoomMessage && msgtype && msgtype.startsWith("m.key.verification")) || - (eventType === EventType.RoomCreate) || - (eventType === EventType.RoomEncryption) || - (tileHandler === "messages.MJitsiWidgetEvent"); - let isInfoMessage = ( - !isBubbleMessage && eventType !== EventType.RoomMessage && - eventType !== EventType.Sticker && eventType !== EventType.RoomCreate - ); - - // If we're showing hidden events in the timeline, we should use the - // source tile when there's no regular tile for an event and also for - // replace relations (which otherwise would display as a confusing - // duplicate of the thing they are replacing). - if (SettingsStore.getValue("showHiddenEventsInTimeline") && !haveTileForEvent(this.props.mxEvent)) { - tileHandler = "messages.ViewSourceEvent"; - isBubbleMessage = false; - // Reuse info message avatar and sender profile styling - isInfoMessage = true; - } // This shouldn't happen: the caller should check we support this type // before trying to instantiate us if (!tileHandler) { @@ -902,7 +875,7 @@ export default class EventTile extends React.Component { mx_EventTile_12hr: this.props.isTwelveHour, // Note: we keep the `sending` state class for tests, not for our styles mx_EventTile_sending: !isEditing && isSending, - mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(), + mx_EventTile_highlight: this.props.tileShape === TileShape.Notif ? false : this.shouldHighlight(), mx_EventTile_selected: this.props.isSelectedEvent, mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation, mx_EventTile_last: this.props.last, @@ -935,7 +908,7 @@ export default class EventTile extends React.Component { let avatarSize; let needsSenderProfile; - if (this.props.tileShape === "notif") { + if (this.props.tileShape === TileShape.Notif) { avatarSize = 24; needsSenderProfile = true; } else if (tileHandler === 'messages.RoomCreate' || isBubbleMessage) { @@ -949,7 +922,7 @@ export default class EventTile extends React.Component { } else if (this.props.layout == Layout.IRC) { avatarSize = 14; needsSenderProfile = true; - } else if (this.props.continuation && this.props.tileShape !== "file_grid") { + } else if (this.props.continuation && this.props.tileShape !== TileShape.FileGrid) { // no avatar or sender profile for continuation messages avatarSize = 0; needsSenderProfile = false; @@ -979,7 +952,7 @@ export default class EventTile extends React.Component { } if (needsSenderProfile) { - if (!this.props.tileShape || this.props.tileShape === 'reply' || this.props.tileShape === 'reply_preview') { + if (!this.props.tileShape) { sender = { } switch (this.props.tileShape) { - case 'notif': { + case TileShape.Notif: { const room = this.context.getRoom(this.props.mxEvent.getRoomId()); return React.createElement(this.props.as || "li", { "className": classes, @@ -1093,11 +1066,12 @@ export default class EventTile extends React.Component { highlightLink={this.props.highlightLink} showUrlPreview={this.props.showUrlPreview} onHeightChanged={this.props.onHeightChanged} + tileShape={this.props.tileShape} />
, ]); } - case 'file_grid': { + case TileShape.FileGrid: { return React.createElement(this.props.as || "li", { "className": classes, "aria-live": ariaLive, @@ -1128,44 +1102,6 @@ export default class EventTile extends React.Component { ]); } - case 'reply': - case 'reply_preview': { - let thread; - if (this.props.tileShape === 'reply_preview') { - thread = ReplyThread.makeThread( - this.props.mxEvent, - this.props.onHeightChanged, - this.props.permalinkCreator, - this.replyThread, - null, - this.props.alwaysShowTimestamps || this.state.hover, - ); - } - return React.createElement(this.props.as || "li", { - "className": classes, - "aria-live": ariaLive, - "aria-atomic": true, - "data-scroll-tokens": scrollToken, - }, [ - ircTimestamp, - avatar, - sender, - ircPadlock, -
- { groupTimestamp } - { groupPadlock } - { thread } - -
, - ]); - } default: { const thread = ReplyThread.makeThread( this.props.mxEvent, @@ -1187,10 +1123,10 @@ export default class EventTile extends React.Component { "data-scroll-tokens": scrollToken, "onMouseEnter": () => this.setState({ hover: true }), "onMouseLeave": () => this.setState({ hover: false }), - }, [ - ircTimestamp, - sender, - ircPadlock, + }, <> + { ircTimestamp } + { sender } + { ircPadlock }
{ groupTimestamp } { groupPadlock } @@ -1208,11 +1144,10 @@ export default class EventTile extends React.Component { { keyRequestInfo } { reactionsRow } { actionBar } -
, - msgOption, - avatar, - - ]) +
+ { msgOption } + { avatar } + ) ); } } diff --git a/src/components/views/rooms/LinkPreviewGroup.tsx b/src/components/views/rooms/LinkPreviewGroup.tsx index 2541b2e375..c9842bdd33 100644 --- a/src/components/views/rooms/LinkPreviewGroup.tsx +++ b/src/components/views/rooms/LinkPreviewGroup.tsx @@ -40,10 +40,12 @@ const LinkPreviewGroup: React.FC = ({ links, mxEvent, onCancelClick, onH const ts = mxEvent.getTs(); const previews = useAsyncMemo<[string, IPreviewUrlResponse][]>(async () => { - return Promise.all<[string, IPreviewUrlResponse] | void>(links.map(link => { - return cli.getUrlPreview(link, ts).then(preview => [link, preview], error => { + return Promise.all<[string, IPreviewUrlResponse] | void>(links.map(async link => { + try { + return [link, await cli.getUrlPreview(link, ts)]; + } catch (error) { console.error("Failed to get URL preview: " + error); - }); + } })).then(a => a.filter(Boolean)) as Promise<[string, IPreviewUrlResponse][]>; }, [links, ts], []); diff --git a/src/components/views/rooms/MemberList.tsx b/src/components/views/rooms/MemberList.tsx index f4df70c7ee..71e54404c0 100644 --- a/src/components/views/rooms/MemberList.tsx +++ b/src/components/views/rooms/MemberList.tsx @@ -43,6 +43,7 @@ import EntityTile from "./EntityTile"; import MemberTile from "./MemberTile"; import BaseAvatar from '../avatars/BaseAvatar'; import { throttle } from 'lodash'; +import SpaceStore from "../../../stores/SpaceStore"; const INITIAL_LOAD_NUM_MEMBERS = 30; const INITIAL_LOAD_NUM_INVITED = 5; @@ -509,7 +510,7 @@ export default class MemberList extends React.Component { const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat(); if (chat && chat.roomId === this.props.roomId) { inviteButtonText = _t("Invite to this community"); - } else if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) { + } else if (SpaceStore.spacesEnabled && room.isSpaceRoom()) { inviteButtonText = _t("Invite to this space"); } @@ -549,7 +550,7 @@ export default class MemberList extends React.Component { let previousPhase = RightPanelPhases.RoomSummary; // We have no previousPhase for when viewing a MemberList from a Space let scopeHeader; - if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) { + if (SpaceStore.spacesEnabled && room?.isSpaceRoom()) { previousPhase = undefined; scopeHeader =
diff --git a/src/components/views/rooms/PinnedEventTile.tsx b/src/components/views/rooms/PinnedEventTile.tsx index 774dea70c8..0e3396e9b0 100644 --- a/src/components/views/rooms/PinnedEventTile.tsx +++ b/src/components/views/rooms/PinnedEventTile.tsx @@ -29,6 +29,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { getUserNameColorClass } from "../../../utils/FormattingUtils"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import { TileShape } from "./EventTile"; interface IProps { room: Room; @@ -87,6 +88,7 @@ export default class PinnedEventTile extends React.Component { className="mx_PinnedEventTile_body" maxImageHeight={150} onHeightChanged={() => {}} // we need to give this, apparently + tileShape={TileShape.Pinned} />
diff --git a/src/components/views/rooms/ReplyPreview.js b/src/components/views/rooms/ReplyPreview.js index f9c8e622a7..c7d19e58db 100644 --- a/src/components/views/rooms/ReplyPreview.js +++ b/src/components/views/rooms/ReplyPreview.js @@ -1,5 +1,5 @@ /* -Copyright 2017 New Vector Ltd +Copyright 2017 - 2021 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. @@ -16,14 +16,12 @@ limitations under the License. import React from 'react'; import dis from '../../../dispatcher/dispatcher'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import RoomViewStore from '../../../stores/RoomViewStore'; -import SettingsStore from "../../../settings/SettingsStore"; import PropTypes from "prop-types"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; -import { UIFeature } from "../../../settings/UIFeature"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import ReplyTile from './ReplyTile'; function cancelQuoting() { dis.dispatch({ @@ -71,8 +69,6 @@ export default class ReplyPreview extends React.Component { render() { if (!this.state.event) return null; - const EventTile = sdk.getComponent('rooms.EventTile'); - return
@@ -88,15 +84,12 @@ export default class ReplyPreview extends React.Component { />
- +
+ +
; } diff --git a/src/components/views/rooms/ReplyTile.tsx b/src/components/views/rooms/ReplyTile.tsx new file mode 100644 index 0000000000..18b30d33d5 --- /dev/null +++ b/src/components/views/rooms/ReplyTile.tsx @@ -0,0 +1,155 @@ +/* +Copyright 2020-2021 Tulir Asokan + +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 classNames from 'classnames'; +import { _t } from '../../../languageHandler'; +import dis from '../../../dispatcher/dispatcher'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; +import SenderProfile from "../messages/SenderProfile"; +import MImageReplyBody from "../messages/MImageReplyBody"; +import * as sdk from '../../../index'; +import { EventType, MsgType } from 'matrix-js-sdk/src/@types/event'; +import { replaceableComponent } from '../../../utils/replaceableComponent'; +import { getEventDisplayInfo } from '../../../utils/EventUtils'; +import MFileBody from "../messages/MFileBody"; + +interface IProps { + mxEvent: MatrixEvent; + permalinkCreator?: RoomPermalinkCreator; + highlights?: string[]; + highlightLink?: string; + onHeightChanged?(): void; +} + +@replaceableComponent("views.rooms.ReplyTile") +export default class ReplyTile extends React.PureComponent { + static defaultProps = { + onHeightChanged: () => {}, + }; + + componentDidMount() { + this.props.mxEvent.on("Event.decrypted", this.onDecrypted); + this.props.mxEvent.on("Event.beforeRedaction", this.onEventRequiresUpdate); + this.props.mxEvent.on("Event.replaced", this.onEventRequiresUpdate); + } + + componentWillUnmount() { + this.props.mxEvent.removeListener("Event.decrypted", this.onDecrypted); + this.props.mxEvent.removeListener("Event.beforeRedaction", this.onEventRequiresUpdate); + this.props.mxEvent.removeListener("Event.replaced", this.onEventRequiresUpdate); + } + + private onDecrypted = (): void => { + this.forceUpdate(); + if (this.props.onHeightChanged) { + this.props.onHeightChanged(); + } + }; + + private onEventRequiresUpdate = (): void => { + // Force update when necessary - redactions and edits + this.forceUpdate(); + }; + + private onClick = (e: React.MouseEvent): void => { + // This allows the permalink to be opened in a new tab/window or copied as + // matrix.to, but also for it to enable routing within Riot when clicked. + e.preventDefault(); + dis.dispatch({ + action: 'view_room', + event_id: this.props.mxEvent.getId(), + highlighted: true, + room_id: this.props.mxEvent.getRoomId(), + }); + }; + + render() { + const mxEvent = this.props.mxEvent; + const msgType = mxEvent.getContent().msgtype; + const evType = mxEvent.getType() as EventType; + + const { tileHandler, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent); + // This shouldn't happen: the caller should check we support this type + // before trying to instantiate us + if (!tileHandler) { + const { mxEvent } = this.props; + console.warn(`Event type not supported: type:${mxEvent.getType()} isState:${mxEvent.isState()}`); + return
+ { _t('This event could not be displayed') } +
; + } + + const EventTileType = sdk.getComponent(tileHandler); + + const classes = classNames("mx_ReplyTile", { + mx_ReplyTile_info: isInfoMessage && !this.props.mxEvent.isRedacted(), + mx_ReplyTile_audio: msgType === MsgType.Audio, + mx_ReplyTile_video: msgType === MsgType.Video, + }); + + let permalink = "#"; + if (this.props.permalinkCreator) { + permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); + } + + let sender; + const needsSenderProfile = ( + !isInfoMessage && + msgType !== MsgType.Image && + tileHandler !== EventType.RoomCreate && + evType !== EventType.Sticker + ); + + if (needsSenderProfile) { + sender = ; + } + + const msgtypeOverrides = { + [MsgType.Image]: MImageReplyBody, + // Override audio and video body with file body. We also hide the download/decrypt button using CSS + [MsgType.Audio]: MFileBody, + [MsgType.Video]: MFileBody, + }; + const evOverrides = { + // Use MImageReplyBody so that the sticker isn't taking up a lot of space + [EventType.Sticker]: MImageReplyBody, + }; + + return ( + + ); + } +} diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index c94256800d..7ece6add9c 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -417,7 +417,7 @@ export default class RoomList extends React.PureComponent { } private renderCommunityInvites(): ReactComponentElement[] { - if (SettingsStore.getValue("feature_spaces")) return []; + if (SpaceStore.spacesEnabled) return []; // TODO: Put community invites in a more sensible place (not in the room list) // See https://github.com/vector-im/element-web/issues/14456 return MatrixClientPeg.get().getGroups().filter(g => { diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js index a66186d116..c0e6826ba5 100644 --- a/src/components/views/rooms/Stickerpicker.js +++ b/src/components/views/rooms/Stickerpicker.js @@ -224,7 +224,7 @@ export default class Stickerpicker extends React.PureComponent { } _getStickerpickerContent() { - // Handle Integration Manager errors + // Handle integration manager errors if (this.state._imError) { return this._errorStickerpickerContent(); } diff --git a/src/components/views/rooms/ThirdPartyMemberInfo.tsx b/src/components/views/rooms/ThirdPartyMemberInfo.tsx index 2bcc3ead57..51bb891c62 100644 --- a/src/components/views/rooms/ThirdPartyMemberInfo.tsx +++ b/src/components/views/rooms/ThirdPartyMemberInfo.tsx @@ -25,9 +25,9 @@ import { isValid3pidInvite } from "../../../RoomInvite"; import RoomAvatar from "../avatars/RoomAvatar"; import RoomName from "../elements/RoomName"; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import SettingsStore from "../../../settings/SettingsStore"; import ErrorDialog from '../dialogs/ErrorDialog'; import AccessibleButton from '../elements/AccessibleButton'; +import SpaceStore from "../../../stores/SpaceStore"; interface IProps { event: MatrixEvent; @@ -134,7 +134,7 @@ export default class ThirdPartyMemberInfo extends React.Component diff --git a/src/components/views/settings/SetIdServer.tsx b/src/components/views/settings/SetIdServer.tsx index 9180c98101..dc38055c10 100644 --- a/src/components/views/settings/SetIdServer.tsx +++ b/src/components/views/settings/SetIdServer.tsx @@ -44,7 +44,7 @@ const REACHABILITY_TIMEOUT = 10000; // ms async function checkIdentityServerUrl(u) { const parsedUrl = url.parse(u); - if (parsedUrl.protocol !== 'https:') return _t("Identity Server URL must be HTTPS"); + if (parsedUrl.protocol !== 'https:') return _t("Identity server URL must be HTTPS"); // XXX: duplicated logic from js-sdk but it's quite tied up in the validation logic in the // js-sdk so probably as easy to duplicate it than to separate it out so we can reuse it @@ -53,17 +53,17 @@ async function checkIdentityServerUrl(u) { if (response.ok) { return null; } else if (response.status < 200 || response.status >= 300) { - return _t("Not a valid Identity Server (status code %(code)s)", { code: response.status }); + return _t("Not a valid identity server (status code %(code)s)", { code: response.status }); } else { - return _t("Could not connect to Identity Server"); + return _t("Could not connect to identity server"); } } catch (e) { - return _t("Could not connect to Identity Server"); + return _t("Could not connect to identity server"); } } interface IProps { - // Whether or not the ID server is missing terms. This affects the text + // Whether or not the identity server is missing terms. This affects the text // shown to the user. missingTerms: boolean; } @@ -87,7 +87,7 @@ export default class SetIdServer extends React.Component { let defaultIdServer = ''; if (!MatrixClientPeg.get().getIdentityServerUrl() && getDefaultIdentityServerUrl()) { - // If no ID server is configured but there's one in the config, prepopulate + // If no identity server is configured but there's one in the config, prepopulate // the field to help the user. defaultIdServer = abbreviateUrl(getDefaultIdentityServerUrl()); } @@ -112,7 +112,7 @@ export default class SetIdServer extends React.Component { } private onAction = (payload: ActionPayload) => { - // We react to changes in the ID server in the event the user is staring at this form + // We react to changes in the identity server in the event the user is staring at this form // when changing their identity server on another device. if (payload.action !== "id_server_changed") return; @@ -356,7 +356,7 @@ export default class SetIdServer extends React.Component { let sectionTitle; let bodyText; if (idServerUrl) { - sectionTitle = _t("Identity Server (%(server)s)", { server: abbreviateUrl(idServerUrl) }); + sectionTitle = _t("Identity server (%(server)s)", { server: abbreviateUrl(idServerUrl) }); bodyText = _t( "You are currently using to discover and be discoverable by " + "existing contacts you know. You can change your identity server below.", @@ -371,7 +371,7 @@ export default class SetIdServer extends React.Component { ); } } else { - sectionTitle = _t("Identity Server"); + sectionTitle = _t("Identity server"); bodyText = _t( "You are not currently using an identity server. " + "To discover and be discoverable by existing contacts you know, " + diff --git a/src/components/views/settings/SetIntegrationManager.tsx b/src/components/views/settings/SetIntegrationManager.tsx index ada78e2848..f1922f93ee 100644 --- a/src/components/views/settings/SetIntegrationManager.tsx +++ b/src/components/views/settings/SetIntegrationManager.tsx @@ -65,13 +65,13 @@ export default class SetIntegrationManager extends React.Component(%(serverName)s) to manage bots, widgets, " + + "Use an integration manager (%(serverName)s) to manage bots, widgets, " + "and sticker packs.", { serverName: currentManager.name }, { b: sub => {sub} }, ); } else { - bodyText = _t("Use an Integration Manager to manage bots, widgets, and sticker packs."); + bodyText = _t("Use an integration manager to manage bots, widgets, and sticker packs."); } return ( @@ -86,7 +86,7 @@ export default class SetIntegrationManager extends React.Component
{_t( - "Integration Managers receive configuration data, and can modify widgets, " + + "Integration managers receive configuration data, and can modify widgets, " + "send room invites, and set power levels on your behalf.", )} diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx index f04c2f13ae..a94821e94a 100644 --- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx @@ -75,7 +75,8 @@ interface IState extends IThemeState { export default class AppearanceUserSettingsTab extends React.Component { private readonly MESSAGE_PREVIEW_TEXT = _t("Hey you. You're the best!"); - private themeTimer: NodeJS.Timeout; + private themeTimer: number; + private unmounted = false; constructor(props: IProps) { super(props); @@ -101,6 +102,7 @@ export default class AppearanceUserSettingsTab extends React.Component - { /* has its own heading as it includes the current ID server */ } + { /* has its own heading as it includes the current identity server */ }
); @@ -387,7 +387,7 @@ export default class GeneralUserSettingsTab extends React.Component { return (
{threepidSection} - { /* has its own heading as it includes the current ID server */ } + { /* has its own heading as it includes the current identity server */ }
); diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx index 608d973992..f2857720a5 100644 --- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx @@ -290,7 +290,7 @@ export default class HelpUserSettingsTab extends React.Component {_t("Advanced")}
{_t("Homeserver is")} {MatrixClientPeg.get().getHomeserverUrl()}
- {_t("Identity Server is")} {MatrixClientPeg.get().getIdentityServerUrl()}
+ {_t("Identity server is")} {MatrixClientPeg.get().getIdentityServerUrl()}

{_t("Access Token")}
diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 5b3cf31cad..9cefbbd94c 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -42,7 +42,6 @@ import { import { Key } from "../../../Keyboard"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { NotificationState } from "../../../stores/notifications/NotificationState"; -import SettingsStore from "../../../settings/SettingsStore"; interface IButtonProps { space?: Room; @@ -134,7 +133,7 @@ const InnerSpacePanel = React.memo(({ children, isPanelCo const [invites, spaces, activeSpace] = useSpaces(); const activeSpaces = activeSpace ? [activeSpace] : []; - const homeNotificationState = SettingsStore.getValue("feature_spaces.all_rooms") + const homeNotificationState = SpaceStore.spacesTweakAllRoomsEnabled ? RoomNotificationStateStore.instance.globalState : SpaceStore.instance.getNotificationState(HOME_SPACE); return
@@ -142,7 +141,7 @@ const InnerSpacePanel = React.memo(({ children, isPanelCo className="mx_SpaceButton_home" onClick={() => SpaceStore.instance.setActiveSpace(null)} selected={!activeSpace} - tooltip={SettingsStore.getValue("feature_spaces.all_rooms") ? _t("All rooms") : _t("Home")} + tooltip={SpaceStore.spacesTweakAllRoomsEnabled ? _t("All rooms") : _t("Home")} notificationState={homeNotificationState} isNarrow={isPanelCollapsed} /> diff --git a/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx b/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx index f27b73a511..b76d53be41 100644 --- a/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx +++ b/src/components/views/spaces/SpaceSettingsVisibilityTab.tsx @@ -39,7 +39,7 @@ enum SpaceVisibility { const useLocalEcho = ( currentFactory: () => T, - setterFn: (value: T) => Promise, + setterFn: (value: T) => Promise, errorFn: (error: Error) => void, ): [value: T, handler: (value: T) => void] => { const [value, setValue] = useState(currentFactory); diff --git a/src/components/views/toasts/VerificationRequestToast.tsx b/src/components/views/toasts/VerificationRequestToast.tsx index 75254d7c62..45f1464b0e 100644 --- a/src/components/views/toasts/VerificationRequestToast.tsx +++ b/src/components/views/toasts/VerificationRequestToast.tsx @@ -44,7 +44,7 @@ interface IState { @replaceableComponent("views.toasts.VerificationRequestToast") export default class VerificationRequestToast extends React.PureComponent { - private intervalHandle: NodeJS.Timeout; + private intervalHandle: number; constructor(props) { super(props); diff --git a/src/components/views/voip/DialPad.tsx b/src/components/views/voip/DialPad.tsx index dff7a8f748..6687c89b52 100644 --- a/src/components/views/voip/DialPad.tsx +++ b/src/components/views/voip/DialPad.tsx @@ -19,16 +19,17 @@ import AccessibleButton from "../elements/AccessibleButton"; import { replaceableComponent } from "../../../utils/replaceableComponent"; const BUTTONS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']; +const BUTTON_LETTERS = ['', 'ABC', 'DEF', 'GHI', 'JKL', 'MNO', 'PQRS', 'TUV', 'WXYZ', '', '+', '']; enum DialPadButtonKind { Digit, - Delete, Dial, } interface IButtonProps { kind: DialPadButtonKind; digit?: string; + digitSubtext?: string; onButtonPress: (string) => void; } @@ -42,11 +43,10 @@ class DialPadButton extends React.PureComponent { case DialPadButtonKind.Digit: return {this.props.digit} +
+ {this.props.digitSubtext} +
; - case DialPadButtonKind.Delete: - return ; case DialPadButtonKind.Dial: return ; } @@ -55,7 +55,7 @@ class DialPadButton extends React.PureComponent { interface IProps { onDigitPress: (string) => void; - hasDialAndDelete: boolean; + hasDial: boolean; onDeletePress?: (string) => void; onDialPress?: (string) => void; } @@ -65,16 +65,15 @@ export default class Dialpad extends React.PureComponent { render() { const buttonNodes = []; - for (const button of BUTTONS) { + for (let i = 0; i < BUTTONS.length; i++) { + const button = BUTTONS[i]; + const digitSubtext = BUTTON_LETTERS[i]; buttonNodes.push(); } - if (this.props.hasDialAndDelete) { - buttonNodes.push(); + if (this.props.hasDial) { buttonNodes.push(); diff --git a/src/components/views/voip/DialPadModal.tsx b/src/components/views/voip/DialPadModal.tsx index 5e5903531e..033aa2e700 100644 --- a/src/components/views/voip/DialPadModal.tsx +++ b/src/components/views/voip/DialPadModal.tsx @@ -15,7 +15,6 @@ limitations under the License. */ import * as React from "react"; -import { _t } from "../../../languageHandler"; import AccessibleButton from "../elements/AccessibleButton"; import Field from "../elements/Field"; import DialPad from './DialPad'; @@ -23,6 +22,7 @@ import dis from '../../../dispatcher/dispatcher'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { DialNumberPayload } from "../../../dispatcher/payloads/DialNumberPayload"; import { Action } from "../../../dispatcher/actions"; +import DialPadBackspaceButton from "../elements/DialPadBackspaceButton"; interface IProps { onFinished: (boolean) => void; @@ -74,22 +74,38 @@ export default class DialpadModal extends React.PureComponent { }; render() { + const backspaceButton = ( + + ); + + // Only show the backspace button if the field has content + let dialPadField; + if (this.state.value.length !== 0) { + dialPadField = ; + } else { + dialPadField = ; + } + return
+
+ +
-
- {_t("Dial pad")} - -
- + {dialPadField}
-
- and connect to instead?": "Disconnect from the identity server and connect to instead?", @@ -1221,20 +1223,20 @@ "Disconnect anyway": "Disconnect anyway", "You are still sharing your personal data on the identity server .": "You are still sharing your personal data on the identity server .", "We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.": "We recommend that you remove your email addresses and phone numbers from the identity server before disconnecting.", - "Identity Server (%(server)s)": "Identity Server (%(server)s)", + "Identity server (%(server)s)": "Identity server (%(server)s)", "You are currently using to discover and be discoverable by existing contacts you know. You can change your identity server below.": "You are currently using to discover and be discoverable by existing contacts you know. You can change your identity server below.", "If you don't want to use to discover and be discoverable by existing contacts you know, enter another identity server below.": "If you don't want to use to discover and be discoverable by existing contacts you know, enter another identity server below.", - "Identity Server": "Identity Server", + "Identity server": "Identity server", "You are not currently using an identity server. To discover and be discoverable by existing contacts you know, add one below.": "You are not currently using an identity server. To discover and be discoverable by existing contacts you know, add one below.", "Disconnecting from your identity server will mean you won't be discoverable by other users and you won't be able to invite others by email or phone.": "Disconnecting from your identity server will mean you won't be discoverable by other users and you won't be able to invite others by email or phone.", "Using an identity server is optional. If you choose not to use an identity server, you won't be discoverable by other users and you won't be able to invite others by email or phone.": "Using an identity server is optional. If you choose not to use an identity server, you won't be discoverable by other users and you won't be able to invite others by email or phone.", "Do not use an identity server": "Do not use an identity server", "Enter a new identity server": "Enter a new identity server", "Change": "Change", - "Use an Integration Manager (%(serverName)s) to manage bots, widgets, and sticker packs.": "Use an Integration Manager (%(serverName)s) to manage bots, widgets, and sticker packs.", - "Use an Integration Manager to manage bots, widgets, and sticker packs.": "Use an Integration Manager to manage bots, widgets, and sticker packs.", + "Use an integration manager (%(serverName)s) to manage bots, widgets, and sticker packs.": "Use an integration manager (%(serverName)s) to manage bots, widgets, and sticker packs.", + "Use an integration manager to manage bots, widgets, and sticker packs.": "Use an integration manager to manage bots, widgets, and sticker packs.", "Manage integrations": "Manage integrations", - "Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.", + "Integration managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Integration managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.", "Add": "Add", "Error encountered (%(errorDetail)s).": "Error encountered (%(errorDetail)s).", "Checking for an update...": "Checking for an update...", @@ -1288,7 +1290,7 @@ "%(brand)s version:": "%(brand)s version:", "olm version:": "olm version:", "Homeserver is": "Homeserver is", - "Identity Server is": "Identity Server is", + "Identity server is": "Identity server is", "Access Token": "Access Token", "Your access token gives full access to your account. Do not share it with anyone.": "Your access token gives full access to your account. Do not share it with anyone.", "Copy": "Copy", @@ -1967,7 +1969,7 @@ "%(brand)s URL": "%(brand)s URL", "Room ID": "Room ID", "Widget ID": "Widget ID", - "Using this widget may share data with %(widgetDomain)s & your Integration Manager.": "Using this widget may share data with %(widgetDomain)s & your Integration Manager.", + "Using this widget may share data with %(widgetDomain)s & your integration manager.": "Using this widget may share data with %(widgetDomain)s & your integration manager.", "Using this widget may share data with %(widgetDomain)s.": "Using this widget may share data with %(widgetDomain)s.", "Widgets do not use message encryption.": "Widgets do not use message encryption.", "Widget added by": "Widget added by", @@ -2285,7 +2287,7 @@ "Integrations are disabled": "Integrations are disabled", "Enable 'Manage Integrations' in Settings to do this.": "Enable 'Manage Integrations' in Settings to do this.", "Integrations not allowed": "Integrations not allowed", - "Your %(brand)s doesn't allow you to use an Integration Manager to do this. Please contact an admin.": "Your %(brand)s doesn't allow you to use an Integration Manager to do this. Please contact an admin.", + "Your %(brand)s doesn't allow you to use an integration manager to do this. Please contact an admin.": "Your %(brand)s doesn't allow you to use an integration manager to do this. Please contact an admin.", "To continue, use Single Sign On to prove your identity.": "To continue, use Single Sign On to prove your identity.", "Confirm to continue": "Confirm to continue", "Click the button below to confirm your identity.": "Click the button below to confirm your identity.", @@ -2294,7 +2296,6 @@ "Something went wrong trying to invite the users.": "Something went wrong trying to invite the users.", "We couldn't invite those users. Please check the users you want to invite and try again.": "We couldn't invite those users. Please check the users you want to invite and try again.", "A call can only be transferred to a single user.": "A call can only be transferred to a single user.", - "Failed to transfer call": "Failed to transfer call", "Failed to find the following users": "Failed to find the following users", "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s": "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s", "Recent Conversations": "Recent Conversations", @@ -2317,6 +2318,8 @@ "Invited people will be able to read old messages.": "Invited people will be able to read old messages.", "Transfer": "Transfer", "Consult first": "Consult first", + "User Directory": "User Directory", + "Dial pad": "Dial pad", "a new master key signature": "a new master key signature", "a new cross-signing key signature": "a new cross-signing key signature", "a device cross-signing signature": "a device cross-signing signature", @@ -2440,7 +2443,7 @@ "Missing session data": "Missing session data", "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.", "Your browser likely removed this data when running low on disk space.": "Your browser likely removed this data when running low on disk space.", - "Integration Manager": "Integration Manager", + "Integration manager": "Integration manager", "Find others by phone or email": "Find others by phone or email", "Be found by phone or email": "Be found by phone or email", "Use bots, bridges, widgets and sticker packs": "Use bots, bridges, widgets and sticker packs", diff --git a/src/indexing/BaseEventIndexManager.ts b/src/indexing/BaseEventIndexManager.ts index 4bae3e7c1d..64576e4412 100644 --- a/src/indexing/BaseEventIndexManager.ts +++ b/src/indexing/BaseEventIndexManager.ts @@ -14,47 +14,16 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { IMatrixProfile, IEventWithRoomId as IMatrixEvent, IResultRoomEvents } from "matrix-js-sdk/src/@types/search"; +import { Direction } from "matrix-js-sdk/src"; + // The following interfaces take their names and member names from seshat and the spec /* eslint-disable camelcase */ - -export interface IMatrixEvent { - type: string; - sender: string; - content: {}; - event_id: string; - origin_server_ts: number; - unsigned?: {}; - roomId: string; -} - -export interface IMatrixProfile { - avatar_url: string; - displayname: string; -} - export interface ICrawlerCheckpoint { roomId: string; token: string; fullCrawl?: boolean; - direction: string; -} - -export interface IResultContext { - events_before: [IMatrixEvent]; - events_after: [IMatrixEvent]; - profile_info: Map; -} - -export interface IResultsElement { - rank: number; - result: IMatrixEvent; - context: IResultContext; -} - -export interface ISearchResult { - count: number; - results: [IResultsElement]; - highlights: [string]; + direction: Direction; } export interface ISearchArgs { @@ -63,6 +32,8 @@ export interface ISearchArgs { after_limit: number; order_by_recency: boolean; room_id?: string; + limit: number; + next_batch?: string; } export interface IEventAndProfile { @@ -205,10 +176,10 @@ export default abstract class BaseEventIndexManager { * @param {ISearchArgs} searchArgs The search configuration for the search, * sets the search term and determines the search result contents. * - * @return {Promise<[ISearchResult]>} A promise that will resolve to an array + * @return {Promise} A promise that will resolve to an array * of search results once the search is done. */ - async searchEventIndex(searchArgs: ISearchArgs): Promise { + async searchEventIndex(searchArgs: ISearchArgs): Promise { throw new Error("Unimplemented"); } diff --git a/src/indexing/EventIndex.ts b/src/indexing/EventIndex.ts index 76104455f7..a5827fc599 100644 --- a/src/indexing/EventIndex.ts +++ b/src/indexing/EventIndex.ts @@ -23,6 +23,7 @@ import { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set'; import { RoomState } from 'matrix-js-sdk/src/models/room-state'; import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window'; import { sleep } from "matrix-js-sdk/src/utils"; +import { IResultRoomEvents } from "matrix-js-sdk/src/@types/search"; import PlatformPeg from "../PlatformPeg"; import { MatrixClientPeg } from "../MatrixClientPeg"; @@ -114,14 +115,14 @@ export default class EventIndex extends EventEmitter { const backCheckpoint: ICrawlerCheckpoint = { roomId: room.roomId, token: token, - direction: "b", + direction: Direction.Backward, fullCrawl: true, }; const forwardCheckpoint: ICrawlerCheckpoint = { roomId: room.roomId, token: token, - direction: "f", + direction: Direction.Forward, }; try { @@ -384,7 +385,7 @@ export default class EventIndex extends EventEmitter { roomId: room.roomId, token: token, fullCrawl: fullCrawl, - direction: "b", + direction: Direction.Backward, }; console.log("EventIndex: Adding checkpoint", checkpoint); @@ -671,10 +672,10 @@ export default class EventIndex extends EventEmitter { * @param {ISearchArgs} searchArgs The search configuration for the search, * sets the search term and determines the search result contents. * - * @return {Promise<[SearchResult]>} A promise that will resolve to an array + * @return {Promise} A promise that will resolve to an array * of search results once the search is done. */ - public async search(searchArgs: ISearchArgs) { + public async search(searchArgs: ISearchArgs): Promise { const indexManager = PlatformPeg.get().getEventIndexingManager(); return indexManager.searchEventIndex(searchArgs); } diff --git a/src/models/IUpload.ts b/src/models/IUpload.ts index 5b376e9330..1b5a13e394 100644 --- a/src/models/IUpload.ts +++ b/src/models/IUpload.ts @@ -14,11 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { IAbortablePromise } from "matrix-js-sdk/src/@types/partials"; + export interface IUpload { fileName: string; roomId: string; total: number; loaded: number; - promise: Promise; + promise: IAbortablePromise; canceled?: boolean; } diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 163bfad2b3..0e84775f96 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -819,7 +819,7 @@ export const SETTINGS: {[setting: string]: ISetting} = { [UIFeature.IdentityServer]: { supportedLevels: LEVELS_UI_FEATURE, default: true, - // Identity Server (Discovery) Settings make no sense if 3PIDs in general are hidden + // Identity server (discovery) settings make no sense if 3PIDs in general are hidden controller: new UIFeatureController(UIFeature.ThirdPartyID), }, [UIFeature.ThirdPartyID]: { diff --git a/src/settings/handlers/AccountSettingsHandler.ts b/src/settings/handlers/AccountSettingsHandler.ts index 60ec849883..9c937ebd88 100644 --- a/src/settings/handlers/AccountSettingsHandler.ts +++ b/src/settings/handlers/AccountSettingsHandler.ts @@ -123,12 +123,13 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa return preferredValue; } - public setValue(settingName: string, roomId: string, newValue: any): Promise { + public async setValue(settingName: string, roomId: string, newValue: any): Promise { // Special case URL previews if (settingName === "urlPreviewsEnabled") { const content = this.getSettings("org.matrix.preview_urls") || {}; content['disable'] = !newValue; - return MatrixClientPeg.get().setAccountData("org.matrix.preview_urls", content); + await MatrixClientPeg.get().setAccountData("org.matrix.preview_urls", content); + return; } // Special case for breadcrumbs @@ -141,26 +142,29 @@ export default class AccountSettingsHandler extends MatrixClientBackedSettingsHa if (!content) content = {}; // If we still don't have content, make some content['recent_rooms'] = newValue; - return MatrixClientPeg.get().setAccountData(BREADCRUMBS_EVENT_TYPE, content); + await MatrixClientPeg.get().setAccountData(BREADCRUMBS_EVENT_TYPE, content); + return; } // Special case recent emoji if (settingName === "recent_emoji") { const content = this.getSettings(RECENT_EMOJI_EVENT_TYPE) || {}; content["recent_emoji"] = newValue; - return MatrixClientPeg.get().setAccountData(RECENT_EMOJI_EVENT_TYPE, content); + await MatrixClientPeg.get().setAccountData(RECENT_EMOJI_EVENT_TYPE, content); + return; } // Special case integration manager provisioning if (settingName === "integrationProvisioning") { const content = this.getSettings(INTEG_PROVISIONING_EVENT_TYPE) || {}; content['enabled'] = newValue; - return MatrixClientPeg.get().setAccountData(INTEG_PROVISIONING_EVENT_TYPE, content); + await MatrixClientPeg.get().setAccountData(INTEG_PROVISIONING_EVENT_TYPE, content); + return; } const content = this.getSettings() || {}; content[settingName] = newValue; - return MatrixClientPeg.get().setAccountData("im.vector.web.settings", content); + await MatrixClientPeg.get().setAccountData("im.vector.web.settings", content); } public canSetValue(settingName: string, roomId: string): boolean { diff --git a/src/settings/handlers/RoomAccountSettingsHandler.ts b/src/settings/handlers/RoomAccountSettingsHandler.ts index e0345fde8c..a5ebfae621 100644 --- a/src/settings/handlers/RoomAccountSettingsHandler.ts +++ b/src/settings/handlers/RoomAccountSettingsHandler.ts @@ -86,22 +86,24 @@ export default class RoomAccountSettingsHandler extends MatrixClientBackedSettin return settings[settingName]; } - public setValue(settingName: string, roomId: string, newValue: any): Promise { + public async setValue(settingName: string, roomId: string, newValue: any): Promise { // Special case URL previews if (settingName === "urlPreviewsEnabled") { const content = this.getSettings(roomId, "org.matrix.room.preview_urls") || {}; content['disable'] = !newValue; - return MatrixClientPeg.get().setRoomAccountData(roomId, "org.matrix.room.preview_urls", content); + await MatrixClientPeg.get().setRoomAccountData(roomId, "org.matrix.room.preview_urls", content); + return; } // Special case allowed widgets if (settingName === "allowedWidgets") { - return MatrixClientPeg.get().setRoomAccountData(roomId, ALLOWED_WIDGETS_EVENT_TYPE, newValue); + await MatrixClientPeg.get().setRoomAccountData(roomId, ALLOWED_WIDGETS_EVENT_TYPE, newValue); + return; } const content = this.getSettings(roomId) || {}; content[settingName] = newValue; - return MatrixClientPeg.get().setRoomAccountData(roomId, "im.vector.web.settings", content); + await MatrixClientPeg.get().setRoomAccountData(roomId, "im.vector.web.settings", content); } public canSetValue(settingName: string, roomId: string): boolean { diff --git a/src/settings/handlers/RoomSettingsHandler.ts b/src/settings/handlers/RoomSettingsHandler.ts index 3315e40a65..974f94062c 100644 --- a/src/settings/handlers/RoomSettingsHandler.ts +++ b/src/settings/handlers/RoomSettingsHandler.ts @@ -87,17 +87,18 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl return settings[settingName]; } - public setValue(settingName: string, roomId: string, newValue: any): Promise { + public async setValue(settingName: string, roomId: string, newValue: any): Promise { // Special case URL previews if (settingName === "urlPreviewsEnabled") { const content = this.getSettings(roomId, "org.matrix.room.preview_urls") || {}; content['disable'] = !newValue; - return MatrixClientPeg.get().sendStateEvent(roomId, "org.matrix.room.preview_urls", content); + await MatrixClientPeg.get().sendStateEvent(roomId, "org.matrix.room.preview_urls", content); + return; } const content = this.getSettings(roomId) || {}; content[settingName] = newValue; - return MatrixClientPeg.get().sendStateEvent(roomId, "im.vector.web.settings", content, ""); + await MatrixClientPeg.get().sendStateEvent(roomId, "im.vector.web.settings", content, ""); } public canSetValue(settingName: string, roomId: string): boolean { diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts index a3b07435c6..aceaf8b898 100644 --- a/src/stores/BreadcrumbsStore.ts +++ b/src/stores/BreadcrumbsStore.ts @@ -22,6 +22,7 @@ import defaultDispatcher from "../dispatcher/dispatcher"; import { arrayHasDiff } from "../utils/arrays"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { SettingLevel } from "../settings/SettingLevel"; +import SpaceStore from "./SpaceStore"; const MAX_ROOMS = 20; // arbitrary const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up @@ -122,7 +123,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { } private async appendRoom(room: Room) { - if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return; // hide space rooms + if (SpaceStore.spacesEnabled && room.isSpaceRoom()) return; // hide space rooms let updated = false; const rooms = (this.state.rooms || []).slice(); // cheap clone diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index 6300c1a936..1a6b5109ec 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -18,6 +18,7 @@ import { ListIteratee, Many, sortBy, throttle } from "lodash"; import { EventType, RoomType } from "matrix-js-sdk/src/@types/event"; import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { ISpaceSummaryRoom } from "matrix-js-sdk/src/@types/spaces"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import defaultDispatcher from "../dispatcher/dispatcher"; @@ -31,7 +32,6 @@ import { RoomNotificationStateStore } from "./notifications/RoomNotificationStat import { DefaultTagID } from "./room-list/models"; import { EnhancedMap, mapDiff } from "../utils/maps"; import { setHasDiff } from "../utils/sets"; -import { ISpaceSummaryEvent, ISpaceSummaryRoom } from "../components/structures/SpaceRoomDirectory"; import RoomViewStore from "./RoomViewStore"; import { Action } from "../dispatcher/actions"; import { arrayHasDiff } from "../utils/arrays"; @@ -59,7 +59,13 @@ export interface ISuggestedRoom extends ISpaceSummaryRoom { const MAX_SUGGESTED_ROOMS = 20; -const homeSpaceKey = SettingsStore.getValue("feature_spaces.all_rooms") ? "ALL_ROOMS" : "HOME_SPACE"; +// All of these settings cause the page to reload and can be costly if read frequently, so read them here only +const spacesEnabled = SettingsStore.getValue("feature_spaces"); +const spacesTweakAllRoomsEnabled = SettingsStore.getValue("feature_spaces.all_rooms"); +const spacesTweakSpaceMemberDMsEnabled = SettingsStore.getValue("feature_spaces.space_member_dms"); +const spacesTweakSpaceDMBadgesEnabled = SettingsStore.getValue("feature_spaces.space_dm_badges"); + +const homeSpaceKey = spacesTweakAllRoomsEnabled ? "ALL_ROOMS" : "HOME_SPACE"; const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || homeSpaceKey}`; const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms] @@ -184,10 +190,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { public fetchSuggestedRooms = async (space: Room, limit = MAX_SUGGESTED_ROOMS): Promise => { try { - const data: { - rooms: ISpaceSummaryRoom[]; - events: ISpaceSummaryEvent[]; - } = await this.matrixClient.getSpaceSummary(space.roomId, 0, true, false, limit); + const data = await this.matrixClient.getSpaceSummary(space.roomId, 0, true, false, limit); const viaMap = new EnhancedMap>(); data.events.forEach(ev => { @@ -263,7 +266,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } public getSpaceFilteredRoomIds = (space: Room | null): Set => { - if (!space && SettingsStore.getValue("feature_spaces.all_rooms")) { + if (!space && spacesTweakAllRoomsEnabled) { return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId)); } return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set(); @@ -360,7 +363,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }; private showInHomeSpace = (room: Room) => { - if (SettingsStore.getValue("feature_spaces.all_rooms")) return true; + if (spacesTweakAllRoomsEnabled) return true; if (room.isSpaceRoom()) return false; return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space || DMRoomMap.shared().getUserIdForRoomId(room.roomId) // put all DMs in the Home Space @@ -392,7 +395,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const oldFilteredRooms = this.spaceFilteredRooms; this.spaceFilteredRooms = new Map(); - if (!SettingsStore.getValue("feature_spaces.all_rooms")) { + if (!spacesTweakAllRoomsEnabled) { // put all room invites in the Home Space const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite"); this.spaceFilteredRooms.set(HOME_SPACE, new Set(invites.map(room => room.roomId))); @@ -419,7 +422,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const roomIds = new Set(childRooms.map(r => r.roomId)); const space = this.matrixClient?.getRoom(spaceId); - if (SettingsStore.getValue("feature_spaces.space_member_dms")) { + if (spacesTweakSpaceMemberDMsEnabled) { // Add relevant DMs space?.getMembers().forEach(member => { if (member.membership !== "join" && member.membership !== "invite") return; @@ -453,7 +456,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // Update NotificationStates this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => { if (roomIds.has(room.roomId)) { - if (s !== HOME_SPACE && SettingsStore.getValue("feature_spaces.space_dm_badges")) return true; + if (s !== HOME_SPACE && spacesTweakSpaceDMBadgesEnabled) return true; return !DMRoomMap.shared().getUserIdForRoomId(room.roomId) || RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite); @@ -552,7 +555,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // TODO confirm this after implementing parenting behaviour if (room.isSpaceRoom()) { this.onSpaceUpdate(); - } else if (!SettingsStore.getValue("feature_spaces.all_rooms")) { + } else if (!spacesTweakAllRoomsEnabled) { this.onRoomUpdate(room); } this.emit(room.roomId); @@ -576,7 +579,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { if (order !== lastOrder) { this.notifyIfOrderChanged(); } - } else if (ev.getType() === EventType.Tag && !SettingsStore.getValue("feature_spaces.all_rooms")) { + } else if (ev.getType() === EventType.Tag && !spacesTweakAllRoomsEnabled) { // If the room was in favourites and now isn't or the opposite then update its position in the trees const oldTags = lastEv?.getContent()?.tags || {}; const newTags = ev.getContent()?.tags || {}; @@ -616,13 +619,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } protected async onNotReady() { - if (!SettingsStore.getValue("feature_spaces")) return; + if (!SpaceStore.spacesEnabled) return; if (this.matrixClient) { this.matrixClient.removeListener("Room", this.onRoom); this.matrixClient.removeListener("Room.myMembership", this.onRoom); this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData); this.matrixClient.removeListener("RoomState.events", this.onRoomState); - if (!SettingsStore.getValue("feature_spaces.all_rooms")) { + if (!spacesTweakAllRoomsEnabled) { this.matrixClient.removeListener("accountData", this.onAccountData); } } @@ -630,12 +633,12 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } protected async onReady() { - if (!SettingsStore.getValue("feature_spaces")) return; + if (!spacesEnabled) return; this.matrixClient.on("Room", this.onRoom); this.matrixClient.on("Room.myMembership", this.onRoom); this.matrixClient.on("Room.accountData", this.onRoomAccountData); this.matrixClient.on("RoomState.events", this.onRoomState); - if (!SettingsStore.getValue("feature_spaces.all_rooms")) { + if (!spacesTweakAllRoomsEnabled) { this.matrixClient.on("accountData", this.onAccountData); } @@ -649,7 +652,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } protected async onAction(payload: ActionPayload) { - if (!SettingsStore.getValue("feature_spaces")) return; + if (!spacesEnabled) return; switch (payload.action) { case "view_room": { // Don't auto-switch rooms when reacting to a context-switch @@ -663,7 +666,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // as it will cause you to end up in the wrong room this.setActiveSpace(room, false); } else if ( - (!SettingsStore.getValue("feature_spaces.all_rooms") || this.activeSpace) && + (!spacesTweakAllRoomsEnabled || this.activeSpace) && !this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId) ) { this.switchToRelatedSpace(roomId); @@ -755,6 +758,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } export default class SpaceStore { + public static spacesEnabled = spacesEnabled; + public static spacesTweakAllRoomsEnabled = spacesTweakAllRoomsEnabled; + public static spacesTweakSpaceMemberDMsEnabled = spacesTweakSpaceMemberDMsEnabled; + public static spacesTweakSpaceDMBadgesEnabled = spacesTweakSpaceDMBadgesEnabled; + private static internalInstance = new SpaceStoreClass(); public static get instance(): SpaceStoreClass { diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index e26c80bb2d..bedbfebd7f 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -35,6 +35,7 @@ import { NameFilterCondition } from "./filters/NameFilterCondition"; import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore"; import { VisibilityProvider } from "./filters/VisibilityProvider"; import { SpaceWatcher } from "./SpaceWatcher"; +import SpaceStore from "../SpaceStore"; interface IState { tagsEnabled?: boolean; @@ -73,10 +74,11 @@ export class RoomListStoreClass extends AsyncStoreWithClient { constructor() { super(defaultDispatcher); + this.setMaxListeners(20); // CustomRoomTagStore + RoomList + LeftPanel + 8xRoomSubList + spares } private setupWatchers() { - if (SettingsStore.getValue("feature_spaces")) { + if (SpaceStore.spacesEnabled) { this.spaceWatcher = new SpaceWatcher(this); } else { this.tagWatcher = new TagWatcher(this); @@ -608,9 +610,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { // if spaces are enabled only consider the prefilter conditions when there are no runtime conditions // for the search all spaces feature - if (this.prefilterConditions.length > 0 - && (!SettingsStore.getValue("feature_spaces") || !this.filterConditions.length) - ) { + if (this.prefilterConditions.length > 0 && (!SpaceStore.spacesEnabled || !this.filterConditions.length)) { rooms = rooms.filter(r => { for (const filter of this.prefilterConditions) { if (!filter.isVisible(r)) { @@ -682,7 +682,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { } else { this.filterConditions.push(filter); // Runtime filters with spaces disable prefiltering for the search all spaces feature - if (SettingsStore.getValue("feature_spaces")) { + if (SpaceStore.spacesEnabled) { // this has to be awaited so that `setKnownRooms` is called in time for the `addFilterCondition` below // this way the runtime filters are only evaluated on one dataset and not both. await this.recalculatePrefiltering(); @@ -715,7 +715,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient { this.algorithm.removeFilterCondition(filter); } // Runtime filters with spaces disable prefiltering for the search all spaces feature - if (SettingsStore.getValue("feature_spaces")) { + if (SpaceStore.spacesEnabled) { promise = this.recalculatePrefiltering(); } } diff --git a/src/stores/room-list/SpaceWatcher.ts b/src/stores/room-list/SpaceWatcher.ts index a1f7786578..1cec612e6f 100644 --- a/src/stores/room-list/SpaceWatcher.ts +++ b/src/stores/room-list/SpaceWatcher.ts @@ -19,7 +19,6 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { RoomListStoreClass } from "./RoomListStore"; import { SpaceFilterCondition } from "./filters/SpaceFilterCondition"; import SpaceStore, { UPDATE_SELECTED_SPACE } from "../SpaceStore"; -import SettingsStore from "../../settings/SettingsStore"; /** * Watches for changes in spaces to manage the filter on the provided RoomListStore @@ -29,7 +28,7 @@ export class SpaceWatcher { private activeSpace: Room = SpaceStore.instance.activeSpace; constructor(private store: RoomListStoreClass) { - if (!SettingsStore.getValue("feature_spaces.all_rooms")) { + if (!SpaceStore.spacesTweakAllRoomsEnabled) { this.filter = new SpaceFilterCondition(); this.updateFilter(); store.addFilter(this.filter); @@ -41,7 +40,7 @@ export class SpaceWatcher { this.activeSpace = activeSpace; if (this.filter) { - if (activeSpace || !SettingsStore.getValue("feature_spaces.all_rooms")) { + if (activeSpace || !SpaceStore.spacesTweakAllRoomsEnabled) { this.updateFilter(); } else { this.store.removeFilter(this.filter); diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 024c484c41..f50d112248 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -34,6 +34,7 @@ import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm"; import { getListAlgorithmInstance } from "./list-ordering"; import SettingsStore from "../../../settings/SettingsStore"; import { VisibilityProvider } from "../filters/VisibilityProvider"; +import SpaceStore from "../../SpaceStore"; /** * Fired when the Algorithm has determined a list has been updated. @@ -199,7 +200,7 @@ export class Algorithm extends EventEmitter { } private async doUpdateStickyRoom(val: Room) { - if (SettingsStore.getValue("feature_spaces") && val?.isSpaceRoom() && val.getMyMembership() !== "invite") { + if (SpaceStore.spacesEnabled && val?.isSpaceRoom() && val.getMyMembership() !== "invite") { // no-op sticky rooms for spaces - they're effectively virtual rooms val = null; } diff --git a/src/stores/room-list/filters/VisibilityProvider.ts b/src/stores/room-list/filters/VisibilityProvider.ts index a6c55226b0..f63b622053 100644 --- a/src/stores/room-list/filters/VisibilityProvider.ts +++ b/src/stores/room-list/filters/VisibilityProvider.ts @@ -18,7 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import CallHandler from "../../../CallHandler"; import { RoomListCustomisations } from "../../../customisations/RoomList"; import VoipUserMapper from "../../../VoipUserMapper"; -import SettingsStore from "../../../settings/SettingsStore"; +import SpaceStore from "../../SpaceStore"; export class VisibilityProvider { private static internalInstance: VisibilityProvider; @@ -50,7 +50,7 @@ export class VisibilityProvider { } // hide space rooms as they'll be shown in the SpacePanel - if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) { + if (SpaceStore.spacesEnabled && room.isSpaceRoom()) { return false; } diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 36791d3dd9..24869b5edc 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -51,7 +51,7 @@ import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; import { getCustomTheme } from "../../theme"; import CountlyAnalytics from "../../CountlyAnalytics"; import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities"; -import { MatrixEvent, IEvent } from "matrix-js-sdk/src/models/event"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { ELEMENT_CLIENT_ID } from "../../identifiers"; import { getUserLanguage } from "../../languageHandler"; @@ -415,7 +415,7 @@ export class StopGapWidget extends EventEmitter { private feedEvent(ev: MatrixEvent) { if (!this.messaging) return; - const raw = ev.event as IEvent; + const raw = ev.getEffectiveEvent(); this.messaging.feedEvent(raw).catch(e => { console.error("Error sending event to widget: ", e); }); diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index fd064bae61..13cd260ef0 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -159,12 +159,12 @@ export class StopGapWidgetDriver extends WidgetDriver { if (results.length >= limit) break; const ev = events[i]; - if (ev.getType() !== eventType) continue; + if (ev.getType() !== eventType || ev.isState()) continue; if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()['msgtype']) continue; results.push(ev); } - return results.map(e => e.event); + return results.map(e => e.getEffectiveEvent()); } public async readStateEvents(eventType: string, stateKey: string | undefined, limit: number): Promise { diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index 1a467b157f..849e546485 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -19,6 +19,9 @@ import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event'; import { MatrixClientPeg } from '../MatrixClientPeg'; import shouldHideEvent from "../shouldHideEvent"; +import { getHandlerTile, haveTileForEvent } from "../components/views/rooms/EventTile"; +import SettingsStore from "../settings/SettingsStore"; +import { EventType } from "matrix-js-sdk/src/@types/event"; /** * Returns whether an event should allow actions like reply, reactions, edit, etc. @@ -96,3 +99,38 @@ export function findEditableEvent(room: Room, isForward: boolean, fromEventId: s } } +export function getEventDisplayInfo(mxEvent: MatrixEvent): { + isInfoMessage: boolean; + tileHandler: string; + isBubbleMessage: boolean; +} { + const content = mxEvent.getContent(); + const msgtype = content.msgtype; + const eventType = mxEvent.getType(); + + let tileHandler = getHandlerTile(mxEvent); + + // Info messages are basically information about commands processed on a room + let isBubbleMessage = eventType.startsWith("m.key.verification") || + (eventType === EventType.RoomMessage && msgtype && msgtype.startsWith("m.key.verification")) || + (eventType === EventType.RoomCreate) || + (eventType === EventType.RoomEncryption) || + (tileHandler === "messages.MJitsiWidgetEvent"); + let isInfoMessage = ( + !isBubbleMessage && eventType !== EventType.RoomMessage && + eventType !== EventType.Sticker && eventType !== EventType.RoomCreate + ); + + // If we're showing hidden events in the timeline, we should use the + // source tile when there's no regular tile for an event and also for + // replace relations (which otherwise would display as a confusing + // duplicate of the thing they are replacing). + if (SettingsStore.getValue("showHiddenEventsInTimeline") && !haveTileForEvent(mxEvent)) { + tileHandler = "messages.ViewSourceEvent"; + isBubbleMessage = false; + // Reuse info message avatar and sender profile styling + isInfoMessage = true; + } + + return { tileHandler, isInfoMessage, isBubbleMessage }; +} diff --git a/src/utils/Timer.ts b/src/utils/Timer.ts index 2317ed934b..38703c1299 100644 --- a/src/utils/Timer.ts +++ b/src/utils/Timer.ts @@ -26,7 +26,7 @@ Once a timer is finished or aborted, it can't be started again a new one through `clone()` or `cloneIfRun()`. */ export default class Timer { - private timerHandle: NodeJS.Timeout; + private timerHandle: number; private startTs: number; private promise: Promise; private resolve: () => void; diff --git a/src/utils/WidgetUtils.ts b/src/utils/WidgetUtils.ts index 222837511d..ea56f2a563 100644 --- a/src/utils/WidgetUtils.ts +++ b/src/utils/WidgetUtils.ts @@ -386,7 +386,7 @@ export default class WidgetUtils { }); } - static removeIntegrationManagerWidgets(): Promise { + static async removeIntegrationManagerWidgets(): Promise { const client = MatrixClientPeg.get(); if (!client) { throw new Error('User not logged in'); @@ -399,7 +399,7 @@ export default class WidgetUtils { delete userWidgets[key]; } }); - return client.setAccountData('m.widgets', userWidgets); + await client.setAccountData('m.widgets', userWidgets); } static addIntegrationManagerWidget(name: string, uiUrl: string, apiUrl: string): Promise { @@ -407,7 +407,7 @@ export default class WidgetUtils { "integration_manager_" + (new Date().getTime()), WidgetType.INTEGRATION_MANAGER, uiUrl, - "Integration Manager: " + name, + "Integration manager: " + name, { "api_url": apiUrl }, ); } @@ -416,7 +416,7 @@ export default class WidgetUtils { * Remove all stickerpicker widgets (stickerpickers are user widgets by nature) * @return {Promise} Resolves on account data updated */ - static removeStickerpickerWidgets(): Promise { + static async removeStickerpickerWidgets(): Promise { const client = MatrixClientPeg.get(); if (!client) { throw new Error('User not logged in'); @@ -429,7 +429,7 @@ export default class WidgetUtils { delete userWidgets[key]; } }); - return client.setAccountData('m.widgets', userWidgets); + await client.setAccountData('m.widgets', userWidgets); } static makeAppConfig( diff --git a/src/utils/createMatrixClient.ts b/src/utils/createMatrixClient.ts index caaf75616d..0cce729e65 100644 --- a/src/utils/createMatrixClient.ts +++ b/src/utils/createMatrixClient.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +// @ts-ignore - `.ts` is needed here to make TS happy +import IndexedDBWorker from "../workers/indexeddb.worker.ts"; import { createClient, ICreateClientOpts } from "matrix-js-sdk/src/matrix"; import { IndexedDBCryptoStore } from "matrix-js-sdk/src/crypto/store/indexeddb-crypto-store"; import { WebStorageSessionStore } from "matrix-js-sdk/src/store/session/webstorage"; @@ -35,10 +37,6 @@ try { * @param {Object} opts options to pass to Matrix.createClient. This will be * extended with `sessionStore` and `store` members. * - * @property {string} indexedDbWorkerScript Optional URL for a web worker script - * for IndexedDB store operations. By default, indexeddb ops are done on - * the main thread. - * * @returns {MatrixClient} the newly-created MatrixClient */ export default function createMatrixClient(opts: ICreateClientOpts) { @@ -51,7 +49,7 @@ export default function createMatrixClient(opts: ICreateClientOpts) { indexedDB: indexedDB, dbName: "riot-web-sync", localStorage: localStorage, - workerScript: createMatrixClient.indexedDbWorkerScript, + workerFactory: () => new IndexedDBWorker(), }); } @@ -70,5 +68,3 @@ export default function createMatrixClient(opts: ICreateClientOpts) { ...opts, }); } - -createMatrixClient.indexedDbWorkerScript = null; diff --git a/src/verification.ts b/src/verification.ts index 719c0ec5b3..98844302df 100644 --- a/src/verification.ts +++ b/src/verification.ts @@ -22,7 +22,7 @@ import Modal from './Modal'; import { RightPanelPhases } from "./stores/RightPanelStorePhases"; import { findDMForUser } from './createRoom'; import { accessSecretStorage } from './SecurityManager'; -import { verificationMethods } from 'matrix-js-sdk/src/crypto'; +import { verificationMethods as VerificationMethods } from 'matrix-js-sdk/src/crypto'; import { Action } from './dispatcher/actions'; import UntrustedDeviceDialog from "./components/views/dialogs/UntrustedDeviceDialog"; import { IDevice } from "./components/views/right_panel/UserInfo"; @@ -63,7 +63,7 @@ export async function verifyDevice(user: User, device: IDevice) { const verificationRequestPromise = cli.legacyDeviceVerification( user.userId, device.deviceId, - verificationMethods.SAS, + VerificationMethods.SAS, ); dis.dispatch({ action: Action.SetRightPanelPhase, diff --git a/src/workers/blurhash.worker.ts b/src/workers/blurhash.worker.ts new file mode 100644 index 0000000000..031cc67c90 --- /dev/null +++ b/src/workers/blurhash.worker.ts @@ -0,0 +1,38 @@ +/* +Copyright 2021 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 { encode } from "blurhash"; + +const ctx: Worker = self as any; + +interface IBlurhashWorkerRequest { + seq: number; + imageData: ImageData; +} + +ctx.addEventListener("message", (event: MessageEvent): void => { + const { seq, imageData } = event.data; + const blurhash = encode( + imageData.data, + imageData.width, + imageData.height, + // use 4 components on the longer dimension, if square then both + imageData.width >= imageData.height ? 4 : 3, + imageData.height >= imageData.width ? 4 : 3, + ); + + ctx.postMessage({ seq, blurhash }); +}); diff --git a/src/workers/indexeddb.worker.ts b/src/workers/indexeddb.worker.ts new file mode 100644 index 0000000000..a05add1c7d --- /dev/null +++ b/src/workers/indexeddb.worker.ts @@ -0,0 +1,23 @@ +/* +Copyright 2017 Vector Creations Ltd + +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 { IndexedDBStoreWorker } from "matrix-js-sdk/src/indexeddb-worker"; + +const ctx: Worker = self as any; + +const remoteWorker = new IndexedDBStoreWorker(ctx.postMessage); + +ctx.onmessage = remoteWorker.onMessage; diff --git a/test/editor/caret-test.js b/test/editor/caret-test.js index e1a66a4431..33b40e1c64 100644 --- a/test/editor/caret-test.js +++ b/test/editor/caret-test.js @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import "../skinned-sdk"; // Must be first for skinning to work import { getLineAndNodePosition } from "../../src/editor/caret"; import EditorModel from "../../src/editor/model"; import { createPartCreator } from "./mock"; diff --git a/test/editor/model-test.js b/test/editor/model-test.js index 35bd4143a7..15c5af5806 100644 --- a/test/editor/model-test.js +++ b/test/editor/model-test.js @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import "../skinned-sdk"; // Must be first for skinning to work import EditorModel from "../../src/editor/model"; import { createPartCreator, createRenderer } from "./mock"; diff --git a/test/editor/operations-test.js b/test/editor/operations-test.js index 32ccaa5440..17a4c8ba11 100644 --- a/test/editor/operations-test.js +++ b/test/editor/operations-test.js @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import "../skinned-sdk"; // Must be first for skinning to work import EditorModel from "../../src/editor/model"; import { createPartCreator, createRenderer } from "./mock"; import { toggleInlineFormat } from "../../src/editor/operations"; diff --git a/test/editor/position-test.js b/test/editor/position-test.js index 813a8e9f7f..ea8658b216 100644 --- a/test/editor/position-test.js +++ b/test/editor/position-test.js @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import "../skinned-sdk"; // Must be first for skinning to work import EditorModel from "../../src/editor/model"; import { createPartCreator } from "./mock"; diff --git a/test/editor/range-test.js b/test/editor/range-test.js index d411a0d911..87c5b06e44 100644 --- a/test/editor/range-test.js +++ b/test/editor/range-test.js @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import "../skinned-sdk"; // Must be first for skinning to work import EditorModel from "../../src/editor/model"; import { createPartCreator, createRenderer } from "./mock"; diff --git a/test/editor/serialize-test.js b/test/editor/serialize-test.js index 691130bd34..085a8afdba 100644 --- a/test/editor/serialize-test.js +++ b/test/editor/serialize-test.js @@ -14,6 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import "../skinned-sdk"; // Must be first for skinning to work import EditorModel from "../../src/editor/model"; import { htmlSerializeIfNeeded } from "../../src/editor/serialize"; import { createPartCreator } from "./mock"; diff --git a/test/end-to-end-tests/synapse/config-templates/consent/homeserver.yaml b/test/end-to-end-tests/synapse/config-templates/consent/homeserver.yaml index deb750666f..13aea8d18d 100644 --- a/test/end-to-end-tests/synapse/config-templates/consent/homeserver.yaml +++ b/test/end-to-end-tests/synapse/config-templates/consent/homeserver.yaml @@ -572,11 +572,11 @@ uploads_path: "{{SYNAPSE_ROOT}}uploads" ## Captcha ## # See docs/CAPTCHA_SETUP for full details of configuring this. -# This Home Server's ReCAPTCHA public key. +# This homeserver's ReCAPTCHA public key. # #recaptcha_public_key: "YOUR_PUBLIC_KEY" -# This Home Server's ReCAPTCHA private key. +# This homeserver's ReCAPTCHA private key. # #recaptcha_private_key: "YOUR_PRIVATE_KEY" @@ -685,7 +685,7 @@ registration_shared_secret: "{{REGISTRATION_SHARED_SECRET}}" # The list of identity servers trusted to verify third party # identifiers by this server. # -# Also defines the ID server which will be called when an account is +# Also defines the identity server which will be called when an account is # deactivated (one will be picked arbitrarily). # #trusted_third_party_id_servers: @@ -889,7 +889,7 @@ email: smtp_user: "exampleusername" smtp_pass: "examplepassword" require_transport_security: False - notif_from: "Your Friendly %(app)s Home Server " + notif_from: "Your Friendly %(app)s homeserver " app_name: Matrix # if template_dir is unset, uses the example templates that are part of # the Synapse distribution. diff --git a/test/stores/SpaceStore-setup.ts b/test/stores/SpaceStore-setup.ts new file mode 100644 index 0000000000..67d492255f --- /dev/null +++ b/test/stores/SpaceStore-setup.ts @@ -0,0 +1,23 @@ +/* +Copyright 2021 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 needs to be executed before the SpaceStore gets imported but due to ES6 import hoisting we have to do this here. +// SpaceStore reads the SettingsStore which needs the localStorage values set at init time. + +localStorage.setItem("mx_labs_feature_feature_spaces", "true"); +localStorage.setItem("mx_labs_feature_feature_spaces.all_rooms", "true"); +localStorage.setItem("mx_labs_feature_feature_spaces.space_member_dms", "true"); +localStorage.setItem("mx_labs_feature_feature_spaces.space_dm_badges", "false"); diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts index 4cbd9f43c8..eb28a72d67 100644 --- a/test/stores/SpaceStore-test.ts +++ b/test/stores/SpaceStore-test.ts @@ -16,7 +16,9 @@ limitations under the License. import { EventEmitter } from "events"; import { EventType } from "matrix-js-sdk/src/@types/event"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import "./SpaceStore-setup"; // enable space lab import "../skinned-sdk"; // Must be first for skinning to work import SpaceStore, { UPDATE_INVITED_SPACES, @@ -26,13 +28,10 @@ import SpaceStore, { import { resetAsyncStoreWithClient, setupAsyncStoreWithClient } from "../utils/test-utils"; import { mkEvent, mkStubRoom, stubClient } from "../test-utils"; import { EnhancedMap } from "../../src/utils/maps"; -import SettingsStore from "../../src/settings/SettingsStore"; import DMRoomMap from "../../src/utils/DMRoomMap"; import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import defaultDispatcher from "../../src/dispatcher/dispatcher"; -type MatrixEvent = any; // importing from js-sdk upsets things - jest.useFakeTimers(); const mockStateEventImplementation = (events: MatrixEvent[]) => { @@ -79,9 +78,6 @@ const mkSpace = (spaceId: string, children: string[] = []) => { return space; }; -const getValue = jest.fn(); -SettingsStore.getValue = getValue; - const getUserIdForRoomId = jest.fn(); // @ts-ignore DMRoomMap.sharedInstance = { getUserIdForRoomId }; @@ -122,18 +118,6 @@ describe("SpaceStore", () => { beforeEach(() => { jest.runAllTimers(); client.getVisibleRooms.mockReturnValue(rooms = []); - getValue.mockImplementation(settingName => { - switch (settingName) { - case "feature_spaces": - return true; - case "feature_spaces.all_rooms": - return true; - case "feature_spaces.space_member_dms": - return true; - case "feature_spaces.space_dm_badges": - return false; - } - }); }); afterEach(async () => { await resetAsyncStoreWithClient(store);