diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index fb237a5845..e9ede862d2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,15 @@ + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 22b35b7c59..73b383d76d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,152 @@ +Changes in [3.26.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.26.0) (2021-07-19) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.26.0-rc.1...v3.26.0) + + * Fix 'User' type import + [\#6376](https://github.com/matrix-org/matrix-react-sdk/pull/6376) + +Changes in [3.26.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.26.0-rc.1) (2021-07-14) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.25.0...v3.26.0-rc.1) + + * Fix voice messages in right panels + [\#6370](https://github.com/matrix-org/matrix-react-sdk/pull/6370) + * Use TileShape enum more universally + [\#6369](https://github.com/matrix-org/matrix-react-sdk/pull/6369) + * Translations update from Weblate + [\#6373](https://github.com/matrix-org/matrix-react-sdk/pull/6373) + * Hide world readable history option in encrypted rooms + [\#5947](https://github.com/matrix-org/matrix-react-sdk/pull/5947) + * Make the Image View buttons easier to hit + [\#6372](https://github.com/matrix-org/matrix-react-sdk/pull/6372) + * Reorder buttons in the Image View + [\#6368](https://github.com/matrix-org/matrix-react-sdk/pull/6368) + * Add VS Code to gitignore + [\#6367](https://github.com/matrix-org/matrix-react-sdk/pull/6367) + * Fix inviter exploding due to member being null + [\#6362](https://github.com/matrix-org/matrix-react-sdk/pull/6362) + * Increase sample count in voice message thumbnail + [\#6359](https://github.com/matrix-org/matrix-react-sdk/pull/6359) + * Improve arraySeed utility + [\#6360](https://github.com/matrix-org/matrix-react-sdk/pull/6360) + * Convert FontManager to TS and stub it out for tests + [\#6358](https://github.com/matrix-org/matrix-react-sdk/pull/6358) + * Adjust recording waveform behaviour for voice messages + [\#6357](https://github.com/matrix-org/matrix-react-sdk/pull/6357) + * Do not honor string power levels + [\#6245](https://github.com/matrix-org/matrix-react-sdk/pull/6245) + * Add alias and directory customisation points + [\#6343](https://github.com/matrix-org/matrix-react-sdk/pull/6343) + * Fix multiinviter user already in room and clean up code + [\#6354](https://github.com/matrix-org/matrix-react-sdk/pull/6354) + * Fix right panel not closing user info when changing rooms + [\#6341](https://github.com/matrix-org/matrix-react-sdk/pull/6341) + * Quit sticker picker on m.sticker + [\#5679](https://github.com/matrix-org/matrix-react-sdk/pull/5679) + * Don't autodetect language in inline code blocks + [\#6350](https://github.com/matrix-org/matrix-react-sdk/pull/6350) + * Make ghost button background transparent + [\#6331](https://github.com/matrix-org/matrix-react-sdk/pull/6331) + * only consider valid & loaded url previews for show N more prompt + [\#6346](https://github.com/matrix-org/matrix-react-sdk/pull/6346) + * Extract MXCs from _matrix/media/r0/ URLs for inline images in messages + [\#6335](https://github.com/matrix-org/matrix-react-sdk/pull/6335) + * Fix small visual regression with the site name on url previews + [\#6342](https://github.com/matrix-org/matrix-react-sdk/pull/6342) + * Make PIP CallView draggable/movable + [\#5952](https://github.com/matrix-org/matrix-react-sdk/pull/5952) + * Convert VoiceUserSettingsTab to TS + [\#6340](https://github.com/matrix-org/matrix-react-sdk/pull/6340) + * Simplify typescript definition for Modernizr + [\#6339](https://github.com/matrix-org/matrix-react-sdk/pull/6339) + * Remember the last used server for room directory searches + [\#6322](https://github.com/matrix-org/matrix-react-sdk/pull/6322) + * Focus composer after reacting + [\#6332](https://github.com/matrix-org/matrix-react-sdk/pull/6332) + * Fix bug which prevented more than one event getting pinned + [\#6336](https://github.com/matrix-org/matrix-react-sdk/pull/6336) + * Make DeviceListener also update on megolm key in SSSS + [\#6337](https://github.com/matrix-org/matrix-react-sdk/pull/6337) + * Improve URL previews + [\#6326](https://github.com/matrix-org/matrix-react-sdk/pull/6326) + * Don't close settings dialog when opening spaces feedback prompt + [\#6334](https://github.com/matrix-org/matrix-react-sdk/pull/6334) + * Update import location for types + [\#6330](https://github.com/matrix-org/matrix-react-sdk/pull/6330) + * Improve blurhash rendering performance + [\#6329](https://github.com/matrix-org/matrix-react-sdk/pull/6329) + * Use a proper color scheme for codeblocks + [\#6320](https://github.com/matrix-org/matrix-react-sdk/pull/6320) + * Burn `sdk.getComponent()` with 🔥 + [\#6308](https://github.com/matrix-org/matrix-react-sdk/pull/6308) + * Fix instances of the Edit Message Composer's save button being wrongly + disabled + [\#6307](https://github.com/matrix-org/matrix-react-sdk/pull/6307) + * Do not generate a lockfile when running in CI + [\#6327](https://github.com/matrix-org/matrix-react-sdk/pull/6327) + * Update lockfile with correct dependencies + [\#6324](https://github.com/matrix-org/matrix-react-sdk/pull/6324) + * Clarify the keys we use when submitting rageshakes + [\#6321](https://github.com/matrix-org/matrix-react-sdk/pull/6321) + * Fix ImageView context menu + [\#6318](https://github.com/matrix-org/matrix-react-sdk/pull/6318) + * TypeScript migration + [\#6315](https://github.com/matrix-org/matrix-react-sdk/pull/6315) + * Move animation to compositor + [\#6310](https://github.com/matrix-org/matrix-react-sdk/pull/6310) + * Reorganize preferences + [\#5742](https://github.com/matrix-org/matrix-react-sdk/pull/5742) + * Fix being able to un-rotate images + [\#6313](https://github.com/matrix-org/matrix-react-sdk/pull/6313) + * Fix icon size in passphrase prompt + [\#6312](https://github.com/matrix-org/matrix-react-sdk/pull/6312) + * Use sleep & defer from js-sdk instead of duplicating it + [\#6305](https://github.com/matrix-org/matrix-react-sdk/pull/6305) + * Convert EventTimeline, EventTimelineSet and TimelineWindow to TS + [\#6295](https://github.com/matrix-org/matrix-react-sdk/pull/6295) + * Comply with new member-delimiter-style rule + [\#6306](https://github.com/matrix-org/matrix-react-sdk/pull/6306) + * Fix Test Linting + [\#6304](https://github.com/matrix-org/matrix-react-sdk/pull/6304) + * Convert Markdown to TypeScript + [\#6303](https://github.com/matrix-org/matrix-react-sdk/pull/6303) + * Convert RoomHeader to TS + [\#6302](https://github.com/matrix-org/matrix-react-sdk/pull/6302) + * Prevent RoomDirectory from exploding when filterString is wrongly nulled + [\#6296](https://github.com/matrix-org/matrix-react-sdk/pull/6296) + * Add support for blurhash (MSC2448) + [\#5099](https://github.com/matrix-org/matrix-react-sdk/pull/5099) + * Remove rateLimitedFunc + [\#6300](https://github.com/matrix-org/matrix-react-sdk/pull/6300) + * Convert some Key Verification classes to TypeScript + [\#6299](https://github.com/matrix-org/matrix-react-sdk/pull/6299) + * Typescript conversion of Composer components and more + [\#6292](https://github.com/matrix-org/matrix-react-sdk/pull/6292) + * Upgrade browserlist target versions + [\#6298](https://github.com/matrix-org/matrix-react-sdk/pull/6298) + * Fix browser crashing when searching for a malformed HTML tag + [\#6297](https://github.com/matrix-org/matrix-react-sdk/pull/6297) + * Add custom audio player + [\#6264](https://github.com/matrix-org/matrix-react-sdk/pull/6264) + * Lint MXC APIs to centralise access + [\#6293](https://github.com/matrix-org/matrix-react-sdk/pull/6293) + * Remove reminescent references to the tinter + [\#6290](https://github.com/matrix-org/matrix-react-sdk/pull/6290) + * More js-sdk type consolidation + [\#6263](https://github.com/matrix-org/matrix-react-sdk/pull/6263) + * Convert MessagePanel, TimelinePanel, ScrollPanel, and more to Typescript + [\#6243](https://github.com/matrix-org/matrix-react-sdk/pull/6243) + * Migrate to `eslint-plugin-matrix-org` + [\#6285](https://github.com/matrix-org/matrix-react-sdk/pull/6285) + * Avoid cyclic dependencies by moving watchers out of constructor + [\#6287](https://github.com/matrix-org/matrix-react-sdk/pull/6287) + * Add spacing between toast buttons with cross browser support in mind + [\#6284](https://github.com/matrix-org/matrix-react-sdk/pull/6284) + * Deprecate Tinter and TintableSVG + [\#6279](https://github.com/matrix-org/matrix-react-sdk/pull/6279) + * Migrate FilePanel to TypeScript + [\#6283](https://github.com/matrix-org/matrix-react-sdk/pull/6283) + Changes in [3.25.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.25.0) (2021-07-05) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.25.0-rc.1...v3.25.0) diff --git a/package.json b/package.json index e80ed8dd5a..6e10bafaea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.25.0", + "version": "3.26.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -79,7 +79,7 @@ "katex": "^0.12.0", "linkifyjs": "^2.1.9", "lodash": "^4.17.20", - "matrix-js-sdk": "12.0.1", + "matrix-js-sdk": "12.1.0", "matrix-widget-api": "^0.1.0-beta.15", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", diff --git a/res/css/_components.scss b/res/css/_components.scss index ac1e4a35ca..b4012e52ba 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -150,6 +150,7 @@ @import "./views/elements/_StyledCheckbox.scss"; @import "./views/elements/_StyledRadioButton.scss"; @import "./views/elements/_SyntaxHighlight.scss"; +@import "./views/elements/_TagComposer.scss"; @import "./views/elements/_TextWithTooltip.scss"; @import "./views/elements/_ToggleSwitch.scss"; @import "./views/elements/_Tooltip.scss"; @@ -165,6 +166,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"; @@ -214,6 +216,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"; @@ -262,9 +265,9 @@ @import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/verification/_VerificationShowSas.scss"; @import "./views/voip/_CallContainer.scss"; +@import "./views/voip/_CallPreview.scss"; @import "./views/voip/_CallView.scss"; @import "./views/voip/_CallViewForRoom.scss"; -@import "./views/voip/_CallPreview.scss"; @import "./views/voip/_DialPad.scss"; @import "./views/voip/_DialPadContextMenu.scss"; @import "./views/voip/_DialPadModal.scss"; diff --git a/res/css/views/elements/_ReplyThread.scss b/res/css/views/elements/_ReplyThread.scss index bf44a11728..44532ea6a7 100644 --- a/res/css/views/elements/_ReplyThread.scss +++ b/res/css/views/elements/_ReplyThread.scss @@ -16,22 +16,45 @@ limitations under the License. .mx_ReplyThread { 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_show { + cursor: pointer; + } + + &.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/elements/_StyledRadioButton.scss b/res/css/views/elements/_StyledRadioButton.scss index 62fb5c5512..1ae787dfc2 100644 --- a/res/css/views/elements/_StyledRadioButton.scss +++ b/res/css/views/elements/_StyledRadioButton.scss @@ -46,7 +46,7 @@ limitations under the License. width: $font-16px; } - > input[type=radio] { + input[type=radio] { // Remove the OS's representation margin: 0; padding: 0; @@ -112,6 +112,12 @@ limitations under the License. } } } + + .mx_RadioButton_innerLabel { + display: flex; + position: relative; + top: 4px; + } } .mx_RadioButton_outlined { diff --git a/res/css/views/elements/_TagComposer.scss b/res/css/views/elements/_TagComposer.scss new file mode 100644 index 0000000000..2ffd601765 --- /dev/null +++ b/res/css/views/elements/_TagComposer.scss @@ -0,0 +1,77 @@ +/* +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_TagComposer { + .mx_TagComposer_input { + display: flex; + + .mx_Field { + flex: 1; + margin: 0; // override from field styles + } + + .mx_AccessibleButton { + min-width: 70px; + padding: 0; // override from button styles + margin-left: 16px; // distance from + } + + .mx_Field, .mx_Field input, .mx_AccessibleButton { + // So they look related to each other by feeling the same + border-radius: 8px; + } + } + + .mx_TagComposer_tags { + display: flex; + flex-wrap: wrap; + margin-top: 12px; // this plus 12px from the tags makes 24px from the input + + .mx_TagComposer_tag { + padding: 6px 8px 8px 12px; + position: relative; + margin-right: 12px; + margin-top: 12px; + + // Cheaty way to get an opacified variable colour background + &::before { + content: ''; + border-radius: 20px; + background-color: $tertiary-fg-color; + opacity: 0.15; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + + // Pass through the pointer otherwise we have effectively put a whole div + // on top of the component, which makes it hard to interact with buttons. + pointer-events: none; + } + } + + .mx_AccessibleButton { + background-image: url('$(res)/img/subtract.svg'); + width: 16px; + height: 16px; + margin-left: 8px; + display: inline-block; + vertical-align: middle; + cursor: pointer; + } + } +} 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..60feb39d11 100644 --- a/res/css/views/rooms/_ReplyPreview.scss +++ b/res/css/views/rooms/_ReplyPreview.scss @@ -22,28 +22,34 @@ limitations under the License. max-height: 50vh; overflow: auto; box-shadow: 0px -16px 32px $composer-shadow-color; + + .mx_ReplyPreview_section { + border-bottom: 1px solid $primary-hairline-color; + + .mx_ReplyPreview_header { + margin: 8px; + color: $primary-fg-color; + font-weight: 400; + opacity: 0.4; + } + + .mx_ReplyPreview_tile { + margin: 0 8px; + } + + .mx_ReplyPreview_title { + float: left; + } + + .mx_ReplyPreview_cancel { + float: right; + cursor: pointer; + display: flex; + } + + .mx_ReplyPreview_clear { + clear: both; + } + } } -.mx_ReplyPreview_section { - border-bottom: 1px solid $primary-hairline-color; -} - -.mx_ReplyPreview_header { - margin: 12px; - color: $primary-fg-color; - font-weight: 400; - opacity: 0.4; -} - -.mx_ReplyPreview_title { - float: left; -} - -.mx_ReplyPreview_cancel { - float: right; - cursor: pointer; -} - -.mx_ReplyPreview_clear { - clear: both; -} diff --git a/res/css/views/rooms/_ReplyTile.scss b/res/css/views/rooms/_ReplyTile.scss new file mode 100644 index 0000000000..f3e204e415 --- /dev/null +++ b/res/css/views/rooms/_ReplyTile.scss @@ -0,0 +1,119 @@ +/* +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 { + position: relative; + padding: 2px 0; + font-size: $font-14px; + 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; + } + } + + > a { + display: flex; + flex-direction: column; + text-decoration: none; + color: $primary-fg-color; + } + + .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_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; + font-size: $font-14px !important; // Override the big emoji override + } + + // 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_info {
+        padding-top: 0;
+    }
+
+    .mx_SenderProfile {
+        font-size: $font-14px;
+        line-height: $font-17px;
+
+        display: inline-block; // anti-zalgo, with overflow hidden
+        padding: 0;
+        margin: 0;
+
+        // truncate long display names
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+    }
+}
diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss
index 03146e0325..b8f4aeb6e7 100644
--- a/res/css/views/rooms/_RoomTile.scss
+++ b/res/css/views/rooms/_RoomTile.scss
@@ -193,6 +193,10 @@ limitations under the License.
         mask-image: url('$(res)/img/element-icons/settings.svg');
     }
 
+    .mx_RoomTile_iconCopyLink::before {
+        mask-image: url('$(res)/img/element-icons/link.svg');
+    }
+
     .mx_RoomTile_iconInvite::before {
         mask-image: url('$(res)/img/element-icons/room/invite.svg');
     }
diff --git a/res/css/views/settings/_Notifications.scss b/res/css/views/settings/_Notifications.scss
index 77a7bc5b68..f93e0a53a8 100644
--- a/res/css/views/settings/_Notifications.scss
+++ b/res/css/views/settings/_Notifications.scss
@@ -1,5 +1,5 @@
 /*
-Copyright 2015, 2016 OpenMarket Ltd
+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.
@@ -14,82 +14,79 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_UserNotifSettings_tableRow {
-    display: table-row;
-}
+.mx_UserNotifSettings {
+    color: $primary-fg-color; // override from default settings page styles
 
-.mx_UserNotifSettings_inputCell {
-    display: table-cell;
-    padding-bottom: 8px;
-    padding-right: 8px;
-    width: 16px;
-}
+    .mx_UserNotifSettings_pushRulesTable {
+        width: calc(100% + 12px); // +12px to line up center of 'Noisy' column with toggle switches
+        table-layout: fixed;
+        border-collapse: collapse;
+        border-spacing: 0;
+        margin-top: 40px;
 
-.mx_UserNotifSettings_labelCell {
-    padding-bottom: 8px;
-    width: 400px;
-    display: table-cell;
-}
+        tr > th {
+            font-weight: $font-semi-bold;
+        }
 
-.mx_UserNotifSettings_pushRulesTableWrapper {
-    padding-bottom: 8px;
-}
+        tr > th:first-child {
+            text-align: left;
+            font-size: $font-18px;
+        }
 
-.mx_UserNotifSettings_pushRulesTable {
-    width: 100%;
-    table-layout: fixed;
-}
+        tr > th:nth-child(n + 2) {
+            color: $secondary-fg-color;
+            font-size: $font-12px;
+            vertical-align: middle;
+            width: 66px;
+        }
 
-.mx_UserNotifSettings_pushRulesTable thead {
-    font-weight: bold;
-}
+        tr > td:nth-child(n + 2) {
+            text-align: center;
+        }
 
-.mx_UserNotifSettings_pushRulesTable tbody th {
-    font-weight: 400;
-}
+        tr > td {
+            padding-top: 8px;
+        }
 
-.mx_UserNotifSettings_pushRulesTable tbody th:first-child {
-    text-align: left;
-}
+        // Override StyledRadioButton default styles
+        .mx_RadioButton {
+            justify-content: center;
 
-.mx_UserNotifSettings_keywords {
-    cursor: pointer;
-    color: $accent-color;
-}
+            .mx_RadioButton_content {
+                display: none;
+            }
 
-.mx_UserNotifSettings_devicesTable td {
-    padding-left: 20px;
-    padding-right: 20px;
-}
+            .mx_RadioButton_spacer {
+                display: none;
+            }
+        }
+    }
 
-.mx_UserNotifSettings_notifTable {
-    display: table;
-    position: relative;
-}
+    .mx_UserNotifSettings_floatingSection {
+        margin-top: 40px;
 
-.mx_UserNotifSettings_notifTable .mx_Spinner {
-    position: absolute;
-}
+        & > div:first-child { // section header
+            font-size: $font-18px;
+            font-weight: $font-semi-bold;
+        }
 
-.mx_NotificationSound_soundUpload {
-    display: none;
-}
+        > table {
+            border-collapse: collapse;
+            border-spacing: 0;
+            margin-top: 8px;
 
-.mx_NotificationSound_browse {
-    color: $accent-color;
-    border: 1px solid $accent-color;
-    background-color: transparent;
-}
+            tr > td:first-child {
+                // Just for a bit of spacing
+                padding-right: 8px;
+            }
+        }
+    }
 
-.mx_NotificationSound_save {
-    margin-left: 5px;
-    color: white;
-    background-color: $accent-color;
-}
+    .mx_UserNotifSettings_clearNotifsButton {
+        margin-top: 8px;
+    }
 
-.mx_NotificationSound_resetSound {
-    margin-top: 5px;
-    color: white;
-    border: $warning-color;
-    background-color: $warning-color;
+    .mx_TagComposer {
+        margin-top: 35px; // lots of distance from the last line of the table
+    }
 }
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/subtract.svg b/res/img/subtract.svg
new file mode 100644
index 0000000000..55e25831ef
--- /dev/null
+++ b/res/img/subtract.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/src/ActiveRoomObserver.ts b/src/ActiveRoomObserver.ts
index 1126dc9496..0be49a24ea 100644
--- a/src/ActiveRoomObserver.ts
+++ b/src/ActiveRoomObserver.ts
@@ -15,6 +15,7 @@ limitations under the License.
 */
 
 import RoomViewStore from './stores/RoomViewStore';
+import { EventSubscription } from 'fbemitter';
 
 type Listener = (isActive: boolean) => void;
 
@@ -30,7 +31,7 @@ type Listener = (isActive: boolean) => void;
 export class ActiveRoomObserver {
     private listeners: {[key: string]: Listener[]} = {};
     private _activeRoomId = RoomViewStore.getRoomId();
-    private readonly roomStoreToken: string;
+    private readonly roomStoreToken: EventSubscription;
 
     constructor() {
         // TODO: We could self-destruct when the last listener goes away, or at least stop listening.
diff --git a/src/Avatar.ts b/src/Avatar.ts
index 4c4bd1c265..b9acf93e29 100644
--- a/src/Avatar.ts
+++ b/src/Avatar.ts
@@ -18,10 +18,11 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member";
 import { User } from "matrix-js-sdk/src/models/user";
 import { Room } from "matrix-js-sdk/src/models/room";
 import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
+import { split } from "lodash";
 
 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(
@@ -122,27 +123,13 @@ export function getInitialLetter(name: string): string {
         return undefined;
     }
 
-    let idx = 0;
     const initial = name[0];
     if ((initial === '@' || initial === '#' || initial === '+') && name[1]) {
-        idx++;
+        name = name.substring(1);
     }
 
-    // string.codePointAt(0) would do this, but that isn't supported by
-    // some browsers (notably PhantomJS).
-    let chars = 1;
-    const first = name.charCodeAt(idx);
-
-    // check if it’s the start of a surrogate pair
-    if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) {
-        const second = name.charCodeAt(idx+1);
-        if (second >= 0xDC00 && second <= 0xDFFF) {
-            chars++;
-        }
-    }
-
-    const firstChar = name.substring(idx, idx+chars);
-    return firstChar.toUpperCase();
+    // rely on the grapheme cluster splitter in lodash so that we don't break apart compound emojis
+    return split(name, "", 1)[0];
 }
 
 export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) {
@@ -153,7 +140,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/HtmlUtils.tsx b/src/HtmlUtils.tsx
index 5e83fdc2a0..a37b7f0ac9 100644
--- a/src/HtmlUtils.tsx
+++ b/src/HtmlUtils.tsx
@@ -25,7 +25,6 @@ import _linkifyElement from 'linkifyjs/element';
 import _linkifyString from 'linkifyjs/string';
 import classNames from 'classnames';
 import EMOJIBASE_REGEX from 'emojibase-regex';
-import url from 'url';
 import katex from 'katex';
 import { AllHtmlEntities } from 'html-entities';
 import { IContent } from 'matrix-js-sdk/src/models/event';
@@ -153,10 +152,8 @@ export function getHtmlText(insaneHtml: string): string {
  */
 export function isUrlPermitted(inputUrl: string): boolean {
     try {
-        const parsed = url.parse(inputUrl);
-        if (!parsed.protocol) return false;
         // URL parser protocol includes the trailing colon
-        return PERMITTED_URL_SCHEMES.includes(parsed.protocol.slice(0, -1));
+        return PERMITTED_URL_SCHEMES.includes(new URL(inputUrl).protocol.slice(0, -1));
     } catch (e) {
         return false;
     }
diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts
index 61ded93833..410124a637 100644
--- a/src/Lifecycle.ts
+++ b/src/Lifecycle.ts
@@ -21,6 +21,7 @@ import { createClient } from 'matrix-js-sdk/src/matrix';
 import { InvalidStoreError } from "matrix-js-sdk/src/errors";
 import { MatrixClient } from "matrix-js-sdk/src/client";
 import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes";
+import { QueryDict } from 'matrix-js-sdk/src/utils';
 
 import { IMatrixClientCreds, MatrixClientPeg } from './MatrixClientPeg';
 import SecurityCustomisations from "./customisations/Security";
@@ -65,7 +66,7 @@ interface ILoadSessionOpts {
     guestIsUrl?: string;
     ignoreGuest?: boolean;
     defaultDeviceDisplayName?: string;
-    fragmentQueryParams?: Record;
+    fragmentQueryParams?: QueryDict;
 }
 
 /**
@@ -118,8 +119,8 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise
         ) {
             console.log("Using guest access credentials");
             return doSetLoggedIn({
-                userId: fragmentQueryParams.guest_user_id,
-                accessToken: fragmentQueryParams.guest_access_token,
+                userId: fragmentQueryParams.guest_user_id as string,
+                accessToken: fragmentQueryParams.guest_access_token as string,
                 homeserverUrl: guestHsUrl,
                 identityServerUrl: guestIsUrl,
                 guest: true,
@@ -173,7 +174,7 @@ export async function getStoredSessionOwner(): Promise<[string, boolean]> {
  *    login, else false
  */
 export function attemptTokenLogin(
-    queryParams: Record,
+    queryParams: QueryDict,
     defaultDeviceDisplayName?: string,
     fragmentAfterLogin?: string,
 ): Promise {
@@ -198,7 +199,7 @@ export function attemptTokenLogin(
         homeserver,
         identityServer,
         "m.login.token", {
-            token: queryParams.loginToken,
+            token: queryParams.loginToken as string,
             initial_device_display_name: defaultDeviceDisplayName,
         },
     ).then(function(creds) {
diff --git a/src/Notifier.ts b/src/Notifier.ts
index 415adcafc8..1137e44aec 100644
--- a/src/Notifier.ts
+++ b/src/Notifier.ts
@@ -328,7 +328,7 @@ export const Notifier = {
 
     onEvent: function(ev: MatrixEvent) {
         if (!this.isSyncing) return; // don't alert for any messages initially
-        if (ev.sender && ev.sender.userId === MatrixClientPeg.get().credentials.userId) return;
+        if (ev.getSender() === MatrixClientPeg.get().credentials.userId) return;
 
         MatrixClientPeg.get().decryptEventIfNeeded(ev);
 
diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx
index ef24fb8e48..0056a37c85 100644
--- a/src/TextForEvent.tsx
+++ b/src/TextForEvent.tsx
@@ -13,7 +13,6 @@ 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 { MatrixClientPeg } from './MatrixClientPeg';
 import { _t } from './languageHandler';
@@ -32,7 +31,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, allowJSX: boolean, showHiddenEvents?: boolean): () => 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();
@@ -84,7 +83,7 @@ function textForMemberEvent(ev): () => string | null {
                     return () => _t('%(senderName)s changed their profile picture', { senderName });
                 } else if (!prevContent.avatar_url && content.avatar_url) {
                     return () => _t('%(senderName)s set a profile picture', { senderName });
-                } else if (SettingsStore.getValue("showHiddenEventsInTimeline")) {
+                } else if (showHiddenEvents ?? SettingsStore.getValue("showHiddenEventsInTimeline")) {
                     // This is a null rejoin, it will only be visible if using 'show hidden events' (labs)
                     return () => _t("%(senderName)s made no change", { senderName });
                 } else {
@@ -127,7 +126,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 +134,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 +153,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 +178,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 +194,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 +224,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 +254,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 +267,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 || [];
@@ -319,7 +318,7 @@ function textForCanonicalAliasEvent(ev): () => string | null {
     });
 }
 
-function textForCallAnswerEvent(event): () => string | null {
+function textForCallAnswerEvent(event: MatrixEvent): () => string | null {
     return () => {
         const senderName = event.sender ? event.sender.name : _t('Someone');
         const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)');
@@ -327,7 +326,7 @@ function textForCallAnswerEvent(event): () => string | null {
     };
 }
 
-function textForCallHangupEvent(event): () => string | null {
+function textForCallHangupEvent(event: MatrixEvent): () => string | null {
     const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
     const eventContent = event.getContent();
     let getReason = () => "";
@@ -364,14 +363,14 @@ function textForCallHangupEvent(event): () => string | null {
     return () => _t('%(senderName)s ended the call.', { senderName: getSenderName() }) + ' ' + getReason();
 }
 
-function textForCallRejectEvent(event): () => string | null {
+function textForCallRejectEvent(event: MatrixEvent): () => string | null {
     return () => {
         const senderName = event.sender ? event.sender.name : _t('Someone');
         return _t('%(senderName)s declined the call.', { senderName });
     };
 }
 
-function textForCallInviteEvent(event): () => string | null {
+function textForCallInviteEvent(event: MatrixEvent): () => string | null {
     const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
     // FIXME: Find a better way to determine this from the event?
     let isVoice = true;
@@ -403,7 +402,7 @@ function textForCallInviteEvent(event): () => string | null {
     }
 }
 
-function textForThreePidInviteEvent(event): () => string | null {
+function textForThreePidInviteEvent(event: MatrixEvent): () => string | null {
     const senderName = event.sender ? event.sender.name : event.getSender();
 
     if (!isValid3pidInvite(event)) {
@@ -419,7 +418,7 @@ function textForThreePidInviteEvent(event): () => string | null {
     });
 }
 
-function textForHistoryVisibilityEvent(event): () => string | null {
+function textForHistoryVisibilityEvent(event: MatrixEvent): () => string | null {
     const senderName = event.sender ? event.sender.name : event.getSender();
     switch (event.getContent().history_visibility) {
         case 'invited':
@@ -441,7 +440,7 @@ function textForHistoryVisibilityEvent(event): () => string | null {
 }
 
 // Currently will only display a change if a user's power level is changed
-function textForPowerEvent(event): () => string | null {
+function textForPowerEvent(event: MatrixEvent): () => string | null {
     const senderName = event.sender ? event.sender.name : event.getSender();
     if (!event.getPrevContent() || !event.getPrevContent().users ||
         !event.getContent() || !event.getContent().users) {
@@ -523,7 +522,7 @@ function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => string
     return () => _t("%(senderName)s changed the pinned messages for the room.", { senderName });
 }
 
-function textForWidgetEvent(event): () => string | null {
+function textForWidgetEvent(event: MatrixEvent): () => string | null {
     const senderName = event.getSender();
     const { name: prevName, type: prevType, url: prevUrl } = event.getPrevContent();
     const { name, type, url } = event.getContent() || {};
@@ -553,12 +552,12 @@ function textForWidgetEvent(event): () => string | null {
     }
 }
 
-function textForWidgetLayoutEvent(event): () => string | null {
+function textForWidgetLayoutEvent(event: MatrixEvent): () => string | null {
     const senderName = event.sender?.name || event.getSender();
     return () => _t("%(senderName)s has updated the widget layout", { senderName });
 }
 
-function textForMjolnirEvent(event): () => string | null {
+function textForMjolnirEvent(event: MatrixEvent): () => string | null {
     const senderName = event.getSender();
     const { entity: prevEntity } = event.getPrevContent();
     const { entity, recommendation, reason } = event.getContent();
@@ -646,7 +645,9 @@ function textForMjolnirEvent(event): () => string | null {
 }
 
 interface IHandlers {
-    [type: string]: (ev: MatrixEvent, allowJSX?: boolean) => (() => string | JSX.Element | null);
+    [type: string]:
+        (ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean) =>
+            (() => string | JSX.Element | null);
 }
 
 const handlers: IHandlers = {
@@ -682,14 +683,27 @@ for (const evType of ALL_RULE_TYPES) {
     stateHandlers[evType] = textForMjolnirEvent;
 }
 
-export function hasText(ev): boolean {
+/**
+ * Determines whether the given event has text to display.
+ * @param ev The event
+ * @param showHiddenEvents An optional cached setting value for showHiddenEventsInTimeline
+ *     to avoid hitting the settings store
+ */
+export function hasText(ev: MatrixEvent, showHiddenEvents?: boolean): boolean {
     const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
-    return Boolean(handler?.(ev));
+    return Boolean(handler?.(ev, false, showHiddenEvents));
 }
 
+/**
+ * Gets the textual content of the given event.
+ * @param ev The event
+ * @param allowJSX Whether to output rich JSX content
+ * @param showHiddenEvents An optional cached setting value for showHiddenEventsInTimeline
+ *     to avoid hitting the settings store
+ */
 export function textForEvent(ev: MatrixEvent): string;
-export function textForEvent(ev: MatrixEvent, allowJSX: true): string | JSX.Element;
-export function textForEvent(ev: MatrixEvent, allowJSX = false): string | JSX.Element {
+export function textForEvent(ev: MatrixEvent, allowJSX: true, showHiddenEvents?: boolean): string | JSX.Element;
+export function textForEvent(ev: MatrixEvent, allowJSX = false, showHiddenEvents?: boolean): string | JSX.Element {
     const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
-    return handler?.(ev, allowJSX)?.() || '';
+    return handler?.(ev, allowJSX, showHiddenEvents)?.() || '';
 }
diff --git a/src/Unread.ts b/src/Unread.ts
index 72f0bb4642..da5b883f92 100644
--- a/src/Unread.ts
+++ b/src/Unread.ts
@@ -30,7 +30,7 @@ import { haveTileForEvent } from "./components/views/rooms/EventTile";
  * @returns {boolean} True if the given event should affect the unread message count
  */
 export function eventTriggersUnreadCount(ev: MatrixEvent): boolean {
-    if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) {
+    if (ev.getSender() === MatrixClientPeg.get().credentials.userId) {
         return false;
     }
 
@@ -63,9 +63,7 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean {
     //             https://github.com/vector-im/element-web/issues/2427
     // ...and possibly some of the others at
     //             https://github.com/vector-im/element-web/issues/3363
-    if (room.timeline.length &&
-        room.timeline[room.timeline.length - 1].sender &&
-        room.timeline[room.timeline.length - 1].sender.userId === myUserId) {
+    if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) {
         return false;
     }
 
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/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 } void; enableGuest?: boolean; // the queryParams extracted from the [real] query-string of the URI - realQueryParams?: Record; + realQueryParams?: QueryDict; // the initial queryParams extracted from the hash-fragment of the URI - startingFragmentQueryParams?: Record; + startingFragmentQueryParams?: QueryDict; // called when we have completed a token login onTokenLoginCompleted?: () => void; // Represents the screen to display as a result of parsing the initial window.location @@ -193,7 +195,7 @@ interface IProps { // TODO type things better // displayname, if any, to set on the device when logging in/registering. defaultDeviceDisplayName?: string; // A function that makes a registration URL - makeRegistrationUrl: (object) => string; + makeRegistrationUrl: (params: QueryDict) => string; } interface IState { @@ -296,7 +298,7 @@ export default class MatrixChat extends React.PureComponent { if (this.screenAfterLogin.screen.startsWith("room/") && params['signurl'] && params['email']) { // probably a threepid invite - try to store it const roomId = this.screenAfterLogin.screen.substring("room/".length); - ThreepidInviteStore.instance.storeInvite(roomId, params as IThreepidInviteWireFormat); + ThreepidInviteStore.instance.storeInvite(roomId, params as unknown as IThreepidInviteWireFormat); } } @@ -627,6 +629,9 @@ export default class MatrixChat extends React.PureComponent { case 'forget_room': this.forgetRoom(payload.room_id); break; + case 'copy_room': + this.copyRoom(payload.room_id); + break; case 'reject_invite': Modal.createTrackedDialog('Reject invitation', '', QuestionDialog, { title: _t('Reject invitation'), @@ -1099,7 +1104,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 +1142,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: ( @@ -1193,6 +1198,17 @@ export default class MatrixChat extends React.PureComponent { }); } + private async copyRoom(roomId: string) { + const roomLink = makeRoomPermalink(roomId); + const success = await copyPlaintext(roomLink); + if (!success) { + Modal.createTrackedDialog("Unable to copy room link", "", ErrorDialog, { + title: _t("Unable to copy room link"), + description: _t("Unable to copy a link to the room to the clipboard."), + }); + } + } + /** * Starts a chat with the welcome user, if the user doesn't already have one * @returns {string} The room ID of the new room, or null if no room was created @@ -1687,7 +1703,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 +1790,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; } @@ -1936,7 +1952,7 @@ export default class MatrixChat extends React.PureComponent { this.setState({ serverConfig }); }; - private makeRegistrationUrl = (params: {[key: string]: string}) => { + private makeRegistrationUrl = (params: QueryDict) => { if (this.props.startingFragmentQueryParams.referrer) { params.referrer = this.props.startingFragmentQueryParams.referrer; } @@ -2091,7 +2107,7 @@ export default class MatrixChat extends React.PureComponent { onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined} onServerConfigChange={this.onServerConfigChange} fragmentAfterLogin={fragmentAfterLogin} - defaultUsername={this.props.startingFragmentQueryParams.defaultUsername} + defaultUsername={this.props.startingFragmentQueryParams.defaultUsername as string} {...this.getServerProperties()} /> ); diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index a0a1ac9b10..8977549697 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -54,7 +54,11 @@ const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, E // check if there is a previous event and it has the same sender as this event // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL -function shouldFormContinuation(prevEvent: MatrixEvent, mxEvent: MatrixEvent): boolean { +function shouldFormContinuation( + prevEvent: MatrixEvent, + mxEvent: MatrixEvent, + showHiddenEvents: boolean, +): boolean { // sanity check inputs if (!prevEvent || !prevEvent.sender || !mxEvent.sender) return false; // check if within the max continuation period @@ -74,7 +78,7 @@ function shouldFormContinuation(prevEvent: MatrixEvent, mxEvent: MatrixEvent): b mxEvent.sender.getMxcAvatarUrl() !== prevEvent.sender.getMxcAvatarUrl()) return false; // if we don't have tile for previous event then it was shown by showHiddenEvents and has no SenderProfile - if (!haveTileForEvent(prevEvent)) return false; + if (!haveTileForEvent(prevEvent, showHiddenEvents)) return false; return true; } @@ -239,7 +243,8 @@ export default class MessagePanel extends React.Component { }; // Cache hidden events setting on mount since Settings is expensive to - // query, and we check this in a hot code path. + // query, and we check this in a hot code path. This is also cached in + // our RoomContext, however we still need a fallback for roomless MessagePanels. this.showHiddenEventsInTimeline = SettingsStore.getValue("showHiddenEventsInTimeline"); this.showTypingNotificationsWatcherRef = @@ -399,17 +404,21 @@ export default class MessagePanel extends React.Component { return !this.isMounted; }; + private get showHiddenEvents(): boolean { + return this.context?.showHiddenEventsInTimeline ?? this.showHiddenEventsInTimeline; + } + // TODO: Implement granular (per-room) hide options public shouldShowEvent(mxEv: MatrixEvent): boolean { - if (mxEv.sender && MatrixClientPeg.get().isUserIgnored(mxEv.sender.userId)) { + if (MatrixClientPeg.get().isUserIgnored(mxEv.getSender())) { return false; // ignored = no show (only happens if the ignore happens after an event was received) } - if (this.showHiddenEventsInTimeline) { + if (this.showHiddenEvents) { return true; } - if (!haveTileForEvent(mxEv)) { + if (!haveTileForEvent(mxEv, this.showHiddenEvents)) { return false; // no tile = no show } @@ -569,7 +578,7 @@ export default class MessagePanel extends React.Component { if (grouper) { if (grouper.shouldGroup(mxEv)) { - grouper.add(mxEv); + grouper.add(mxEv, this.showHiddenEvents); continue; } else { // not part of group, so get the group tiles, close the @@ -649,7 +658,8 @@ export default class MessagePanel extends React.Component { } // is this a continuation of the previous message? - const continuation = !wantsDateSeparator && shouldFormContinuation(prevEvent, mxEv); + const continuation = !wantsDateSeparator && + shouldFormContinuation(prevEvent, mxEv, this.showHiddenEvents); const eventId = mxEv.getId(); const highlight = (eventId === this.props.highlightedEventId); @@ -946,7 +956,7 @@ abstract class BaseGrouper { } public abstract shouldGroup(ev: MatrixEvent): boolean; - public abstract add(ev: MatrixEvent): void; + public abstract add(ev: MatrixEvent, showHiddenEvents?: boolean): void; public abstract getTiles(): ReactNode[]; public abstract getNewPrevEvent(): MatrixEvent; } @@ -1200,10 +1210,10 @@ class MemberGrouper extends BaseGrouper { return membershipTypes.includes(ev.getType() as EventType); } - public add(ev: MatrixEvent): void { + public add(ev: MatrixEvent, showHiddenEvents?: boolean): void { if (ev.getType() === EventType.RoomMember) { // We can ignore any events that don't actually have a message to display - if (!hasText(ev)) return; + if (!hasText(ev, showHiddenEvents)) return; } this.readMarker = this.readMarker || this.panel.readMarkerForEvent( ev.getId(), 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/RoomView.tsx b/src/components/structures/RoomView.tsx index 2c118149a0..0c10a2aeca 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -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) {}; @@ -165,6 +166,7 @@ export interface IState { canReply: boolean; layout: Layout; lowBandwidth: boolean; + showHiddenEventsInTimeline: boolean; showReadReceipts: boolean; showRedactions: boolean; showJoinLeaves: boolean; @@ -229,6 +231,7 @@ export default class RoomView extends React.Component { canReply: false, layout: SettingsStore.getValue("layout"), lowBandwidth: SettingsStore.getValue("lowBandwidth"), + showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline"), showReadReceipts: true, showRedactions: true, showJoinLeaves: true, @@ -252,7 +255,6 @@ export default class RoomView extends React.Component { this.context.on("userTrustStatusChanged", this.onUserVerificationChanged); this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged); this.context.on("Event.decrypted", this.onEventDecrypted); - this.context.on("event", this.onEvent); // Start listening for RoomViewStore updates this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate); @@ -267,6 +269,9 @@ export default class RoomView extends React.Component { SettingsStore.watchSetting("lowBandwidth", null, () => this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }), ), + SettingsStore.watchSetting("showHiddenEventsInTimeline", null, () => + this.setState({ showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline") }), + ), ]; } @@ -636,7 +641,6 @@ export default class RoomView extends React.Component { this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged); this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged); this.context.removeListener("Event.decrypted", this.onEventDecrypted); - this.context.removeListener("event", this.onEvent); } window.removeEventListener('beforeunload', this.onPageUnload); @@ -836,8 +840,7 @@ export default class RoomView extends React.Component { if (this.unmounted) return; // ignore events for other rooms - if (!room) return; - if (!this.state.room || room.roomId != this.state.room.roomId) return; + if (!room || room.roomId !== this.state.room?.roomId) return; // ignore events from filtered timelines if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; @@ -858,6 +861,10 @@ export default class RoomView extends React.Component { // we'll only be showing a spinner. if (this.state.joining) return; + if (!ev.isBeingDecrypted() && !ev.isDecryptionFailure()) { + this.handleEffects(ev); + } + if (ev.getSender() !== this.context.credentials.userId) { // update unread count when scrolled up if (!this.state.searchResults && this.state.atEndOfLiveTimeline) { @@ -870,20 +877,14 @@ export default class RoomView extends React.Component { } }; - private onEventDecrypted = (ev) => { + private onEventDecrypted = (ev: MatrixEvent) => { + if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all + if (ev.getRoomId() !== this.state.room.roomId) return; // not for us if (ev.isDecryptionFailure()) return; this.handleEffects(ev); }; - private onEvent = (ev) => { - if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; - this.handleEffects(ev); - }; - - private handleEffects = (ev) => { - if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all - if (ev.getRoomId() !== this.state.room.roomId) return; // not for us - + private handleEffects = (ev: MatrixEvent) => { const notifState = RoomNotificationStateStore.instance.getRoomState(this.state.room); if (!notifState.isUnread) return; @@ -916,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 @@ -930,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) { @@ -1022,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) => { @@ -1395,7 +1393,7 @@ export default class RoomView extends React.Component { continue; } - if (!haveTileForEvent(mxEv)) { + if (!haveTileForEvent(mxEv, this.state.showHiddenEventsInTimeline)) { // XXX: can this ever happen? It will make the result count // not match the displayed count. continue; @@ -1748,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 ( @@ -1882,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/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index cb440ca576..f94edaedfb 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"; @@ -177,7 +176,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; @@ -852,7 +851,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 { // more than the timeout on userActiveRecently. // const myUserId = MatrixClientPeg.get().credentials.userId; - const sender = ev.sender ? ev.sender.userId : null; callRMUpdated = false; - if (sender != myUserId && !UserActivity.sharedInstance().userActiveRecently()) { + if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) { updatedState.readMarkerVisible = true; } else if (lastLiveEvent && this.getReadMarkerPosition() === 0) { // we know we're stuckAtBottom, so we can advance the RM @@ -863,7 +862,7 @@ class TimelinePanel extends React.Component { const myUserId = MatrixClientPeg.get().credentials.userId; for (i++; i < events.length; i++) { const ev = events[i]; - if (!ev.sender || ev.sender.userId != myUserId) { + if (ev.getSender() !== myUserId) { break; } } @@ -1051,6 +1050,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) { @@ -1092,6 +1093,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}`, @@ -1333,8 +1336,9 @@ class TimelinePanel extends React.Component { } const shouldIgnore = !!ev.status || // local echo - (ignoreOwn && ev.sender && ev.sender.userId == myUserId); // own message - const isWithoutTile = !haveTileForEvent(ev) || shouldHideEvent(ev, this.context); + (ignoreOwn && ev.getSender() === myUserId); // own message + const isWithoutTile = !haveTileForEvent(ev, this.context?.showHiddenEventsInTimeline) || + shouldHideEvent(ev, this.context); if (isWithoutTile || !node) { // don't start counting if the event should be ignored, 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/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index bd776953a6..36f6db87a6 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -24,6 +24,7 @@ import ImageView from '../elements/ImageView'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; import * as Avatar from '../../../Avatar'; +import DMRoomMap from "../../../utils/DMRoomMap"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { mediaFromMxc } from "../../../customisations/Media"; import { IOOBData } from '../../../stores/ThreepidInviteStore'; @@ -135,6 +136,11 @@ export default class RoomAvatar extends React.Component { public render() { const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props; + const roomName = room ? room.name : oobData.name; + // If the room is a DM, we use the other user's ID for the color hash + // in order to match the room avatar with their avatar + const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : oobData.roomId; + return ( { mx_RoomAvatar_isSpaceRoom: room?.isSpaceRoom(), })} name={room ? room.name : oobData.name} - idName={room ? room.roomId : oobData.roomId} + idName={idName} urls={this.state.urls} onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick} /> 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/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/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index f8b2297f5c..58bab511bf 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -71,6 +71,7 @@ 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 */ @@ -1407,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"), diff --git a/src/components/views/dialogs/ServerPickerDialog.tsx b/src/components/views/dialogs/ServerPickerDialog.tsx index 8dafd8a2bc..d480ca2043 100644 --- a/src/components/views/dialogs/ServerPickerDialog.tsx +++ b/src/components/views/dialogs/ServerPickerDialog.tsx @@ -205,13 +205,14 @@ export default class ServerPickerDialog extends React.PureComponent { > {this.props.name} void; + permalinkCreator: RoomPermalinkCreator; + // Specifies which layout to use. + layout?: Layout; + // Whether to always show a timestamp + alwaysShowTimestamps?: boolean; +} + +interface IState { + // The loaded events to be rendered as linear-replies + events: MatrixEvent[]; + // The latest loaded event which has not yet been shown + loadedEv: MatrixEvent; + // Whether the component is still loading more events + loading: boolean; + // Whether as error was encountered fetching a replied to event. + err: boolean; +} // 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 // be low as each event being loaded (after the first) is triggered by an explicit user action. @replaceableComponent("views.elements.ReplyThread") -export default class ReplyThread extends React.Component { - static propTypes = { - // the latest event in this chain of replies - parentEv: PropTypes.instanceOf(MatrixEvent), - // called when the ReplyThread contents has changed, including EventTiles thereof - onHeightChanged: PropTypes.func.isRequired, - permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired, - // Specifies which layout to use. - layout: LayoutPropType, - // Whether to always show a timestamp - alwaysShowTimestamps: PropTypes.bool, - }; - +export default class ReplyThread extends React.Component { static contextType = MatrixClientContext; + private unmounted = false; + private room: Room; constructor(props, context) { super(props, context); this.state = { - // The loaded events to be rendered as linear-replies events: [], - - // The latest loaded event which has not yet been shown loadedEv: null, - // Whether the component is still loading more events loading: true, - - // Whether as error was encountered fetching a replied to event. err: false, }; - 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); - this.collapse = this.collapse.bind(this); } - static getParentEventId(ev) { + public static getParentEventId(ev: MatrixEvent): string { if (!ev || ev.isRedacted()) return; // XXX: For newer relations (annotations, replacements, etc.), we now @@ -95,7 +95,7 @@ export default class ReplyThread extends React.Component { } // Part of Replies fallback support - static stripPlainReply(body) { + public static stripPlainReply(body: string): string { // Removes lines beginning with `> ` until you reach one that doesn't. const lines = body.split('\n'); while (lines.length && lines[0].startsWith('> ')) lines.shift(); @@ -105,7 +105,7 @@ export default class ReplyThread extends React.Component { } // Part of Replies fallback support - static stripHTMLReply(html) { + public static stripHTMLReply(html: string): string { // Sanitize the original HTML for inclusion in . We allow // any HTML, since the original sender could use special tags that we // don't recognize, but want to pass along to any recipients who do @@ -127,7 +127,10 @@ export default class ReplyThread extends React.Component { } // Part of Replies fallback support - static getNestedReplyText(ev, permalinkCreator) { + public static getNestedReplyText( + ev: MatrixEvent, + permalinkCreator: RoomPermalinkCreator, + ): { body: string, html: string } { if (!ev) return null; let { body, formatted_body: html } = ev.getContent(); @@ -203,7 +206,7 @@ export default class ReplyThread extends React.Component { return { body, html }; } - static makeReplyMixIn(ev) { + public static makeReplyMixIn(ev: MatrixEvent) { if (!ev) return {}; return { 'm.relates_to': { @@ -214,10 +217,15 @@ export default class ReplyThread extends React.Component { }; } - static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout, alwaysShowTimestamps) { - if (!ReplyThread.getParentEventId(parentEv)) { - return null; - } + public static makeThread( + parentEv: MatrixEvent, + onHeightChanged: () => void, + permalinkCreator: RoomPermalinkCreator, + ref: React.RefObject, + layout: Layout, + alwaysShowTimestamps: boolean, + ): JSX.Element { + if (!ReplyThread.getParentEventId(parentEv)) return null; return { - 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() { + private async initialize(): Promise { const { parentEv } = this.props; // at time of making this component we checked that props.parentEv has a parentEventId const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv)); @@ -287,7 +267,7 @@ export default class ReplyThread extends React.Component { } } - async getNextEvent(ev) { + private async getNextEvent(ev: MatrixEvent): Promise { try { const inReplyToEventId = ReplyThread.getParentEventId(ev); return await this.getEvent(inReplyToEventId); @@ -296,7 +276,7 @@ export default class ReplyThread extends React.Component { } } - async getEvent(eventId) { + private async getEvent(eventId: string): Promise { if (!eventId) return null; const event = this.room.findEventById(eventId); if (event) return event; @@ -313,15 +293,15 @@ export default class ReplyThread extends React.Component { return this.room.findEventById(eventId); } - canCollapse() { + public canCollapse = (): boolean => { return this.state.events.length > 1; - } + }; - collapse() { + public collapse = (): void => { this.initialize(); - } + }; - async onQuoteClick() { + private onQuoteClick = async (): Promise => { const events = [this.state.loadedEv, ...this.state.events]; let loadedEv = null; @@ -335,6 +315,10 @@ export default class ReplyThread extends React.Component { }); dis.fire(Action.FocusSendMessageComposer); + }; + + private getReplyThreadColorClass(ev: MatrixEvent): string { + return getUserNameColorClass(ev.getSender()).replace("Username", "ReplyThread"); } render() { @@ -349,9 +333,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 +350,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/elements/Spinner.js b/src/components/views/elements/Spinner.js deleted file mode 100644 index 75f85d0441..0000000000 --- a/src/components/views/elements/Spinner.js +++ /dev/null @@ -1,39 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from "react"; -import PropTypes from "prop-types"; -import { _t } from "../../../languageHandler"; - -const Spinner = ({ w = 32, h = 32, message }) => ( -
- { message &&
{ message }
 
} -
-
-); - -Spinner.propTypes = { - w: PropTypes.number, - h: PropTypes.number, - message: PropTypes.node, -}; - -export default Spinner; diff --git a/src/components/views/elements/Spinner.tsx b/src/components/views/elements/Spinner.tsx new file mode 100644 index 0000000000..ee43a5bf0e --- /dev/null +++ b/src/components/views/elements/Spinner.tsx @@ -0,0 +1,45 @@ +/* +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. +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 { _t } from "../../../languageHandler"; + +interface IProps { + w?: number; + h?: number; + message?: string; +} + +export default class Spinner extends React.PureComponent { + public static defaultProps: Partial = { + w: 32, + h: 32, + }; + + public render() { + const { w, h, message } = this.props; + return ( +
+ { message &&
{ message }
 
} +
+
+ ); + } +} diff --git a/src/components/views/elements/StyledRadioButton.tsx b/src/components/views/elements/StyledRadioButton.tsx index 7ec472b639..38d7358524 100644 --- a/src/components/views/elements/StyledRadioButton.tsx +++ b/src/components/views/elements/StyledRadioButton.tsx @@ -20,6 +20,10 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps extends React.InputHTMLAttributes { outlined?: boolean; + // If true (default), the children will be contained within a