diff --git a/res/css/_components.scss b/res/css/_components.scss
index 4efc3f2316..1146a100b5 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -149,6 +149,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";
@@ -263,9 +264,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/_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 <Field>
+        }
+
+        .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/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/_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/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 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M8 16C12.4183 16 16 12.4183 16 8C16 3.58167 12.4183 0 8 0C3.58173 0 0 3.58167 0 8C0 12.4183 3.58173 16 8 16ZM3.96967 5.0304L6.93933 8L3.96967 10.9697L5.03033 12.0304L8 9.06067L10.9697 12.0304L12.0303 10.9697L9.06067 8L12.0303 5.0304L10.9697 3.96973L8 6.93945L5.03033 3.96973L3.96967 5.0304Z" fill="#8D97A5"/>
+</svg>
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<string, string>;
+    fragmentQueryParams?: QueryDict;
 }
 
 /**
@@ -118,8 +119,8 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise<boolean>
         ) {
             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<string, string>,
+    queryParams: QueryDict,
     defaultDeviceDisplayName?: string,
     fragmentAfterLogin?: string,
 ): Promise<boolean> {
@@ -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 95341705bf..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: MatrixEvent): () => 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: MatrixEvent): () => 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 {
@@ -319,7 +318,7 @@ function textForCanonicalAliasEvent(ev: MatrixEvent): () => 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: MatrixEvent): 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/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx
index b3d869cd91..785838ffca 100644
--- a/src/components/structures/MatrixChat.tsx
+++ b/src/components/structures/MatrixChat.tsx
@@ -19,7 +19,7 @@ import { createClient } from "matrix-js-sdk/src/matrix";
 import { InvalidStoreError } from "matrix-js-sdk/src/errors";
 import { RoomMember } from "matrix-js-sdk/src/models/room-member";
 import { MatrixEvent } from "matrix-js-sdk/src/models/event";
-import { sleep, defer, IDeferred } from "matrix-js-sdk/src/utils";
+import { sleep, defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils";
 
 // focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss
 import 'focus-visible';
@@ -105,6 +105,8 @@ import VerificationRequestToast from '../views/toasts/VerificationRequestToast';
 import PerformanceMonitor, { PerformanceEntryNames } from "../../performance";
 import UIStore, { UI_EVENTS } from "../../stores/UIStore";
 import SoftLogout from './auth/SoftLogout';
+import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
+import { copyPlaintext } from "../../utils/strings";
 
 /** constants for MatrixChat.state.view */
 export enum Views {
@@ -153,7 +155,7 @@ const ONBOARDING_FLOW_STARTERS = [
 
 interface IScreen {
     screen: string;
-    params?: object;
+    params?: QueryDict;
 }
 
 /* eslint-disable camelcase */
@@ -183,9 +185,9 @@ interface IProps { // TODO type things better
     onNewScreen: (screen: string, replaceLast: boolean) => void;
     enableGuest?: boolean;
     // the queryParams extracted from the [real] query-string of the URI
-    realQueryParams?: Record<string, string>;
+    realQueryParams?: QueryDict;
     // the initial queryParams extracted from the hash-fragment of the URI
-    startingFragmentQueryParams?: Record<string, string>;
+    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<IProps, IState> {
             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<IProps, IState> {
             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'),
@@ -1193,6 +1198,17 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
         });
     }
 
+    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
@@ -1936,7 +1952,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
         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<IProps, IState> {
                     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<IProps, IState> {
         };
 
         // 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<IProps, IState> {
         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<IProps, IState> {
 
             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<IProps, IState> {
         }
 
         // 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/RoomView.tsx b/src/components/structures/RoomView.tsx
index 2fe694a435..0c10a2aeca 100644
--- a/src/components/structures/RoomView.tsx
+++ b/src/components/structures/RoomView.tsx
@@ -166,6 +166,7 @@ export interface IState {
     canReply: boolean;
     layout: Layout;
     lowBandwidth: boolean;
+    showHiddenEventsInTimeline: boolean;
     showReadReceipts: boolean;
     showRedactions: boolean;
     showJoinLeaves: boolean;
@@ -230,6 +231,7 @@ export default class RoomView extends React.Component<IProps, IState> {
             canReply: false,
             layout: SettingsStore.getValue("layout"),
             lowBandwidth: SettingsStore.getValue("lowBandwidth"),
+            showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline"),
             showReadReceipts: true,
             showRedactions: true,
             showJoinLeaves: true,
@@ -253,7 +255,6 @@ export default class RoomView extends React.Component<IProps, IState> {
         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);
@@ -268,6 +269,9 @@ export default class RoomView extends React.Component<IProps, IState> {
             SettingsStore.watchSetting("lowBandwidth", null, () =>
                 this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }),
             ),
+            SettingsStore.watchSetting("showHiddenEventsInTimeline", null, () =>
+                this.setState({ showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline") }),
+            ),
         ];
     }
 
@@ -637,7 +641,6 @@ export default class RoomView extends React.Component<IProps, IState> {
             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);
@@ -837,8 +840,7 @@ export default class RoomView extends React.Component<IProps, IState> {
         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;
@@ -859,6 +861,10 @@ export default class RoomView extends React.Component<IProps, IState> {
         // 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) {
@@ -871,20 +877,14 @@ export default class RoomView extends React.Component<IProps, IState> {
         }
     };
 
-    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;
 
@@ -1393,7 +1393,7 @@ export default class RoomView extends React.Component<IProps, IState> {
                 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;
diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx
index c21aac790b..59930bf41d 100644
--- a/src/components/structures/TimelinePanel.tsx
+++ b/src/components/structures/TimelinePanel.tsx
@@ -555,9 +555,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
                 // 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<IProps, IState> {
         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;
             }
         }
@@ -1337,8 +1336,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
             }
 
             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/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 }) => (
-    <div className="mx_Spinner">
-        { message && <React.Fragment><div className="mx_Spinner_Msg">{ message }</div>&nbsp;</React.Fragment> }
-        <div
-            className="mx_Spinner_icon"
-            style={{ width: w, height: h }}
-            aria-label={_t("Loading...")}
-        ></div>
-    </div>
-);
-
-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<IProps> {
+    public static defaultProps: Partial<IProps> = {
+        w: 32,
+        h: 32,
+    };
+
+    public render() {
+        const { w, h, message } = this.props;
+        return (
+            <div className="mx_Spinner">
+                { message && <React.Fragment><div className="mx_Spinner_Msg">{ message }</div>&nbsp;</React.Fragment> }
+                <div
+                    className="mx_Spinner_icon"
+                    style={{ width: w, height: h }}
+                    aria-label={_t("Loading...")}
+                />
+            </div>
+        );
+    }
+}
diff --git a/src/components/views/elements/TagComposer.tsx b/src/components/views/elements/TagComposer.tsx
new file mode 100644
index 0000000000..03f501f02c
--- /dev/null
+++ b/src/components/views/elements/TagComposer.tsx
@@ -0,0 +1,91 @@
+/*
+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 React, { ChangeEvent, FormEvent } from "react";
+import { replaceableComponent } from "../../../utils/replaceableComponent";
+import Field from "./Field";
+import { _t } from "../../../languageHandler";
+import AccessibleButton from "./AccessibleButton";
+
+interface IProps {
+    tags: string[];
+    onAdd: (tag: string) => void;
+    onRemove: (tag: string) => void;
+    disabled?: boolean;
+    label?: string;
+    placeholder?: string;
+}
+
+interface IState {
+    newTag: string;
+}
+
+/**
+ * A simple, controlled, composer for entering string tags. Contains a simple
+ * input, add button, and per-tag remove button.
+ */
+@replaceableComponent("views.elements.TagComposer")
+export default class TagComposer extends React.PureComponent<IProps, IState> {
+    public constructor(props: IProps) {
+        super(props);
+
+        this.state = {
+            newTag: "",
+        };
+    }
+
+    private onInputChange = (ev: ChangeEvent<HTMLInputElement>) => {
+        this.setState({ newTag: ev.target.value });
+    };
+
+    private onAdd = (ev: FormEvent) => {
+        ev.preventDefault();
+        if (!this.state.newTag) return;
+
+        this.props.onAdd(this.state.newTag);
+        this.setState({ newTag: "" });
+    };
+
+    private onRemove(tag: string) {
+        // We probably don't need to proxy this, but for
+        // sanity of `this` we'll do so anyways.
+        this.props.onRemove(tag);
+    }
+
+    public render() {
+        return <div className='mx_TagComposer'>
+            <form className='mx_TagComposer_input' onSubmit={this.onAdd}>
+                <Field
+                    value={this.state.newTag}
+                    onChange={this.onInputChange}
+                    label={this.props.label || _t("Keyword")}
+                    placeholder={this.props.placeholder || _t("New keyword")}
+                    disabled={this.props.disabled}
+                    autoComplete="off"
+                />
+                <AccessibleButton onClick={this.onAdd} kind='primary' disabled={this.props.disabled}>
+                    { _t("Add") }
+                </AccessibleButton>
+            </form>
+            <div className='mx_TagComposer_tags'>
+                { this.props.tags.map((t, i) => (<div className='mx_TagComposer_tag' key={i}>
+                    <span>{ t }</span>
+                    <AccessibleButton onClick={this.onRemove.bind(this, t)} disabled={this.props.disabled} />
+                </div>)) }
+            </div>
+        </div>;
+    }
+}
diff --git a/src/components/views/messages/TextualEvent.tsx b/src/components/views/messages/TextualEvent.tsx
index 70f90a33e4..8fc116b5d0 100644
--- a/src/components/views/messages/TextualEvent.tsx
+++ b/src/components/views/messages/TextualEvent.tsx
@@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React from 'react';
-import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
+import React from "react";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
 
+import RoomContext from "../../../contexts/RoomContext";
 import * as TextForEvent from "../../../TextForEvent";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
 
@@ -26,11 +27,11 @@ interface IProps {
 
 @replaceableComponent("views.messages.TextualEvent")
 export default class TextualEvent extends React.Component<IProps> {
-    render() {
-        const text = TextForEvent.textForEvent(this.props.mxEvent, true);
-        if (!text || (text as string).length === 0) return null;
-        return (
-            <div className="mx_TextualEvent">{ text }</div>
-        );
+    static contextType = RoomContext;
+
+    public render() {
+        const text = TextForEvent.textForEvent(this.props.mxEvent, true, this.context?.showHiddenEventsInTimeline);
+        if (!text) return null;
+        return <div className="mx_TextualEvent">{ text }</div>;
     }
 }
diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx
index 63ac8ff375..e0a924f1e7 100644
--- a/src/components/views/rooms/EventTile.tsx
+++ b/src/components/views/rooms/EventTile.tsx
@@ -1160,7 +1160,7 @@ function isMessageEvent(ev) {
     return (messageTypes.includes(ev.getType()));
 }
 
-export function haveTileForEvent(e) {
+export function haveTileForEvent(e: MatrixEvent, showHiddenEvents?: boolean) {
     // Only messages have a tile (black-rectangle) if redacted
     if (e.isRedacted() && !isMessageEvent(e)) return false;
 
@@ -1170,7 +1170,7 @@ export function haveTileForEvent(e) {
     const handler = getHandlerTile(e);
     if (handler === undefined) return false;
     if (handler === 'messages.TextualEvent') {
-        return hasText(e);
+        return hasText(e, showHiddenEvents);
     } else if (handler === 'messages.RoomCreate') {
         return Boolean(e.getContent()['predecessor']);
     } else {
diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx
index fce9e297a1..8d825a2b53 100644
--- a/src/components/views/rooms/RoomSublist.tsx
+++ b/src/components/views/rooms/RoomSublist.tsx
@@ -408,10 +408,10 @@ export default class RoomSublist extends React.Component<IProps, IState> {
         this.setState({ addRoomContextMenuPosition: null });
     };
 
-    private onUnreadFirstChanged = async () => {
+    private onUnreadFirstChanged = () => {
         const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
         const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance;
-        await RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm);
+        RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm);
         this.forceUpdate(); // because if the sublist doesn't have any changes then we will miss the list order change
     };
 
diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx
index 9be0274dd5..c9386c3868 100644
--- a/src/components/views/rooms/RoomTile.tsx
+++ b/src/components/views/rooms/RoomTile.tsx
@@ -358,6 +358,17 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
         this.setState({ generalMenuPosition: null }); // hide the menu
     };
 
+    private onCopyRoomClick = (ev: ButtonEvent) => {
+        ev.preventDefault();
+        ev.stopPropagation();
+
+        dis.dispatch({
+            action: 'copy_room',
+            room_id: this.props.room.roomId,
+        });
+        this.setState({ generalMenuPosition: null }); // hide the menu
+    };
+
     private onInviteClick = (ev: ButtonEvent) => {
         ev.preventDefault();
         ev.stopPropagation();
@@ -408,7 +419,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
             >
                 <IconizedContextMenuOptionList first>
                     <IconizedContextMenuRadio
-                        label={_t("Use default")}
+                        label={_t("Global")}
                         active={state === ALL_MESSAGES}
                         iconClassName="mx_RoomTile_iconBell"
                         onClick={this.onClickAllNotifs}
@@ -517,6 +528,11 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
                             iconClassName="mx_RoomTile_iconInvite"
                         />
                     ) : null}
+                    <IconizedContextMenuOption
+                        onClick={this.onCopyRoomClick}
+                        label={_t("Copy Link")}
+                        iconClassName="mx_RoomTile_iconCopyLink"
+                    />
                     <IconizedContextMenuOption
                         onClick={this.onOpenRoomSettings}
                         label={_t("Settings")}
diff --git a/src/components/views/rooms/SearchResultTile.tsx b/src/components/views/rooms/SearchResultTile.tsx
index 980e8835f8..c033855eb5 100644
--- a/src/components/views/rooms/SearchResultTile.tsx
+++ b/src/components/views/rooms/SearchResultTile.tsx
@@ -15,14 +15,15 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React from 'react';
+import React from "react";
 import { SearchResult } from "matrix-js-sdk/src/models/search-result";
-import EventTile, { haveTileForEvent } from "./EventTile";
-import DateSeparator from '../messages/DateSeparator';
+import RoomContext from "../../../contexts/RoomContext";
 import SettingsStore from "../../../settings/SettingsStore";
 import { UIFeature } from "../../../settings/UIFeature";
 import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
 import { replaceableComponent } from "../../../utils/replaceableComponent";
+import DateSeparator from "../messages/DateSeparator";
+import EventTile, { haveTileForEvent } from "./EventTile";
 
 interface IProps {
     // a matrix-js-sdk SearchResult containing the details of this result
@@ -37,6 +38,8 @@ interface IProps {
 
 @replaceableComponent("views.rooms.SearchResultTile")
 export default class SearchResultTile extends React.Component<IProps> {
+    static contextType = RoomContext;
+
     public render() {
         const result = this.props.searchResult;
         const mxEv = result.context.getEvent();
@@ -44,7 +47,10 @@ export default class SearchResultTile extends React.Component<IProps> {
 
         const ts1 = mxEv.getTs();
         const ret = [<DateSeparator key={ts1 + "-search"} ts={ts1} />];
+        const layout = SettingsStore.getValue("layout");
+        const isTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
         const alwaysShowTimestamps = SettingsStore.getValue("alwaysShowTimestamps");
+        const enableFlair = SettingsStore.getValue(UIFeature.Flair);
 
         const timeline = result.context.getTimeline();
         for (let j = 0; j < timeline.length; j++) {
@@ -54,26 +60,25 @@ export default class SearchResultTile extends React.Component<IProps> {
             if (!contextual) {
                 highlights = this.props.searchHighlights;
             }
-            if (haveTileForEvent(ev)) {
-                ret.push((
+            if (haveTileForEvent(ev, this.context?.showHiddenEventsInTimeline)) {
+                ret.push(
                     <EventTile
                         key={`${eventId}+${j}`}
                         mxEvent={ev}
+                        layout={layout}
                         contextual={contextual}
                         highlights={highlights}
                         permalinkCreator={this.props.permalinkCreator}
                         highlightLink={this.props.resultLink}
                         onHeightChanged={this.props.onHeightChanged}
-                        isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
+                        isTwelveHour={isTwelveHour}
                         alwaysShowTimestamps={alwaysShowTimestamps}
-                        enableFlair={SettingsStore.getValue(UIFeature.Flair)}
-                    />
-                ));
+                        enableFlair={enableFlair}
+                    />,
+                );
             }
         }
-        return (
-            <li data-scroll-tokens={eventId}>
-                { ret }
-            </li>);
+
+        return <li data-scroll-tokens={eventId}>{ ret }</li>;
     }
 }
diff --git a/src/components/views/settings/Notifications.js b/src/components/views/settings/Notifications.js
deleted file mode 100644
index c263ff50c8..0000000000
--- a/src/components/views/settings/Notifications.js
+++ /dev/null
@@ -1,917 +0,0 @@
-/*
-Copyright 2016 OpenMarket Ltd
-Copyright 2020 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-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 * as sdk from '../../../index';
-import { _t } from '../../../languageHandler';
-import { MatrixClientPeg } from '../../../MatrixClientPeg';
-import SettingsStore from '../../../settings/SettingsStore';
-import Modal from '../../../Modal';
-import {
-    NotificationUtils,
-    VectorPushRulesDefinitions,
-    PushRuleVectorState,
-    ContentRules,
-} from '../../../notifications';
-import SdkConfig from "../../../SdkConfig";
-import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
-import AccessibleButton from "../elements/AccessibleButton";
-import { SettingLevel } from "../../../settings/SettingLevel";
-import { UIFeature } from "../../../settings/UIFeature";
-import { replaceableComponent } from "../../../utils/replaceableComponent";
-
-// TODO: this "view" component still has far too much application logic in it,
-// which should be factored out to other files.
-
-// TODO: this component also does a lot of direct poking into this.state, which
-// is VERY NAUGHTY.
-
-/**
- * Rules that Vector used to set in order to override the actions of default rules.
- * These are used to port peoples existing overrides to match the current API.
- * These can be removed and forgotten once everyone has moved to the new client.
- */
-const LEGACY_RULES = {
-    "im.vector.rule.contains_display_name": ".m.rule.contains_display_name",
-    "im.vector.rule.room_one_to_one": ".m.rule.room_one_to_one",
-    "im.vector.rule.room_message": ".m.rule.message",
-    "im.vector.rule.invite_for_me": ".m.rule.invite_for_me",
-    "im.vector.rule.call": ".m.rule.call",
-    "im.vector.rule.notices": ".m.rule.suppress_notices",
-};
-
-function portLegacyActions(actions) {
-    const decoded = NotificationUtils.decodeActions(actions);
-    if (decoded !== null) {
-        return NotificationUtils.encodeActions(decoded);
-    } else {
-        // We don't recognise one of the actions here, so we don't try to
-        // canonicalise them.
-        return actions;
-    }
-}
-
-@replaceableComponent("views.settings.Notifications")
-export default class Notifications extends React.Component {
-    static phases = {
-        LOADING: "LOADING", // The component is loading or sending data to the hs
-        DISPLAY: "DISPLAY", // The component is ready and display data
-        ERROR: "ERROR", // There was an error
-    };
-
-    state = {
-        phase: Notifications.phases.LOADING,
-        masterPushRule: undefined, // The master rule ('.m.rule.master')
-        vectorPushRules: [], // HS default push rules displayed in Vector UI
-        vectorContentRules: { // Keyword push rules displayed in Vector UI
-            vectorState: PushRuleVectorState.ON,
-            rules: [],
-        },
-        externalPushRules: [], // Push rules (except content rule) that have been defined outside Vector UI
-        externalContentRules: [], // Keyword push rules that have been defined outside Vector UI
-        threepids: [], // used for email notifications
-    };
-
-    componentDidMount() {
-        this._refreshFromServer();
-    }
-
-    onEnableNotificationsChange = (checked) => {
-        const self = this;
-        this.setState({
-            phase: Notifications.phases.LOADING,
-        });
-
-        MatrixClientPeg.get().setPushRuleEnabled(
-            'global', self.state.masterPushRule.kind, self.state.masterPushRule.rule_id, !checked,
-        ).then(function() {
-            self._refreshFromServer();
-        });
-    };
-
-    onEnableDesktopNotificationsChange = (checked) => {
-        SettingsStore.setValue(
-            "notificationsEnabled", null,
-            SettingLevel.DEVICE,
-            checked,
-        ).finally(() => {
-            this.forceUpdate();
-        });
-    };
-
-    onEnableDesktopNotificationBodyChange = (checked) => {
-        SettingsStore.setValue(
-            "notificationBodyEnabled", null,
-            SettingLevel.DEVICE,
-            checked,
-        ).finally(() => {
-            this.forceUpdate();
-        });
-    };
-
-    onEnableAudioNotificationsChange = (checked) => {
-        SettingsStore.setValue(
-            "audioNotificationsEnabled", null,
-            SettingLevel.DEVICE,
-            checked,
-        ).finally(() => {
-            this.forceUpdate();
-        });
-    };
-
-    /*
-     * Returns the email pusher (pusher of type 'email') for a given
-     * email address. Email pushers all have the same app ID, so since
-     * pushers are unique over (app ID, pushkey), there will be at most
-     * one such pusher.
-     */
-    getEmailPusher(pushers, address) {
-        if (pushers === undefined) {
-            return undefined;
-        }
-        for (let i = 0; i < pushers.length; ++i) {
-            if (pushers[i].kind === 'email' && pushers[i].pushkey === address) {
-                return pushers[i];
-            }
-        }
-        return undefined;
-    }
-
-    onEnableEmailNotificationsChange = (address, checked) => {
-        let emailPusherPromise;
-        if (checked) {
-            const data = {};
-            data['brand'] = SdkConfig.get().brand;
-            emailPusherPromise = MatrixClientPeg.get().setPusher({
-                kind: 'email',
-                app_id: 'm.email',
-                pushkey: address,
-                app_display_name: 'Email Notifications',
-                device_display_name: address,
-                lang: navigator.language,
-                data: data,
-                append: true, // We always append for email pushers since we don't want to stop other accounts notifying to the same email address
-            });
-        } else {
-            const emailPusher = this.getEmailPusher(this.state.pushers, address);
-            emailPusher.kind = null;
-            emailPusherPromise = MatrixClientPeg.get().setPusher(emailPusher);
-        }
-        emailPusherPromise.then(() => {
-            this._refreshFromServer();
-        }, (error) => {
-            const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
-            Modal.createTrackedDialog('Error saving email notification preferences', '', ErrorDialog, {
-                title: _t('Error saving email notification preferences'),
-                description: _t('An error occurred whilst saving your email notification preferences.'),
-            });
-        });
-    };
-
-    onNotifStateButtonClicked = (event) => {
-        // FIXME: use .bind() rather than className metadata here surely
-        const vectorRuleId = event.target.className.split("-")[0];
-        const newPushRuleVectorState = event.target.className.split("-")[1];
-
-        if ("_keywords" === vectorRuleId) {
-            this._setKeywordsPushRuleVectorState(newPushRuleVectorState);
-        } else {
-            const rule = this.getRule(vectorRuleId);
-            if (rule) {
-                this._setPushRuleVectorState(rule, newPushRuleVectorState);
-            }
-        }
-    };
-
-    onKeywordsClicked = (event) => {
-        // Compute the keywords list to display
-        let keywords = [];
-        for (const i in this.state.vectorContentRules.rules) {
-            const rule = this.state.vectorContentRules.rules[i];
-            keywords.push(rule.pattern);
-        }
-        if (keywords.length) {
-            // As keeping the order of per-word push rules hs side is a bit tricky to code,
-            // display the keywords in alphabetical order to the user
-            keywords.sort();
-
-            keywords = keywords.join(", ");
-        } else {
-            keywords = "";
-        }
-
-        const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog");
-        Modal.createTrackedDialog('Keywords Dialog', '', TextInputDialog, {
-            title: _t('Keywords'),
-            description: _t('Enter keywords separated by a comma:'),
-            button: _t('OK'),
-            value: keywords,
-            onFinished: (shouldLeave, newValue) => {
-                if (shouldLeave && newValue !== keywords) {
-                    let newKeywords = newValue.split(',');
-                    for (const i in newKeywords) {
-                        newKeywords[i] = newKeywords[i].trim();
-                    }
-
-                    // Remove duplicates and empty
-                    newKeywords = newKeywords.reduce(function(array, keyword) {
-                        if (keyword !== "" && array.indexOf(keyword) < 0) {
-                            array.push(keyword);
-                        }
-                        return array;
-                    }, []);
-
-                    this._setKeywords(newKeywords);
-                }
-            },
-        });
-    };
-
-    getRule(vectorRuleId) {
-        for (const i in this.state.vectorPushRules) {
-            const rule = this.state.vectorPushRules[i];
-            if (rule.vectorRuleId === vectorRuleId) {
-                return rule;
-            }
-        }
-    }
-
-    _setPushRuleVectorState(rule, newPushRuleVectorState) {
-        if (rule && rule.vectorState !== newPushRuleVectorState) {
-            this.setState({
-                phase: Notifications.phases.LOADING,
-            });
-
-            const self = this;
-            const cli = MatrixClientPeg.get();
-            const deferreds = [];
-            const ruleDefinition = VectorPushRulesDefinitions[rule.vectorRuleId];
-
-            if (rule.rule) {
-                const actions = ruleDefinition.vectorStateToActions[newPushRuleVectorState];
-
-                if (!actions) {
-                    // The new state corresponds to disabling the rule.
-                    deferreds.push(cli.setPushRuleEnabled('global', rule.rule.kind, rule.rule.rule_id, false));
-                } else {
-                    // The new state corresponds to enabling the rule and setting specific actions
-                    deferreds.push(this._updatePushRuleActions(rule.rule, actions, true));
-                }
-            }
-
-            Promise.all(deferreds).then(function() {
-                self._refreshFromServer();
-            }, function(error) {
-                const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
-                console.error("Failed to change settings: " + error);
-                Modal.createTrackedDialog('Failed to change settings', '', ErrorDialog, {
-                    title: _t('Failed to change settings'),
-                    description: ((error && error.message) ? error.message : _t('Operation failed')),
-                    onFinished: self._refreshFromServer,
-                });
-            });
-        }
-    }
-
-    _setKeywordsPushRuleVectorState(newPushRuleVectorState) {
-        // Is there really a change?
-        if (this.state.vectorContentRules.vectorState === newPushRuleVectorState
-            || this.state.vectorContentRules.rules.length === 0) {
-            return;
-        }
-
-        const self = this;
-        const cli = MatrixClientPeg.get();
-
-        this.setState({
-            phase: Notifications.phases.LOADING,
-        });
-
-        // Update all rules in self.state.vectorContentRules
-        const deferreds = [];
-        for (const i in this.state.vectorContentRules.rules) {
-            const rule = this.state.vectorContentRules.rules[i];
-
-            let enabled; let actions;
-            switch (newPushRuleVectorState) {
-                case PushRuleVectorState.ON:
-                    if (rule.actions.length !== 1) {
-                        actions = PushRuleVectorState.actionsFor(PushRuleVectorState.ON);
-                    }
-
-                    if (this.state.vectorContentRules.vectorState === PushRuleVectorState.OFF) {
-                        enabled = true;
-                    }
-                    break;
-
-                case PushRuleVectorState.LOUD:
-                    if (rule.actions.length !== 3) {
-                        actions = PushRuleVectorState.actionsFor(PushRuleVectorState.LOUD);
-                    }
-
-                    if (this.state.vectorContentRules.vectorState === PushRuleVectorState.OFF) {
-                        enabled = true;
-                    }
-                    break;
-
-                case PushRuleVectorState.OFF:
-                    enabled = false;
-                    break;
-            }
-
-            if (actions) {
-                // Note that the workaround in _updatePushRuleActions will automatically
-                // enable the rule
-                deferreds.push(this._updatePushRuleActions(rule, actions, enabled));
-            } else if (enabled != undefined) {
-                deferreds.push(cli.setPushRuleEnabled('global', rule.kind, rule.rule_id, enabled));
-            }
-        }
-
-        Promise.all(deferreds).then(function(resps) {
-            self._refreshFromServer();
-        }, function(error) {
-            const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
-            console.error("Can't update user notification settings: " + error);
-            Modal.createTrackedDialog('Can\'t update user notifcation settings', '', ErrorDialog, {
-                title: _t('Can\'t update user notification settings'),
-                description: ((error && error.message) ? error.message : _t('Operation failed')),
-                onFinished: self._refreshFromServer,
-            });
-        });
-    }
-
-    _setKeywords(newKeywords) {
-        this.setState({
-            phase: Notifications.phases.LOADING,
-        });
-
-        const self = this;
-        const cli = MatrixClientPeg.get();
-        const removeDeferreds = [];
-
-        // Remove per-word push rules of keywords that are no more in the list
-        const vectorContentRulesPatterns = [];
-        for (const i in self.state.vectorContentRules.rules) {
-            const rule = self.state.vectorContentRules.rules[i];
-
-            vectorContentRulesPatterns.push(rule.pattern);
-
-            if (newKeywords.indexOf(rule.pattern) < 0) {
-                removeDeferreds.push(cli.deletePushRule('global', rule.kind, rule.rule_id));
-            }
-        }
-
-        // If the keyword is part of `externalContentRules`, remove the rule
-        // before recreating it in the right Vector path
-        for (const i in self.state.externalContentRules) {
-            const rule = self.state.externalContentRules[i];
-
-            if (newKeywords.indexOf(rule.pattern) >= 0) {
-                removeDeferreds.push(cli.deletePushRule('global', rule.kind, rule.rule_id));
-            }
-        }
-
-        const onError = function(error) {
-            const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
-            console.error("Failed to update keywords: " + error);
-            Modal.createTrackedDialog('Failed to update keywords', '', ErrorDialog, {
-                title: _t('Failed to update keywords'),
-                description: ((error && error.message) ? error.message : _t('Operation failed')),
-                onFinished: self._refreshFromServer,
-            });
-        };
-
-        // Then, add the new ones
-        Promise.all(removeDeferreds).then(function(resps) {
-            const deferreds = [];
-
-            let pushRuleVectorStateKind = self.state.vectorContentRules.vectorState;
-            if (pushRuleVectorStateKind === PushRuleVectorState.OFF) {
-                // When the current global keywords rule is OFF, we need to look at
-                // the flavor of rules in 'vectorContentRules' to apply the same actions
-                // when creating the new rule.
-                // Thus, this new rule will join the 'vectorContentRules' set.
-                if (self.state.vectorContentRules.rules.length) {
-                    pushRuleVectorStateKind = PushRuleVectorState.contentRuleVectorStateKind(
-                        self.state.vectorContentRules.rules[0],
-                    );
-                } else {
-                    // ON is default
-                    pushRuleVectorStateKind = PushRuleVectorState.ON;
-                }
-            }
-
-            for (const i in newKeywords) {
-                const keyword = newKeywords[i];
-
-                if (vectorContentRulesPatterns.indexOf(keyword) < 0) {
-                    if (self.state.vectorContentRules.vectorState !== PushRuleVectorState.OFF) {
-                        deferreds.push(cli.addPushRule('global', 'content', keyword, {
-                            actions: PushRuleVectorState.actionsFor(pushRuleVectorStateKind),
-                            pattern: keyword,
-                        }));
-                    } else {
-                        deferreds.push(self._addDisabledPushRule('global', 'content', keyword, {
-                           actions: PushRuleVectorState.actionsFor(pushRuleVectorStateKind),
-                           pattern: keyword,
-                        }));
-                    }
-                }
-            }
-
-            Promise.all(deferreds).then(function(resps) {
-                self._refreshFromServer();
-            }, onError);
-        }, onError);
-    }
-
-    // Create a push rule but disabled
-    _addDisabledPushRule(scope, kind, ruleId, body) {
-        const cli = MatrixClientPeg.get();
-        return cli.addPushRule(scope, kind, ruleId, body).then(() =>
-            cli.setPushRuleEnabled(scope, kind, ruleId, false),
-        );
-    }
-
-    // Check if any legacy im.vector rules need to be ported to the new API
-    // for overriding the actions of default rules.
-    _portRulesToNewAPI(rulesets) {
-        const needsUpdate = [];
-        const cli = MatrixClientPeg.get();
-
-        for (const kind in rulesets.global) {
-            const ruleset = rulesets.global[kind];
-            for (let i = 0; i < ruleset.length; ++i) {
-                const rule = ruleset[i];
-                if (rule.rule_id in LEGACY_RULES) {
-                    console.log("Porting legacy rule", rule);
-                    needsUpdate.push( function(kind, rule) {
-                        return cli.setPushRuleActions(
-                            'global', kind, LEGACY_RULES[rule.rule_id], portLegacyActions(rule.actions),
-                        ).then(() =>
-                            cli.deletePushRule('global', kind, rule.rule_id),
-                        ).catch( (e) => {
-                            console.warn(`Error when porting legacy rule: ${e}`);
-                        });
-                    }(kind, rule));
-                }
-            }
-        }
-
-        if (needsUpdate.length > 0) {
-            // If some of the rules need to be ported then wait for the porting
-            // to happen and then fetch the rules again.
-            return Promise.all(needsUpdate).then(() =>
-                cli.getPushRules(),
-            );
-        } else {
-            // Otherwise return the rules that we already have.
-            return rulesets;
-        }
-    }
-
-    _refreshFromServer = () => {
-        const self = this;
-        const pushRulesPromise = MatrixClientPeg.get().getPushRules().then(
-            self._portRulesToNewAPI,
-        ).then(function(rulesets) {
-            /// XXX seriously? wtf is this?
-            MatrixClientPeg.get().pushRules = rulesets;
-
-            // Get homeserver default rules and triage them by categories
-            const ruleCategories = {
-                // The master rule (all notifications disabling)
-                '.m.rule.master': 'master',
-
-                // The default push rules displayed by Vector UI
-                '.m.rule.contains_display_name': 'vector',
-                '.m.rule.contains_user_name': 'vector',
-                '.m.rule.roomnotif': 'vector',
-                '.m.rule.room_one_to_one': 'vector',
-                '.m.rule.encrypted_room_one_to_one': 'vector',
-                '.m.rule.message': 'vector',
-                '.m.rule.encrypted': 'vector',
-                '.m.rule.invite_for_me': 'vector',
-                //'.m.rule.member_event': 'vector',
-                '.m.rule.call': 'vector',
-                '.m.rule.suppress_notices': 'vector',
-                '.m.rule.tombstone': 'vector',
-
-                // Others go to others
-            };
-
-            // HS default rules
-            const defaultRules = { master: [], vector: {}, others: [] };
-
-            for (const kind in rulesets.global) {
-                for (let i = 0; i < Object.keys(rulesets.global[kind]).length; ++i) {
-                    const r = rulesets.global[kind][i];
-                    const cat = ruleCategories[r.rule_id];
-                    r.kind = kind;
-
-                    if (r.rule_id[0] === '.') {
-                        if (cat === 'vector') {
-                            defaultRules.vector[r.rule_id] = r;
-                        } else if (cat === 'master') {
-                            defaultRules.master.push(r);
-                        } else {
-                            defaultRules['others'].push(r);
-                        }
-                    }
-                }
-            }
-
-            // Get the master rule if any defined by the hs
-            if (defaultRules.master.length > 0) {
-                self.state.masterPushRule = defaultRules.master[0];
-            }
-
-            // parse the keyword rules into our state
-            const contentRules = ContentRules.parseContentRules(rulesets);
-            self.state.vectorContentRules = {
-                vectorState: contentRules.vectorState,
-                rules: contentRules.rules,
-            };
-            self.state.externalContentRules = contentRules.externalRules;
-
-            // Build the rules displayed in the Vector UI matrix table
-            self.state.vectorPushRules = [];
-            self.state.externalPushRules = [];
-
-            const vectorRuleIds = [
-                '.m.rule.contains_display_name',
-                '.m.rule.contains_user_name',
-                '.m.rule.roomnotif',
-                '_keywords',
-                '.m.rule.room_one_to_one',
-                '.m.rule.encrypted_room_one_to_one',
-                '.m.rule.message',
-                '.m.rule.encrypted',
-                '.m.rule.invite_for_me',
-                //'im.vector.rule.member_event',
-                '.m.rule.call',
-                '.m.rule.suppress_notices',
-                '.m.rule.tombstone',
-            ];
-            for (const i in vectorRuleIds) {
-                const vectorRuleId = vectorRuleIds[i];
-
-                if (vectorRuleId === '_keywords') {
-                    // keywords needs a special handling
-                    // For Vector UI, this is a single global push rule but translated in Matrix,
-                    // it corresponds to all content push rules (stored in self.state.vectorContentRule)
-                    self.state.vectorPushRules.push({
-                        "vectorRuleId": "_keywords",
-                        "description": (
-                            <span>
-                                { _t('Messages containing <span>keywords</span>',
-                                    {},
-                                    { 'span': (sub) =>
-                                        <span className="mx_UserNotifSettings_keywords" onClick={ self.onKeywordsClicked }>{sub}</span>,
-                                    },
-                                )}
-                            </span>
-                        ),
-                        "vectorState": self.state.vectorContentRules.vectorState,
-                    });
-                } else {
-                    const ruleDefinition = VectorPushRulesDefinitions[vectorRuleId];
-                    const rule = defaultRules.vector[vectorRuleId];
-
-                    const vectorState = ruleDefinition.ruleToVectorState(rule);
-
-                    //console.log("Refreshing vectorPushRules for " + vectorRuleId +", "+ ruleDefinition.description +", " + rule +", " + vectorState);
-
-                    self.state.vectorPushRules.push({
-                        "vectorRuleId": vectorRuleId,
-                        "description": _t(ruleDefinition.description), // Text from VectorPushRulesDefinitions.js
-                        "rule": rule,
-                        "vectorState": vectorState,
-                    });
-
-                    // if there was a rule which we couldn't parse, add it to the external list
-                    if (rule && !vectorState) {
-                        rule.description = ruleDefinition.description;
-                        self.state.externalPushRules.push(rule);
-                    }
-                }
-            }
-
-            // Build the rules not managed by Vector UI
-            const otherRulesDescriptions = {
-                '.m.rule.message': _t('Notify for all other messages/rooms'),
-                '.m.rule.fallback': _t('Notify me for anything else'),
-            };
-
-            for (const i in defaultRules.others) {
-                const rule = defaultRules.others[i];
-                const ruleDescription = otherRulesDescriptions[rule.rule_id];
-
-                // Show enabled default rules that was modified by the user
-                if (ruleDescription && rule.enabled && !rule.default) {
-                    rule.description = ruleDescription;
-                    self.state.externalPushRules.push(rule);
-                }
-            }
-        });
-
-        const pushersPromise = MatrixClientPeg.get().getPushers().then(function(resp) {
-            self.setState({ pushers: resp.pushers });
-        });
-
-        Promise.all([pushRulesPromise, pushersPromise]).then(function() {
-            self.setState({
-                phase: Notifications.phases.DISPLAY,
-            });
-        }, function(error) {
-            console.error(error);
-            self.setState({
-                phase: Notifications.phases.ERROR,
-            });
-        }).finally(() => {
-            // actually explicitly update our state  having been deep-manipulating it
-            self.setState({
-                masterPushRule: self.state.masterPushRule,
-                vectorContentRules: self.state.vectorContentRules,
-                vectorPushRules: self.state.vectorPushRules,
-                externalContentRules: self.state.externalContentRules,
-                externalPushRules: self.state.externalPushRules,
-            });
-        });
-
-        MatrixClientPeg.get().getThreePids().then((r) => this.setState({ threepids: r.threepids }));
-    };
-
-    _onClearNotifications = () => {
-        const cli = MatrixClientPeg.get();
-
-        cli.getRooms().forEach(r => {
-            if (r.getUnreadNotificationCount() > 0) {
-                const events = r.getLiveTimeline().getEvents();
-                if (events.length) cli.sendReadReceipt(events.pop());
-            }
-        });
-    };
-
-    _updatePushRuleActions(rule, actions, enabled) {
-        const cli = MatrixClientPeg.get();
-
-        return cli.setPushRuleActions(
-            'global', rule.kind, rule.rule_id, actions,
-        ).then( function() {
-            // Then, if requested, enabled or disabled the rule
-            if (undefined != enabled) {
-                return cli.setPushRuleEnabled(
-                    'global', rule.kind, rule.rule_id, enabled,
-                );
-            }
-        });
-    }
-
-    renderNotifRulesTableRow(title, className, pushRuleVectorState) {
-        return (
-            <tr key={ className }>
-                <th>
-                    { title }
-                </th>
-
-                <th>
-                    <input className= {className + "-" + PushRuleVectorState.OFF}
-                        type="radio"
-                        checked={ pushRuleVectorState === PushRuleVectorState.OFF }
-                        onChange={ this.onNotifStateButtonClicked } />
-                </th>
-
-                <th>
-                    <input className= {className + "-" + PushRuleVectorState.ON}
-                        type="radio"
-                        checked={ pushRuleVectorState === PushRuleVectorState.ON }
-                        onChange={ this.onNotifStateButtonClicked } />
-                </th>
-
-                <th>
-                    <input className= {className + "-" + PushRuleVectorState.LOUD}
-                        type="radio"
-                        checked={ pushRuleVectorState === PushRuleVectorState.LOUD }
-                        onChange={ this.onNotifStateButtonClicked } />
-                </th>
-            </tr>
-        );
-    }
-
-    renderNotifRulesTableRows() {
-        const rows = [];
-        for (const i in this.state.vectorPushRules) {
-            const rule = this.state.vectorPushRules[i];
-            if (rule.rule === undefined && rule.vectorRuleId.startsWith(".m.")) {
-                console.warn(`Skipping render of rule ${rule.vectorRuleId} due to no underlying rule`);
-                continue;
-            }
-            //console.log("rendering: " + rule.description + ", " + rule.vectorRuleId + ", " + rule.vectorState);
-            rows.push(this.renderNotifRulesTableRow(rule.description, rule.vectorRuleId, rule.vectorState));
-        }
-        return rows;
-    }
-
-    hasEmailPusher(pushers, address) {
-        if (pushers === undefined) {
-            return false;
-        }
-        for (let i = 0; i < pushers.length; ++i) {
-            if (pushers[i].kind === 'email' && pushers[i].pushkey === address) {
-                return true;
-            }
-        }
-        return false;
-    }
-
-    emailNotificationsRow(address, label) {
-        return <LabelledToggleSwitch value={this.hasEmailPusher(this.state.pushers, address)}
-            onChange={this.onEnableEmailNotificationsChange.bind(this, address)}
-            label={label} key={`emailNotif_${label}`} />;
-    }
-
-    render() {
-        let spinner;
-        if (this.state.phase === Notifications.phases.LOADING) {
-            const Loader = sdk.getComponent("elements.Spinner");
-            spinner = <Loader />;
-        }
-
-        let masterPushRuleDiv;
-        if (this.state.masterPushRule) {
-            masterPushRuleDiv = <LabelledToggleSwitch value={!this.state.masterPushRule.enabled}
-                onChange={this.onEnableNotificationsChange}
-                label={_t('Enable notifications for this account')} />;
-        }
-
-        let clearNotificationsButton;
-        if (MatrixClientPeg.get().getRooms().some(r => r.getUnreadNotificationCount() > 0)) {
-            clearNotificationsButton = <AccessibleButton onClick={this._onClearNotifications} kind='danger'>
-                {_t("Clear notifications")}
-            </AccessibleButton>;
-        }
-
-        // When enabled, the master rule inhibits all existing rules
-        // So do not show all notification settings
-        if (this.state.masterPushRule && this.state.masterPushRule.enabled) {
-            return (
-                <div>
-                    {masterPushRuleDiv}
-
-                    <div className="mx_UserNotifSettings_notifTable">
-                        { _t('All notifications are currently disabled for all targets.') }
-                    </div>
-
-                    {clearNotificationsButton}
-                </div>
-            );
-        }
-
-        const emailThreepids = this.state.threepids.filter((tp) => tp.medium === "email");
-        let emailNotificationsRows;
-        if (emailThreepids.length > 0) {
-            emailNotificationsRows = emailThreepids.map((threePid) => this.emailNotificationsRow(
-                threePid.address, `${_t('Enable email notifications')} (${threePid.address})`,
-            ));
-        } else if (SettingsStore.getValue(UIFeature.ThirdPartyID)) {
-            emailNotificationsRows = <div>
-                { _t('Add an email address to configure email notifications') }
-            </div>;
-        }
-
-        // Build external push rules
-        const externalRules = [];
-        for (const i in this.state.externalPushRules) {
-            const rule = this.state.externalPushRules[i];
-            externalRules.push(<li>{ _t(rule.description) }</li>);
-        }
-
-        // Show keywords not displayed by the vector UI as a single external push rule
-        let externalKeywords = [];
-        for (const i in this.state.externalContentRules) {
-            const rule = this.state.externalContentRules[i];
-            externalKeywords.push(rule.pattern);
-        }
-        if (externalKeywords.length) {
-            externalKeywords = externalKeywords.join(", ");
-            externalRules.push(<li>
-                {_t('Notifications on the following keywords follow rules which can’t be displayed here:') }
-                { externalKeywords }
-            </li>);
-        }
-
-        let devicesSection;
-        if (this.state.pushers === undefined) {
-            devicesSection = <div className="error">{ _t('Unable to fetch notification target list') }</div>;
-        } else if (this.state.pushers.length === 0) {
-            devicesSection = null;
-        } else {
-            // TODO: It would be great to be able to delete pushers from here too,
-            // and this wouldn't be hard to add.
-            const rows = [];
-            for (let i = 0; i < this.state.pushers.length; ++i) {
-                rows.push(<tr key={ i }>
-                    <td>{this.state.pushers[i].app_display_name}</td>
-                    <td>{this.state.pushers[i].device_display_name}</td>
-                </tr>);
-            }
-            devicesSection = (<table className="mx_UserNotifSettings_devicesTable">
-                <tbody>
-                    {rows}
-                </tbody>
-            </table>);
-        }
-        if (devicesSection) {
-            devicesSection = (<div>
-                <h3>{ _t('Notification targets') }</h3>
-                { devicesSection }
-            </div>);
-        }
-
-        let advancedSettings;
-        if (externalRules.length) {
-            const brand = SdkConfig.get().brand;
-            advancedSettings = (
-                <div>
-                    <h3>{ _t('Advanced notification settings') }</h3>
-                    { _t('There are advanced notifications which are not shown here.') }<br />
-                    {_t(
-                        'You might have configured them in a client other than %(brand)s. ' +
-                        'You cannot tune them in %(brand)s but they still apply.',
-                        { brand },
-                    )}
-                    <ul>
-                        { externalRules }
-                    </ul>
-                </div>
-            );
-        }
-
-        return (
-            <div>
-
-                {masterPushRuleDiv}
-
-                <div className="mx_UserNotifSettings_notifTable">
-
-                    { spinner }
-
-                    <LabelledToggleSwitch value={SettingsStore.getValue("notificationsEnabled")}
-                        onChange={this.onEnableDesktopNotificationsChange}
-                        label={_t('Enable desktop notifications for this session')} />
-
-                    <LabelledToggleSwitch value={SettingsStore.getValue("notificationBodyEnabled")}
-                        onChange={this.onEnableDesktopNotificationBodyChange}
-                        label={_t('Show message in desktop notification')} />
-
-                    <LabelledToggleSwitch value={SettingsStore.getValue("audioNotificationsEnabled")}
-                        onChange={this.onEnableAudioNotificationsChange}
-                        label={_t('Enable audible notifications for this session')} />
-
-                    { emailNotificationsRows }
-
-                    <div className="mx_UserNotifSettings_pushRulesTableWrapper">
-                        <table className="mx_UserNotifSettings_pushRulesTable">
-                            <thead>
-                                <tr>
-                                    <th width="55%"></th>
-                                    <th width="15%">{ _t('Off') }</th>
-                                    <th width="15%">{ _t('On') }</th>
-                                    <th width="15%">{ _t('Noisy') }</th>
-                                </tr>
-                            </thead>
-                            <tbody>
-
-                                { this.renderNotifRulesTableRows() }
-
-                            </tbody>
-                        </table>
-                    </div>
-
-                    { advancedSettings }
-
-                    { devicesSection }
-
-                    { clearNotificationsButton }
-                </div>
-
-            </div>
-        );
-    }
-}
diff --git a/src/components/views/settings/Notifications.tsx b/src/components/views/settings/Notifications.tsx
new file mode 100644
index 0000000000..a488145153
--- /dev/null
+++ b/src/components/views/settings/Notifications.tsx
@@ -0,0 +1,647 @@
+/*
+Copyright 2016 - 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 Spinner from "../elements/Spinner";
+import { MatrixClientPeg } from "../../../MatrixClientPeg";
+import { IAnnotatedPushRule, IPusher, PushRuleAction, PushRuleKind, RuleId } from "matrix-js-sdk/src/@types/PushRules";
+import {
+    ContentRules,
+    IContentRules,
+    PushRuleVectorState,
+    VectorPushRulesDefinitions,
+    VectorState,
+} from "../../../notifications";
+import { _t, TranslatedString } from "../../../languageHandler";
+import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids";
+import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
+import SettingsStore from "../../../settings/SettingsStore";
+import StyledRadioButton from "../elements/StyledRadioButton";
+import { SettingLevel } from "../../../settings/SettingLevel";
+import Modal from "../../../Modal";
+import ErrorDialog from "../dialogs/ErrorDialog";
+import SdkConfig from "../../../SdkConfig";
+import AccessibleButton from "../elements/AccessibleButton";
+import TagComposer from "../elements/TagComposer";
+import { objectClone } from "../../../utils/objects";
+import { arrayDiff } from "../../../utils/arrays";
+
+// TODO: this "view" component still has far too much application logic in it,
+// which should be factored out to other files.
+
+enum Phase {
+    Loading = "loading",
+    Ready = "ready",
+    Persisting = "persisting", // technically a meta-state for Ready, but whatever
+    Error = "error",
+}
+
+enum RuleClass {
+    Master = "master",
+
+    // The vector sections map approximately to UI sections
+    VectorGlobal = "vector_global",
+    VectorMentions = "vector_mentions",
+    VectorOther = "vector_other",
+    Other = "other", // unknown rules, essentially
+}
+
+const KEYWORD_RULE_ID = "_keywords"; // used as a placeholder "Rule ID" throughout this component
+const KEYWORD_RULE_CATEGORY = RuleClass.VectorMentions;
+
+// This array doesn't care about categories: it's just used for a simple sort
+const RULE_DISPLAY_ORDER: string[] = [
+    // Global
+    RuleId.DM,
+    RuleId.EncryptedDM,
+    RuleId.Message,
+    RuleId.EncryptedMessage,
+
+    // Mentions
+    RuleId.ContainsDisplayName,
+    RuleId.ContainsUserName,
+    RuleId.AtRoomNotification,
+
+    // Other
+    RuleId.InviteToSelf,
+    RuleId.IncomingCall,
+    RuleId.SuppressNotices,
+    RuleId.Tombstone,
+];
+
+interface IVectorPushRule {
+    ruleId: RuleId | typeof KEYWORD_RULE_ID | string;
+    rule?: IAnnotatedPushRule;
+    description: TranslatedString | string;
+    vectorState: VectorState;
+}
+
+interface IProps {}
+
+interface IState {
+    phase: Phase;
+
+    // Optional stuff is required when `phase === Ready`
+    masterPushRule?: IAnnotatedPushRule;
+    vectorKeywordRuleInfo?: IContentRules;
+    vectorPushRules?: {
+        [category in RuleClass]?: IVectorPushRule[];
+    };
+    pushers?: IPusher[];
+    threepids?: IThreepid[];
+}
+
+export default class Notifications extends React.PureComponent<IProps, IState> {
+    public constructor(props: IProps) {
+        super(props);
+
+        this.state = {
+            phase: Phase.Loading,
+        };
+    }
+
+    private get isInhibited(): boolean {
+        // Caution: The master rule's enabled state is inverted from expectation. When
+        // the master rule is *enabled* it means all other rules are *disabled* (or
+        // inhibited). Conversely, when the master rule is *disabled* then all other rules
+        // are *enabled* (or operate fine).
+        return this.state.masterPushRule?.enabled;
+    }
+
+    public componentDidMount() {
+        // noinspection JSIgnoredPromiseFromCall
+        this.refreshFromServer();
+    }
+
+    private async refreshFromServer() {
+        try {
+            const newState = (await Promise.all([
+                this.refreshRules(),
+                this.refreshPushers(),
+                this.refreshThreepids(),
+            ])).reduce((p, c) => Object.assign(c, p), {});
+
+            this.setState({
+                ...newState,
+                phase: Phase.Ready,
+            });
+        } catch (e) {
+            console.error("Error setting up notifications for settings: ", e);
+            this.setState({ phase: Phase.Error });
+        }
+    }
+
+    private async refreshRules(): Promise<Partial<IState>> {
+        const ruleSets = await MatrixClientPeg.get().getPushRules();
+
+        const categories = {
+            [RuleId.Master]: RuleClass.Master,
+
+            [RuleId.DM]: RuleClass.VectorGlobal,
+            [RuleId.EncryptedDM]: RuleClass.VectorGlobal,
+            [RuleId.Message]: RuleClass.VectorGlobal,
+            [RuleId.EncryptedMessage]: RuleClass.VectorGlobal,
+
+            [RuleId.ContainsDisplayName]: RuleClass.VectorMentions,
+            [RuleId.ContainsUserName]: RuleClass.VectorMentions,
+            [RuleId.AtRoomNotification]: RuleClass.VectorMentions,
+
+            [RuleId.InviteToSelf]: RuleClass.VectorOther,
+            [RuleId.IncomingCall]: RuleClass.VectorOther,
+            [RuleId.SuppressNotices]: RuleClass.VectorOther,
+            [RuleId.Tombstone]: RuleClass.VectorOther,
+
+            // Everything maps to a generic "other" (unknown rule)
+        };
+
+        const defaultRules: {
+            [k in RuleClass]: IAnnotatedPushRule[];
+        } = {
+            [RuleClass.Master]: [],
+            [RuleClass.VectorGlobal]: [],
+            [RuleClass.VectorMentions]: [],
+            [RuleClass.VectorOther]: [],
+            [RuleClass.Other]: [],
+        };
+
+        for (const k in ruleSets.global) {
+            // noinspection JSUnfilteredForInLoop
+            const kind = k as PushRuleKind;
+            for (const r of ruleSets.global[kind]) {
+                const rule: IAnnotatedPushRule = Object.assign(r, { kind });
+                const category = categories[rule.rule_id] ?? RuleClass.Other;
+
+                if (rule.rule_id[0] === '.') {
+                    defaultRules[category].push(rule);
+                }
+            }
+        }
+
+        const preparedNewState: Partial<IState> = {};
+        if (defaultRules.master.length > 0) {
+            preparedNewState.masterPushRule = defaultRules.master[0];
+        } else {
+            // XXX: Can this even happen? How do we safely recover?
+            throw new Error("Failed to locate a master push rule");
+        }
+
+        // Parse keyword rules
+        preparedNewState.vectorKeywordRuleInfo = ContentRules.parseContentRules(ruleSets);
+
+        // Prepare rendering for all of our known rules
+        preparedNewState.vectorPushRules = {};
+        const vectorCategories = [RuleClass.VectorGlobal, RuleClass.VectorMentions, RuleClass.VectorOther];
+        for (const category of vectorCategories) {
+            preparedNewState.vectorPushRules[category] = [];
+            for (const rule of defaultRules[category]) {
+                const definition = VectorPushRulesDefinitions[rule.rule_id];
+                const vectorState = definition.ruleToVectorState(rule);
+                preparedNewState.vectorPushRules[category].push({
+                    ruleId: rule.rule_id,
+                    rule, vectorState,
+                    description: _t(definition.description),
+                });
+            }
+
+            // Quickly sort the rules for display purposes
+            preparedNewState.vectorPushRules[category].sort((a, b) => {
+                let idxA = RULE_DISPLAY_ORDER.indexOf(a.ruleId);
+                let idxB = RULE_DISPLAY_ORDER.indexOf(b.ruleId);
+
+                // Assume unknown things go at the end
+                if (idxA < 0) idxA = RULE_DISPLAY_ORDER.length;
+                if (idxB < 0) idxB = RULE_DISPLAY_ORDER.length;
+
+                return idxA - idxB;
+            });
+
+            if (category === KEYWORD_RULE_CATEGORY) {
+                preparedNewState.vectorPushRules[category].push({
+                    ruleId: KEYWORD_RULE_ID,
+                    description: _t("Messages containing keywords"),
+                    vectorState: preparedNewState.vectorKeywordRuleInfo.vectorState,
+                });
+            }
+        }
+
+        return preparedNewState;
+    }
+
+    private refreshPushers(): Promise<Partial<IState>> {
+        return MatrixClientPeg.get().getPushers();
+    }
+
+    private refreshThreepids(): Promise<Partial<IState>> {
+        return MatrixClientPeg.get().getThreePids();
+    }
+
+    private showSaveError() {
+        Modal.createTrackedDialog('Error saving notification preferences', '', ErrorDialog, {
+            title: _t('Error saving notification preferences'),
+            description: _t('An error occurred whilst saving your notification preferences.'),
+        });
+    }
+
+    private onMasterRuleChanged = async (checked: boolean) => {
+        this.setState({ phase: Phase.Persisting });
+
+        try {
+            const masterRule = this.state.masterPushRule;
+            await MatrixClientPeg.get().setPushRuleEnabled('global', masterRule.kind, masterRule.rule_id, !checked);
+            await this.refreshFromServer();
+        } catch (e) {
+            this.setState({ phase: Phase.Error });
+            console.error("Error updating master push rule:", e);
+            this.showSaveError();
+        }
+    };
+
+    private onEmailNotificationsChanged = async (email: string, checked: boolean) => {
+        this.setState({ phase: Phase.Persisting });
+
+        try {
+            if (checked) {
+                await MatrixClientPeg.get().setPusher({
+                    kind: "email",
+                    app_id: "m.email",
+                    pushkey: email,
+                    app_display_name: "Email Notifications",
+                    device_display_name: email,
+                    lang: navigator.language,
+                    data: {
+                        brand: SdkConfig.get().brand,
+                    },
+
+                    // We always append for email pushers since we don't want to stop other
+                    // accounts notifying to the same email address
+                    append: true,
+                });
+            } else {
+                const pusher = this.state.pushers.find(p => p.kind === "email" && p.pushkey === email);
+                pusher.kind = null; // flag for delete
+                await MatrixClientPeg.get().setPusher(pusher);
+            }
+
+            await this.refreshFromServer();
+        } catch (e) {
+            this.setState({ phase: Phase.Error });
+            console.error("Error updating email pusher:", e);
+            this.showSaveError();
+        }
+    };
+
+    private onDesktopNotificationsChanged = async (checked: boolean) => {
+        await SettingsStore.setValue("notificationsEnabled", null, SettingLevel.DEVICE, checked);
+        this.forceUpdate(); // the toggle is controlled by SettingsStore#getValue()
+    };
+
+    private onDesktopShowBodyChanged = async (checked: boolean) => {
+        await SettingsStore.setValue("notificationBodyEnabled", null, SettingLevel.DEVICE, checked);
+        this.forceUpdate(); // the toggle is controlled by SettingsStore#getValue()
+    };
+
+    private onAudioNotificationsChanged = async (checked: boolean) => {
+        await SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, checked);
+        this.forceUpdate(); // the toggle is controlled by SettingsStore#getValue()
+    };
+
+    private onRadioChecked = async (rule: IVectorPushRule, checkedState: VectorState) => {
+        this.setState({ phase: Phase.Persisting });
+
+        try {
+            const cli = MatrixClientPeg.get();
+            if (rule.ruleId === KEYWORD_RULE_ID) {
+                // Update all the keywords
+                for (const rule of this.state.vectorKeywordRuleInfo.rules) {
+                    let enabled: boolean;
+                    let actions: PushRuleAction[];
+                    if (checkedState === VectorState.On) {
+                        if (rule.actions.length !== 1) { // XXX: Magic number
+                            actions = PushRuleVectorState.actionsFor(checkedState);
+                        }
+                        if (this.state.vectorKeywordRuleInfo.vectorState === VectorState.Off) {
+                            enabled = true;
+                        }
+                    } else if (checkedState === VectorState.Loud) {
+                        if (rule.actions.length !== 3) { // XXX: Magic number
+                            actions = PushRuleVectorState.actionsFor(checkedState);
+                        }
+                        if (this.state.vectorKeywordRuleInfo.vectorState === VectorState.Off) {
+                            enabled = true;
+                        }
+                    } else {
+                        enabled = false;
+                    }
+
+                    if (actions) {
+                        await cli.setPushRuleActions('global', rule.kind, rule.rule_id, actions);
+                    }
+                    if (enabled !== undefined) {
+                        await cli.setPushRuleEnabled('global', rule.kind, rule.rule_id, enabled);
+                    }
+                }
+            } else {
+                const definition = VectorPushRulesDefinitions[rule.ruleId];
+                const actions = definition.vectorStateToActions[checkedState];
+                if (!actions) {
+                    await cli.setPushRuleEnabled('global', rule.rule.kind, rule.rule.rule_id, false);
+                } else {
+                    await cli.setPushRuleActions('global', rule.rule.kind, rule.rule.rule_id, actions);
+                    await cli.setPushRuleEnabled('global', rule.rule.kind, rule.rule.rule_id, true);
+                }
+            }
+
+            await this.refreshFromServer();
+        } catch (e) {
+            this.setState({ phase: Phase.Error });
+            console.error("Error updating push rule:", e);
+            this.showSaveError();
+        }
+    };
+
+    private onClearNotificationsClicked = () => {
+        MatrixClientPeg.get().getRooms().forEach(r => {
+            if (r.getUnreadNotificationCount() > 0) {
+                const events = r.getLiveTimeline().getEvents();
+                if (events.length) {
+                    // noinspection JSIgnoredPromiseFromCall
+                    MatrixClientPeg.get().sendReadReceipt(events[events.length - 1]);
+                }
+            }
+        });
+    };
+
+    private async setKeywords(keywords: string[], originalRules: IAnnotatedPushRule[]) {
+        try {
+            // De-duplicate and remove empties
+            keywords = Array.from(new Set(keywords)).filter(k => !!k);
+            const oldKeywords = Array.from(new Set(originalRules.map(r => r.pattern))).filter(k => !!k);
+
+            // Note: Technically because of the UI interaction (at the time of writing), the diff
+            // will only ever be +/-1 so we don't really have to worry about efficiently handling
+            // tons of keyword changes.
+
+            const diff = arrayDiff(oldKeywords, keywords);
+
+            for (const word of diff.removed) {
+                for (const rule of originalRules.filter(r => r.pattern === word)) {
+                    await MatrixClientPeg.get().deletePushRule('global', rule.kind, rule.rule_id);
+                }
+            }
+
+            let ruleVectorState = this.state.vectorKeywordRuleInfo.vectorState;
+            if (ruleVectorState === VectorState.Off) {
+                // When the current global keywords rule is OFF, we need to look at
+                // the flavor of existing rules to apply the same actions
+                // when creating the new rule.
+                if (originalRules.length) {
+                    ruleVectorState = PushRuleVectorState.contentRuleVectorStateKind(originalRules[0]);
+                } else {
+                    ruleVectorState = VectorState.On; // default
+                }
+            }
+            const kind = PushRuleKind.ContentSpecific;
+            for (const word of diff.added) {
+                await MatrixClientPeg.get().addPushRule('global', kind, word, {
+                    actions: PushRuleVectorState.actionsFor(ruleVectorState),
+                    pattern: word,
+                });
+                if (ruleVectorState === VectorState.Off) {
+                    await MatrixClientPeg.get().setPushRuleEnabled('global', kind, word, false);
+                }
+            }
+
+            await this.refreshFromServer();
+        } catch (e) {
+            this.setState({ phase: Phase.Error });
+            console.error("Error updating keyword push rules:", e);
+            this.showSaveError();
+        }
+    }
+
+    private onKeywordAdd = (keyword: string) => {
+        const originalRules = objectClone(this.state.vectorKeywordRuleInfo.rules);
+
+        // We add the keyword immediately as a sort of local echo effect
+        this.setState({
+            phase: Phase.Persisting,
+            vectorKeywordRuleInfo: {
+                ...this.state.vectorKeywordRuleInfo,
+                rules: [
+                    ...this.state.vectorKeywordRuleInfo.rules,
+
+                    // XXX: Horrible assumption that we don't need the remaining fields
+                    { pattern: keyword } as IAnnotatedPushRule,
+                ],
+            },
+        }, async () => {
+            await this.setKeywords(this.state.vectorKeywordRuleInfo.rules.map(r => r.pattern), originalRules);
+        });
+    };
+
+    private onKeywordRemove = (keyword: string) => {
+        const originalRules = objectClone(this.state.vectorKeywordRuleInfo.rules);
+
+        // We remove the keyword immediately as a sort of local echo effect
+        this.setState({
+            phase: Phase.Persisting,
+            vectorKeywordRuleInfo: {
+                ...this.state.vectorKeywordRuleInfo,
+                rules: this.state.vectorKeywordRuleInfo.rules.filter(r => r.pattern !== keyword),
+            },
+        }, async () => {
+            await this.setKeywords(this.state.vectorKeywordRuleInfo.rules.map(r => r.pattern), originalRules);
+        });
+    };
+
+    private renderTopSection() {
+        const masterSwitch = <LabelledToggleSwitch
+            value={!this.isInhibited}
+            label={_t("Enable for this account")}
+            onChange={this.onMasterRuleChanged}
+            disabled={this.state.phase === Phase.Persisting}
+        />;
+
+        // If all the rules are inhibited, don't show anything.
+        if (this.isInhibited) {
+            return masterSwitch;
+        }
+
+        const emailSwitches = this.state.threepids.filter(t => t.medium === ThreepidMedium.Email)
+            .map(e => <LabelledToggleSwitch
+                key={e.address}
+                value={this.state.pushers.some(p => p.kind === "email" && p.pushkey === e.address)}
+                label={_t("Enable email notifications for %(email)s", { email: e.address })}
+                onChange={this.onEmailNotificationsChanged.bind(this, e.address)}
+                disabled={this.state.phase === Phase.Persisting}
+            />);
+
+        return <>
+            { masterSwitch }
+
+            <LabelledToggleSwitch
+                value={SettingsStore.getValue("notificationsEnabled")}
+                onChange={this.onDesktopNotificationsChanged}
+                label={_t('Enable desktop notifications for this session')}
+                disabled={this.state.phase === Phase.Persisting}
+            />
+
+            <LabelledToggleSwitch
+                value={SettingsStore.getValue("notificationBodyEnabled")}
+                onChange={this.onDesktopShowBodyChanged}
+                label={_t('Show message in desktop notification')}
+                disabled={this.state.phase === Phase.Persisting}
+            />
+
+            <LabelledToggleSwitch
+                value={SettingsStore.getValue("audioNotificationsEnabled")}
+                onChange={this.onAudioNotificationsChanged}
+                label={_t('Enable audible notifications for this session')}
+                disabled={this.state.phase === Phase.Persisting}
+            />
+
+            { emailSwitches }
+        </>;
+    }
+
+    private renderCategory(category: RuleClass) {
+        if (category !== RuleClass.VectorOther && this.isInhibited) {
+            return null; // nothing to show for the section
+        }
+
+        let clearNotifsButton: JSX.Element;
+        if (
+            category === RuleClass.VectorOther
+            && MatrixClientPeg.get().getRooms().some(r => r.getUnreadNotificationCount() > 0)
+        ) {
+            clearNotifsButton = <AccessibleButton
+                onClick={this.onClearNotificationsClicked}
+                kind='danger'
+                className='mx_UserNotifSettings_clearNotifsButton'
+            >{ _t("Clear notifications") }</AccessibleButton>;
+        }
+
+        if (category === RuleClass.VectorOther && this.isInhibited) {
+            // only render the utility buttons (if needed)
+            if (clearNotifsButton) {
+                return <div className='mx_UserNotifSettings_floatingSection'>
+                    <div>{ _t("Other") }</div>
+                    { clearNotifsButton }
+                </div>;
+            }
+            return null;
+        }
+
+        let keywordComposer: JSX.Element;
+        if (category === RuleClass.VectorMentions) {
+            keywordComposer = <TagComposer
+                tags={this.state.vectorKeywordRuleInfo?.rules.map(r => r.pattern)}
+                onAdd={this.onKeywordAdd}
+                onRemove={this.onKeywordRemove}
+                disabled={this.state.phase === Phase.Persisting}
+                label={_t("Keyword")}
+                placeholder={_t("New keyword")}
+            />;
+        }
+
+        const makeRadio = (r: IVectorPushRule, s: VectorState) => (
+            <StyledRadioButton
+                key={r.ruleId}
+                name={r.ruleId}
+                checked={r.vectorState === s}
+                onChange={this.onRadioChecked.bind(this, r, s)}
+                disabled={this.state.phase === Phase.Persisting}
+            />
+        );
+
+        const rows = this.state.vectorPushRules[category].map(r => <tr key={category + r.ruleId}>
+            <td>{ r.description }</td>
+            <td>{ makeRadio(r, VectorState.On) }</td>
+            <td>{ makeRadio(r, VectorState.Off) }</td>
+            <td>{ makeRadio(r, VectorState.Loud) }</td>
+        </tr>);
+
+        let sectionName: TranslatedString;
+        switch (category) {
+            case RuleClass.VectorGlobal:
+                sectionName = _t("Global");
+                break;
+            case RuleClass.VectorMentions:
+                sectionName = _t("Mentions & keywords");
+                break;
+            case RuleClass.VectorOther:
+                sectionName = _t("Other");
+                break;
+            default:
+                throw new Error("Developer error: Unnamed notifications section: " + category);
+        }
+
+        return <>
+            <table className='mx_UserNotifSettings_pushRulesTable'>
+                <thead>
+                    <tr>
+                        <th>{ sectionName }</th>
+                        <th>{ _t("On") }</th>
+                        <th>{ _t("Off") }</th>
+                        <th>{ _t("Noisy") }</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    { rows }
+                </tbody>
+            </table>
+            { clearNotifsButton }
+            { keywordComposer }
+        </>;
+    }
+
+    private renderTargets() {
+        if (this.isInhibited) return null; // no targets if there's no notifications
+
+        const rows = this.state.pushers.map(p => <tr key={p.kind+p.pushkey}>
+            <td>{ p.app_display_name }</td>
+            <td>{ p.device_display_name }</td>
+        </tr>);
+
+        if (!rows.length) return null; // no targets to show
+
+        return <div className='mx_UserNotifSettings_floatingSection'>
+            <div>{ _t("Notification targets") }</div>
+            <table>
+                <tbody>
+                    { rows }
+                </tbody>
+            </table>
+        </div>;
+    }
+
+    public render() {
+        if (this.state.phase === Phase.Loading) {
+            // Ends up default centered
+            return <Spinner />;
+        } else if (this.state.phase === Phase.Error) {
+            return <p>{ _t("There was an error loading your notification settings.") }</p>;
+        }
+
+        return <div className='mx_UserNotifSettings'>
+            { this.renderTopSection() }
+            { this.renderCategory(RuleClass.VectorGlobal) }
+            { this.renderCategory(RuleClass.VectorMentions) }
+            { this.renderCategory(RuleClass.VectorOther) }
+            { this.renderTargets() }
+        </div>;
+    }
+}
diff --git a/src/components/views/settings/tabs/user/NotificationUserSettingsTab.js b/src/components/views/settings/tabs/user/NotificationUserSettingsTab.tsx
similarity index 86%
rename from src/components/views/settings/tabs/user/NotificationUserSettingsTab.js
rename to src/components/views/settings/tabs/user/NotificationUserSettingsTab.tsx
index 0aabdd24e2..a0f4e330bb 100644
--- a/src/components/views/settings/tabs/user/NotificationUserSettingsTab.js
+++ b/src/components/views/settings/tabs/user/NotificationUserSettingsTab.tsx
@@ -1,5 +1,5 @@
 /*
-Copyright 2019 New Vector Ltd
+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.
@@ -16,17 +16,12 @@ limitations under the License.
 
 import React from 'react';
 import { _t } from "../../../../../languageHandler";
-import * as sdk from "../../../../../index";
 import { replaceableComponent } from "../../../../../utils/replaceableComponent";
+import Notifications from "../../Notifications";
 
 @replaceableComponent("views.settings.tabs.user.NotificationUserSettingsTab")
 export default class NotificationUserSettingsTab extends React.Component {
-    constructor() {
-        super();
-    }
-
     render() {
-        const Notifications = sdk.getComponent("views.settings.Notifications");
         return (
             <div className="mx_SettingsTab mx_NotificationUserSettingsTab">
                 <div className="mx_SettingsTab_heading">{_t("Notifications")}</div>
diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts
index 3464f952a6..2a84c1f110 100644
--- a/src/contexts/RoomContext.ts
+++ b/src/contexts/RoomContext.ts
@@ -41,6 +41,7 @@ const RoomContext = createContext<IState>({
     canReply: false,
     layout: Layout.Group,
     lowBandwidth: false,
+    showHiddenEventsInTimeline: false,
     showReadReceipts: true,
     showRedactions: true,
     showJoinLeaves: true,
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 5cc900a21b..2790e17eed 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1133,33 +1133,24 @@
     "Connecting to integration manager...": "Connecting to integration manager...",
     "Cannot connect to integration manager": "Cannot connect to integration manager",
     "The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.",
-    "Error saving email notification preferences": "Error saving email notification preferences",
-    "An error occurred whilst saving your email notification preferences.": "An error occurred whilst saving your email notification preferences.",
-    "Keywords": "Keywords",
-    "Enter keywords separated by a comma:": "Enter keywords separated by a comma:",
-    "Failed to change settings": "Failed to change settings",
-    "Can't update user notification settings": "Can't update user notification settings",
-    "Failed to update keywords": "Failed to update keywords",
-    "Messages containing <span>keywords</span>": "Messages containing <span>keywords</span>",
-    "Notify for all other messages/rooms": "Notify for all other messages/rooms",
-    "Notify me for anything else": "Notify me for anything else",
-    "Enable notifications for this account": "Enable notifications for this account",
-    "Clear notifications": "Clear notifications",
-    "All notifications are currently disabled for all targets.": "All notifications are currently disabled for all targets.",
-    "Enable email notifications": "Enable email notifications",
-    "Add an email address to configure email notifications": "Add an email address to configure email notifications",
-    "Notifications on the following keywords follow rules which can’t be displayed here:": "Notifications on the following keywords follow rules which can’t be displayed here:",
-    "Unable to fetch notification target list": "Unable to fetch notification target list",
-    "Notification targets": "Notification targets",
-    "Advanced notification settings": "Advanced notification settings",
-    "There are advanced notifications which are not shown here.": "There are advanced notifications which are not shown here.",
-    "You might have configured them in a client other than %(brand)s. You cannot tune them in %(brand)s but they still apply.": "You might have configured them in a client other than %(brand)s. You cannot tune them in %(brand)s but they still apply.",
+    "Messages containing keywords": "Messages containing keywords",
+    "Error saving notification preferences": "Error saving notification preferences",
+    "An error occurred whilst saving your notification preferences.": "An error occurred whilst saving your notification preferences.",
+    "Enable for this account": "Enable for this account",
+    "Enable email notifications for %(email)s": "Enable email notifications for %(email)s",
     "Enable desktop notifications for this session": "Enable desktop notifications for this session",
     "Show message in desktop notification": "Show message in desktop notification",
     "Enable audible notifications for this session": "Enable audible notifications for this session",
-    "Off": "Off",
+    "Clear notifications": "Clear notifications",
+    "Keyword": "Keyword",
+    "New keyword": "New keyword",
+    "Global": "Global",
+    "Mentions & keywords": "Mentions & keywords",
     "On": "On",
+    "Off": "Off",
     "Noisy": "Noisy",
+    "Notification targets": "Notification targets",
+    "There was an error loading your notification settings.": "There was an error loading your notification settings.",
     "Failed to save your profile": "Failed to save your profile",
     "The operation could not be completed": "The operation could not be completed",
     "<a>Upgrade</a> to your own domain": "<a>Upgrade</a> to your own domain",
@@ -1658,7 +1649,6 @@
     "Show %(count)s more|other": "Show %(count)s more",
     "Show %(count)s more|one": "Show %(count)s more",
     "Show less": "Show less",
-    "Use default": "Use default",
     "All messages": "All messages",
     "Mentions & Keywords": "Mentions & Keywords",
     "Notification options": "Notification options",
@@ -1667,6 +1657,7 @@
     "Favourite": "Favourite",
     "Low Priority": "Low Priority",
     "Invite People": "Invite People",
+    "Copy Link": "Copy Link",
     "Leave Room": "Leave Room",
     "Room options": "Room options",
     "%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.",
@@ -2674,6 +2665,8 @@
     "Are you sure you want to leave the space '%(spaceName)s'?": "Are you sure you want to leave the space '%(spaceName)s'?",
     "Are you sure you want to leave the room '%(roomName)s'?": "Are you sure you want to leave the room '%(roomName)s'?",
     "Failed to forget room %(errCode)s": "Failed to forget room %(errCode)s",
+    "Unable to copy room link": "Unable to copy room link",
+    "Unable to copy a link to the room to the clipboard.": "Unable to copy a link to the room to the clipboard.",
     "Signed Out": "Signed Out",
     "For security, this session has been signed out. Please sign in again.": "For security, this session has been signed out. Please sign in again.",
     "Terms and Conditions": "Terms and Conditions",
diff --git a/src/indexing/EventIndex.ts b/src/indexing/EventIndex.ts
index a5827fc599..a7142010f2 100644
--- a/src/indexing/EventIndex.ts
+++ b/src/indexing/EventIndex.ts
@@ -67,7 +67,6 @@ export default class EventIndex extends EventEmitter {
 
         client.on('sync', this.onSync);
         client.on('Room.timeline', this.onRoomTimeline);
-        client.on('Event.decrypted', this.onEventDecrypted);
         client.on('Room.timelineReset', this.onTimelineReset);
         client.on('Room.redaction', this.onRedaction);
         client.on('RoomState.events', this.onRoomStateEvent);
@@ -82,7 +81,6 @@ export default class EventIndex extends EventEmitter {
 
         client.removeListener('sync', this.onSync);
         client.removeListener('Room.timeline', this.onRoomTimeline);
-        client.removeListener('Event.decrypted', this.onEventDecrypted);
         client.removeListener('Room.timelineReset', this.onTimelineReset);
         client.removeListener('Room.redaction', this.onRedaction);
         client.removeListener('RoomState.events', this.onRoomStateEvent);
@@ -221,18 +219,6 @@ export default class EventIndex extends EventEmitter {
         }
     };
 
-    /*
-     * The Event.decrypted listener.
-     *
-     * Checks if the event was marked for addition in the Room.timeline
-     * listener, if so queues it up to be added to the index.
-     */
-    private onEventDecrypted = async (ev: MatrixEvent, err: Error) => {
-        // If the event isn't in our live event set, ignore it.
-        if (err) return;
-        await this.addLiveEventToIndex(ev);
-    };
-
     /*
      * The Room.redaction listener.
      *
diff --git a/src/notifications/ContentRules.ts b/src/notifications/ContentRules.ts
index 5f1281e58c..2b45065568 100644
--- a/src/notifications/ContentRules.ts
+++ b/src/notifications/ContentRules.ts
@@ -1,6 +1,5 @@
 /*
-Copyright 2016 OpenMarket Ltd
-Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+Copyright 2016 - 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.
@@ -15,13 +14,13 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { PushRuleVectorState, State } from "./PushRuleVectorState";
-import { IExtendedPushRule, IRuleSets } from "./types";
+import { PushRuleVectorState, VectorState } from "./PushRuleVectorState";
+import { IAnnotatedPushRule, IPushRules, PushRuleKind } from "matrix-js-sdk/src/@types/PushRules";
 
 export interface IContentRules {
-    vectorState: State;
-    rules: IExtendedPushRule[];
-    externalRules: IExtendedPushRule[];
+    vectorState: VectorState;
+    rules: IAnnotatedPushRule[];
+    externalRules: IAnnotatedPushRule[];
 }
 
 export const SCOPE = "global";
@@ -39,9 +38,9 @@ export class ContentRules {
      *   externalRules: a list of other keyword rules, with states other than
      *      vectorState
      */
-    static parseContentRules(rulesets: IRuleSets): IContentRules {
+    public static parseContentRules(rulesets: IPushRules): IContentRules {
         // first categorise the keyword rules in terms of their actions
-        const contentRules = this._categoriseContentRules(rulesets);
+        const contentRules = ContentRules.categoriseContentRules(rulesets);
 
         // Decide which content rules to display in Vector UI.
         // Vector displays a single global rule for a list of keywords
@@ -59,7 +58,7 @@ export class ContentRules {
 
         if (contentRules.loud.length) {
             return {
-                vectorState: State.Loud,
+                vectorState: VectorState.Loud,
                 rules: contentRules.loud,
                 externalRules: [
                     ...contentRules.loud_but_disabled,
@@ -70,33 +69,33 @@ export class ContentRules {
             };
         } else if (contentRules.loud_but_disabled.length) {
             return {
-                vectorState: State.Off,
+                vectorState: VectorState.Off,
                 rules: contentRules.loud_but_disabled,
                 externalRules: [...contentRules.on, ...contentRules.on_but_disabled, ...contentRules.other],
             };
         } else if (contentRules.on.length) {
             return {
-                vectorState: State.On,
+                vectorState: VectorState.On,
                 rules: contentRules.on,
                 externalRules: [...contentRules.on_but_disabled, ...contentRules.other],
             };
         } else if (contentRules.on_but_disabled.length) {
             return {
-                vectorState: State.Off,
+                vectorState: VectorState.Off,
                 rules: contentRules.on_but_disabled,
                 externalRules: contentRules.other,
             };
         } else {
             return {
-                vectorState: State.On,
+                vectorState: VectorState.On,
                 rules: [],
                 externalRules: contentRules.other,
             };
         }
     }
 
-    static _categoriseContentRules(rulesets: IRuleSets) {
-        const contentRules: Record<"on"|"on_but_disabled"|"loud"|"loud_but_disabled"|"other", IExtendedPushRule[]> = {
+    private static categoriseContentRules(rulesets: IPushRules) {
+        const contentRules: Record<"on"|"on_but_disabled"|"loud"|"loud_but_disabled"|"other", IAnnotatedPushRule[]> = {
             on: [],
             on_but_disabled: [],
             loud: [],
@@ -109,7 +108,7 @@ export class ContentRules {
                 const r = rulesets.global[kind][i];
 
                 // check it's not a default rule
-                if (r.rule_id[0] === '.' || kind !== "content") {
+                if (r.rule_id[0] === '.' || kind !== PushRuleKind.ContentSpecific) {
                     continue;
                 }
 
@@ -117,14 +116,14 @@ export class ContentRules {
                 r.kind = kind;
 
                 switch (PushRuleVectorState.contentRuleVectorStateKind(r)) {
-                    case State.On:
+                    case VectorState.On:
                         if (r.enabled) {
                             contentRules.on.push(r);
                         } else {
                             contentRules.on_but_disabled.push(r);
                         }
                         break;
-                    case State.Loud:
+                    case VectorState.Loud:
                         if (r.enabled) {
                             contentRules.loud.push(r);
                         } else {
diff --git a/src/notifications/NotificationUtils.ts b/src/notifications/NotificationUtils.ts
index 1d5356e16b..3f07c56972 100644
--- a/src/notifications/NotificationUtils.ts
+++ b/src/notifications/NotificationUtils.ts
@@ -1,6 +1,5 @@
 /*
-Copyright 2016 OpenMarket Ltd
-Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+Copyright 2016 - 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.
@@ -15,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { Action, Actions } from "./types";
+import { PushRuleAction, PushRuleActionName, TweakHighlight, TweakSound } from "matrix-js-sdk/src/@types/PushRules";
 
 interface IEncodedActions {
     notify: boolean;
@@ -30,23 +29,23 @@ export class NotificationUtils {
     //   "highlight: true/false,
     // }
     // to a list of push actions.
-    static encodeActions(action: IEncodedActions) {
+    static encodeActions(action: IEncodedActions): PushRuleAction[] {
         const notify = action.notify;
         const sound = action.sound;
         const highlight = action.highlight;
         if (notify) {
-            const actions: Action[] = [Actions.Notify];
+            const actions: PushRuleAction[] = [PushRuleActionName.Notify];
             if (sound) {
-                actions.push({ "set_tweak": "sound", "value": sound });
+                actions.push({ "set_tweak": "sound", "value": sound } as TweakSound);
             }
             if (highlight) {
-                actions.push({ "set_tweak": "highlight" });
+                actions.push({ "set_tweak": "highlight" } as TweakHighlight);
             } else {
-                actions.push({ "set_tweak": "highlight", "value": false });
+                actions.push({ "set_tweak": "highlight", "value": false } as TweakHighlight);
             }
             return actions;
         } else {
-            return [Actions.DontNotify];
+            return [PushRuleActionName.DontNotify];
         }
     }
 
@@ -56,16 +55,16 @@ export class NotificationUtils {
     //   "highlight: true/false,
     // }
     // If the actions couldn't be decoded then returns null.
-    static decodeActions(actions: Action[]): IEncodedActions {
+    static decodeActions(actions: PushRuleAction[]): IEncodedActions {
         let notify = false;
         let sound = null;
         let highlight = false;
 
         for (let i = 0; i < actions.length; ++i) {
             const action = actions[i];
-            if (action === Actions.Notify) {
+            if (action === PushRuleActionName.Notify) {
                 notify = true;
-            } else if (action === Actions.DontNotify) {
+            } else if (action === PushRuleActionName.DontNotify) {
                 notify = false;
             } else if (typeof action === "object") {
                 if (action.set_tweak === "sound") {
diff --git a/src/notifications/PushRuleVectorState.ts b/src/notifications/PushRuleVectorState.ts
index 78c7e4b43b..34f7dcf786 100644
--- a/src/notifications/PushRuleVectorState.ts
+++ b/src/notifications/PushRuleVectorState.ts
@@ -1,6 +1,5 @@
 /*
-Copyright 2016 OpenMarket Ltd
-Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+Copyright 2016 - 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.
@@ -17,9 +16,9 @@ limitations under the License.
 
 import { StandardActions } from "./StandardActions";
 import { NotificationUtils } from "./NotificationUtils";
-import { IPushRule } from "./types";
+import { IPushRule } from "matrix-js-sdk/src/@types/PushRules";
 
-export enum State {
+export enum VectorState {
     /** The push rule is disabled */
     Off = "off",
     /** The user will receive push notification for this rule */
@@ -31,26 +30,26 @@ export enum State {
 
 export class PushRuleVectorState {
     // Backwards compatibility (things should probably be using the enum above instead)
-    static OFF = State.Off;
-    static ON = State.On;
-    static LOUD = State.Loud;
+    static OFF = VectorState.Off;
+    static ON = VectorState.On;
+    static LOUD = VectorState.Loud;
 
     /**
      * Enum for state of a push rule as defined by the Vector UI.
      * @readonly
      * @enum {string}
      */
-    static states = State;
+    static states = VectorState;
 
     /**
      * Convert a PushRuleVectorState to a list of actions
      *
      * @return [object] list of push-rule actions
      */
-    static actionsFor(pushRuleVectorState: State) {
-        if (pushRuleVectorState === State.On) {
+    static actionsFor(pushRuleVectorState: VectorState) {
+        if (pushRuleVectorState === VectorState.On) {
             return StandardActions.ACTION_NOTIFY;
-        } else if (pushRuleVectorState === State.Loud) {
+        } else if (pushRuleVectorState === VectorState.Loud) {
             return StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND;
         }
     }
@@ -62,7 +61,7 @@ export class PushRuleVectorState {
      * category or in PushRuleVectorState.LOUD, regardless of its enabled
      * state. Returns null if it does not match these categories.
      */
-    static contentRuleVectorStateKind(rule: IPushRule): State {
+    static contentRuleVectorStateKind(rule: IPushRule): VectorState {
         const decoded = NotificationUtils.decodeActions(rule.actions);
 
         if (!decoded) {
@@ -80,10 +79,10 @@ export class PushRuleVectorState {
         let stateKind = null;
         switch (tweaks) {
             case 0:
-                stateKind = State.On;
+                stateKind = VectorState.On;
                 break;
             case 2:
-                stateKind = State.Loud;
+                stateKind = VectorState.Loud;
                 break;
         }
         return stateKind;
diff --git a/src/notifications/VectorPushRulesDefinitions.ts b/src/notifications/VectorPushRulesDefinitions.ts
index 38dd88e6c6..a8c617e786 100644
--- a/src/notifications/VectorPushRulesDefinitions.ts
+++ b/src/notifications/VectorPushRulesDefinitions.ts
@@ -1,6 +1,5 @@
 /*
-Copyright 2016 OpenMarket Ltd
-Copyright 2019 The Matrix.org Foundation C.I.C.
+Copyright 2016 - 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.
@@ -17,19 +16,24 @@ limitations under the License.
 
 import { _td } from '../languageHandler';
 import { StandardActions } from "./StandardActions";
-import { PushRuleVectorState } from "./PushRuleVectorState";
+import { PushRuleVectorState, VectorState } from "./PushRuleVectorState";
 import { NotificationUtils } from "./NotificationUtils";
+import { PushRuleAction, PushRuleKind } from "matrix-js-sdk/src/@types/PushRules";
+
+type StateToActionsMap = {
+    [state in VectorState]?: PushRuleAction[];
+};
 
 interface IProps {
-    kind: Kind;
+    kind: PushRuleKind;
     description: string;
-    vectorStateToActions: Action;
+    vectorStateToActions: StateToActionsMap;
 }
 
 class VectorPushRuleDefinition {
-    private kind: Kind;
+    private kind: PushRuleKind;
     private description: string;
-    private vectorStateToActions: Action;
+    public readonly vectorStateToActions: StateToActionsMap;
 
     constructor(opts: IProps) {
         this.kind = opts.kind;
@@ -73,73 +77,62 @@ class VectorPushRuleDefinition {
     }
 }
 
-enum Kind {
-    Override = "override",
-    Underride = "underride",
-}
-
-interface Action {
-    on: StandardActions;
-    loud: StandardActions;
-    off: StandardActions;
-}
-
 /**
  * The descriptions of rules managed by the Vector UI.
  */
 export const VectorPushRulesDefinitions = {
     // Messages containing user's display name
     ".m.rule.contains_display_name": new VectorPushRuleDefinition({
-        kind: Kind.Override,
+        kind: PushRuleKind.Override,
         description: _td("Messages containing my display name"), // passed through _t() translation in src/components/views/settings/Notifications.js
         vectorStateToActions: { // The actions for each vector state, or null to disable the rule.
-            on: StandardActions.ACTION_NOTIFY,
-            loud: StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND,
-            off: StandardActions.ACTION_DISABLED,
+            [VectorState.On]: StandardActions.ACTION_NOTIFY,
+            [VectorState.Loud]: StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND,
+            [VectorState.Off]: StandardActions.ACTION_DISABLED,
         },
     }),
 
     // Messages containing user's username (localpart/MXID)
     ".m.rule.contains_user_name": new VectorPushRuleDefinition({
-        kind: Kind.Override,
+        kind: PushRuleKind.Override,
         description: _td("Messages containing my username"), // passed through _t() translation in src/components/views/settings/Notifications.js
         vectorStateToActions: { // The actions for each vector state, or null to disable the rule.
-            on: StandardActions.ACTION_NOTIFY,
-            loud: StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND,
-            off: StandardActions.ACTION_DISABLED,
+            [VectorState.On]: StandardActions.ACTION_NOTIFY,
+            [VectorState.Loud]: StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND,
+            [VectorState.Off]: StandardActions.ACTION_DISABLED,
         },
     }),
 
     // Messages containing @room
     ".m.rule.roomnotif": new VectorPushRuleDefinition({
-        kind: Kind.Override,
+        kind: PushRuleKind.Override,
         description: _td("Messages containing @room"), // passed through _t() translation in src/components/views/settings/Notifications.js
         vectorStateToActions: { // The actions for each vector state, or null to disable the rule.
-            on: StandardActions.ACTION_NOTIFY,
-            loud: StandardActions.ACTION_HIGHLIGHT,
-            off: StandardActions.ACTION_DISABLED,
+            [VectorState.On]: StandardActions.ACTION_NOTIFY,
+            [VectorState.Loud]: StandardActions.ACTION_HIGHLIGHT,
+            [VectorState.Off]: StandardActions.ACTION_DISABLED,
         },
     }),
 
     // Messages just sent to the user in a 1:1 room
     ".m.rule.room_one_to_one": new VectorPushRuleDefinition({
-        kind: Kind.Underride,
+        kind: PushRuleKind.Underride,
         description: _td("Messages in one-to-one chats"), // passed through _t() translation in src/components/views/settings/Notifications.js
         vectorStateToActions: {
-            on: StandardActions.ACTION_NOTIFY,
-            loud: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
-            off: StandardActions.ACTION_DONT_NOTIFY,
+            [VectorState.On]: StandardActions.ACTION_NOTIFY,
+            [VectorState.Loud]: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
+            [VectorState.Off]: StandardActions.ACTION_DONT_NOTIFY,
         },
     }),
 
     // Encrypted messages just sent to the user in a 1:1 room
     ".m.rule.encrypted_room_one_to_one": new VectorPushRuleDefinition({
-        kind: Kind.Underride,
+        kind: PushRuleKind.Underride,
         description: _td("Encrypted messages in one-to-one chats"), // passed through _t() translation in src/components/views/settings/Notifications.js
         vectorStateToActions: {
-            on: StandardActions.ACTION_NOTIFY,
-            loud: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
-            off: StandardActions.ACTION_DONT_NOTIFY,
+            [VectorState.On]: StandardActions.ACTION_NOTIFY,
+            [VectorState.Loud]: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
+            [VectorState.Off]: StandardActions.ACTION_DONT_NOTIFY,
         },
     }),
 
@@ -147,12 +140,12 @@ export const VectorPushRulesDefinitions = {
     // 1:1 room messages are catched by the .m.rule.room_one_to_one rule if any defined
     // By opposition, all other room messages are from group chat rooms.
     ".m.rule.message": new VectorPushRuleDefinition({
-        kind: Kind.Underride,
+        kind: PushRuleKind.Underride,
         description: _td("Messages in group chats"), // passed through _t() translation in src/components/views/settings/Notifications.js
         vectorStateToActions: {
-            on: StandardActions.ACTION_NOTIFY,
-            loud: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
-            off: StandardActions.ACTION_DONT_NOTIFY,
+            [VectorState.On]: StandardActions.ACTION_NOTIFY,
+            [VectorState.Loud]: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
+            [VectorState.Off]: StandardActions.ACTION_DONT_NOTIFY,
         },
     }),
 
@@ -160,57 +153,57 @@ export const VectorPushRulesDefinitions = {
     // Encrypted 1:1 room messages are catched by the .m.rule.encrypted_room_one_to_one rule if any defined
     // By opposition, all other room messages are from group chat rooms.
     ".m.rule.encrypted": new VectorPushRuleDefinition({
-        kind: Kind.Underride,
+        kind: PushRuleKind.Underride,
         description: _td("Encrypted messages in group chats"), // passed through _t() translation in src/components/views/settings/Notifications.js
         vectorStateToActions: {
-            on: StandardActions.ACTION_NOTIFY,
-            loud: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
-            off: StandardActions.ACTION_DONT_NOTIFY,
+            [VectorState.On]: StandardActions.ACTION_NOTIFY,
+            [VectorState.Loud]: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
+            [VectorState.Off]: StandardActions.ACTION_DONT_NOTIFY,
         },
     }),
 
     // Invitation for the user
     ".m.rule.invite_for_me": new VectorPushRuleDefinition({
-        kind: Kind.Underride,
+        kind: PushRuleKind.Underride,
         description: _td("When I'm invited to a room"), // passed through _t() translation in src/components/views/settings/Notifications.js
         vectorStateToActions: {
-            on: StandardActions.ACTION_NOTIFY,
-            loud: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
-            off: StandardActions.ACTION_DISABLED,
+            [VectorState.On]: StandardActions.ACTION_NOTIFY,
+            [VectorState.Loud]: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
+            [VectorState.Off]: StandardActions.ACTION_DISABLED,
         },
     }),
 
     // Incoming call
     ".m.rule.call": new VectorPushRuleDefinition({
-        kind: Kind.Underride,
+        kind: PushRuleKind.Underride,
         description: _td("Call invitation"), // passed through _t() translation in src/components/views/settings/Notifications.js
         vectorStateToActions: {
-            on: StandardActions.ACTION_NOTIFY,
-            loud: StandardActions.ACTION_NOTIFY_RING_SOUND,
-            off: StandardActions.ACTION_DISABLED,
+            [VectorState.On]: StandardActions.ACTION_NOTIFY,
+            [VectorState.Loud]: StandardActions.ACTION_NOTIFY_RING_SOUND,
+            [VectorState.Off]: StandardActions.ACTION_DISABLED,
         },
     }),
 
     // Notifications from bots
     ".m.rule.suppress_notices": new VectorPushRuleDefinition({
-        kind: Kind.Override,
+        kind: PushRuleKind.Override,
         description: _td("Messages sent by bot"), // passed through _t() translation in src/components/views/settings/Notifications.js
         vectorStateToActions: {
             // .m.rule.suppress_notices is a "negative" rule, we have to invert its enabled value for vector UI
-            on: StandardActions.ACTION_DISABLED,
-            loud: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
-            off: StandardActions.ACTION_DONT_NOTIFY,
+            [VectorState.On]: StandardActions.ACTION_DISABLED,
+            [VectorState.Loud]: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
+            [VectorState.Off]: StandardActions.ACTION_DONT_NOTIFY,
         },
     }),
 
     // Room upgrades (tombstones)
     ".m.rule.tombstone": new VectorPushRuleDefinition({
-        kind: Kind.Override,
+        kind: PushRuleKind.Override,
         description: _td("When rooms are upgraded"), // passed through _t() translation in src/components/views/settings/Notifications.js
         vectorStateToActions: { // The actions for each vector state, or null to disable the rule.
-            on: StandardActions.ACTION_NOTIFY,
-            loud: StandardActions.ACTION_HIGHLIGHT,
-            off: StandardActions.ACTION_DISABLED,
+            [VectorState.On]: StandardActions.ACTION_NOTIFY,
+            [VectorState.Loud]: StandardActions.ACTION_HIGHLIGHT,
+            [VectorState.Off]: StandardActions.ACTION_DISABLED,
         },
     }),
 };
diff --git a/src/notifications/types.ts b/src/notifications/types.ts
deleted file mode 100644
index ea46552947..0000000000
--- a/src/notifications/types.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-/*
-Copyright 2020 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-export enum NotificationSetting {
-    AllMessages = "all_messages", // .m.rule.message = notify
-    DirectMessagesMentionsKeywords = "dm_mentions_keywords", // .m.rule.message = mark_unread. This is the new default.
-    MentionsKeywordsOnly = "mentions_keywords", // .m.rule.message = mark_unread; .m.rule.room_one_to_one = mark_unread
-    Never = "never", // .m.rule.master = enabled (dont_notify)
-}
-
-export interface ISoundTweak {
-    // eslint-disable-next-line camelcase
-    set_tweak: "sound";
-    value: string;
-}
-export interface IHighlightTweak {
-    // eslint-disable-next-line camelcase
-    set_tweak: "highlight";
-    value?: boolean;
-}
-
-export type Tweak = ISoundTweak | IHighlightTweak;
-
-export enum Actions {
-    Notify = "notify",
-    DontNotify = "dont_notify", // no-op
-    Coalesce = "coalesce", // unused
-    MarkUnread = "mark_unread", // new
-}
-
-export type Action = Actions | Tweak;
-
-// Push rule kinds in descending priority order
-export enum Kind {
-    Override = "override",
-    ContentSpecific = "content",
-    RoomSpecific = "room",
-    SenderSpecific = "sender",
-    Underride = "underride",
-}
-
-export interface IEventMatchCondition {
-    kind: "event_match";
-    key: string;
-    pattern: string;
-}
-
-export interface IContainsDisplayNameCondition {
-    kind: "contains_display_name";
-}
-
-export interface IRoomMemberCountCondition {
-    kind: "room_member_count";
-    is: string;
-}
-
-export interface ISenderNotificationPermissionCondition {
-    kind: "sender_notification_permission";
-    key: string;
-}
-
-export type Condition =
-    IEventMatchCondition |
-    IContainsDisplayNameCondition |
-    IRoomMemberCountCondition |
-    ISenderNotificationPermissionCondition;
-
-export enum RuleIds {
-    MasterRule = ".m.rule.master", // The master rule (all notifications disabling)
-    MessageRule = ".m.rule.message",
-    EncryptedMessageRule = ".m.rule.encrypted",
-    RoomOneToOneRule = ".m.rule.room_one_to_one",
-    EncryptedRoomOneToOneRule = ".m.rule.room_one_to_one",
-}
-
-export interface IPushRule {
-    enabled: boolean;
-    // eslint-disable-next-line camelcase
-    rule_id: RuleIds | string;
-    actions: Action[];
-    default: boolean;
-    conditions?: Condition[]; // only applicable to `underride` and `override` rules
-    pattern?: string; // only applicable to `content` rules
-}
-
-// push rule extended with kind, used by ContentRules and js-sdk's pushprocessor
-export interface IExtendedPushRule extends IPushRule {
-    kind: Kind;
-}
-
-export interface IPushRuleSet {
-    override: IPushRule[];
-    content: IPushRule[];
-    room: IPushRule[];
-    sender: IPushRule[];
-    underride: IPushRule[];
-}
-
-export interface IRuleSets {
-    global: IPushRuleSet;
-}
diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts
index bedbfebd7f..3913a2220f 100644
--- a/src/stores/room-list/RoomListStore.ts
+++ b/src/stores/room-list/RoomListStore.ts
@@ -132,8 +132,8 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
         // Update any settings here, as some may have happened before we were logically ready.
         console.log("Regenerating room lists: Startup");
         await this.readAndCacheSettingsFromStore();
-        await this.regenerateAllLists({ trigger: false });
-        await this.handleRVSUpdate({ trigger: false }); // fake an RVS update to adjust sticky room, if needed
+        this.regenerateAllLists({ trigger: false });
+        this.handleRVSUpdate({ trigger: false }); // fake an RVS update to adjust sticky room, if needed
 
         this.updateFn.mark(); // we almost certainly want to trigger an update.
         this.updateFn.trigger();
@@ -150,7 +150,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
         await this.updateState({
             tagsEnabled,
         });
-        await this.updateAlgorithmInstances();
+        this.updateAlgorithmInstances();
     }
 
     /**
@@ -158,23 +158,23 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
      * @param trigger Set to false to prevent a list update from being sent. Should only
      * be used if the calling code will manually trigger the update.
      */
-    private async handleRVSUpdate({ trigger = true }) {
+    private handleRVSUpdate({ trigger = true }) {
         if (!this.matrixClient) return; // We assume there won't be RVS updates without a client
 
         const activeRoomId = RoomViewStore.getRoomId();
         if (!activeRoomId && this.algorithm.stickyRoom) {
-            await this.algorithm.setStickyRoom(null);
+            this.algorithm.setStickyRoom(null);
         } else if (activeRoomId) {
             const activeRoom = this.matrixClient.getRoom(activeRoomId);
             if (!activeRoom) {
                 console.warn(`${activeRoomId} is current in RVS but missing from client - clearing sticky room`);
-                await this.algorithm.setStickyRoom(null);
+                this.algorithm.setStickyRoom(null);
             } else if (activeRoom !== this.algorithm.stickyRoom) {
                 if (SettingsStore.getValue("advancedRoomListLogging")) {
                     // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
                     console.log(`Changing sticky room to ${activeRoomId}`);
                 }
-                await this.algorithm.setStickyRoom(activeRoom);
+                this.algorithm.setStickyRoom(activeRoom);
             }
         }
 
@@ -226,7 +226,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
                 console.log("Regenerating room lists: Settings changed");
                 await this.readAndCacheSettingsFromStore();
 
-                await this.regenerateAllLists({ trigger: false }); // regenerate the lists now
+                this.regenerateAllLists({ trigger: false }); // regenerate the lists now
                 this.updateFn.trigger();
             }
         }
@@ -368,7 +368,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
                                 // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
                                 console.log(`[RoomListDebug] Clearing sticky room due to room upgrade`);
                             }
-                            await this.algorithm.setStickyRoom(null);
+                            this.algorithm.setStickyRoom(null);
                         }
 
                         // Note: we hit the algorithm instead of our handleRoomUpdate() function to
@@ -377,7 +377,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
                             // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
                             console.log(`[RoomListDebug] Removing previous room from room list`);
                         }
-                        await this.algorithm.handleRoomUpdate(prevRoom, RoomUpdateCause.RoomRemoved);
+                        this.algorithm.handleRoomUpdate(prevRoom, RoomUpdateCause.RoomRemoved);
                     }
                 }
 
@@ -433,7 +433,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
             return; // don't do anything on new/moved rooms which ought not to be shown
         }
 
-        const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause);
+        const shouldUpdate = this.algorithm.handleRoomUpdate(room, cause);
         if (shouldUpdate) {
             if (SettingsStore.getValue("advancedRoomListLogging")) {
                 // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
@@ -462,13 +462,13 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
 
         // Reset the sticky room before resetting the known rooms so the algorithm
         // doesn't freak out.
-        await this.algorithm.setStickyRoom(null);
-        await this.algorithm.setKnownRooms(rooms);
+        this.algorithm.setStickyRoom(null);
+        this.algorithm.setKnownRooms(rooms);
 
         // Set the sticky room back, if needed, now that we have updated the store.
         // This will use relative stickyness to the new room set.
         if (stickyIsStillPresent) {
-            await this.algorithm.setStickyRoom(currentSticky);
+            this.algorithm.setStickyRoom(currentSticky);
         }
 
         // Finally, mark an update and resume updates from the algorithm
@@ -477,12 +477,12 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
     }
 
     public async setTagSorting(tagId: TagID, sort: SortAlgorithm) {
-        await this.setAndPersistTagSorting(tagId, sort);
+        this.setAndPersistTagSorting(tagId, sort);
         this.updateFn.trigger();
     }
 
-    private async setAndPersistTagSorting(tagId: TagID, sort: SortAlgorithm) {
-        await this.algorithm.setTagSorting(tagId, sort);
+    private setAndPersistTagSorting(tagId: TagID, sort: SortAlgorithm) {
+        this.algorithm.setTagSorting(tagId, sort);
         // TODO: Per-account? https://github.com/vector-im/element-web/issues/14114
         localStorage.setItem(`mx_tagSort_${tagId}`, sort);
     }
@@ -520,13 +520,13 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
         return tagSort;
     }
 
-    public async setListOrder(tagId: TagID, order: ListAlgorithm) {
-        await this.setAndPersistListOrder(tagId, order);
+    public setListOrder(tagId: TagID, order: ListAlgorithm) {
+        this.setAndPersistListOrder(tagId, order);
         this.updateFn.trigger();
     }
 
-    private async setAndPersistListOrder(tagId: TagID, order: ListAlgorithm) {
-        await this.algorithm.setListOrdering(tagId, order);
+    private setAndPersistListOrder(tagId: TagID, order: ListAlgorithm) {
+        this.algorithm.setListOrdering(tagId, order);
         // TODO: Per-account? https://github.com/vector-im/element-web/issues/14114
         localStorage.setItem(`mx_listOrder_${tagId}`, order);
     }
@@ -563,7 +563,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
         return listOrder;
     }
 
-    private async updateAlgorithmInstances() {
+    private updateAlgorithmInstances() {
         // We'll require an update, so mark for one. Marking now also prevents the calls
         // to setTagSorting and setListOrder from causing triggers.
         this.updateFn.mark();
@@ -576,10 +576,10 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
             const listOrder = this.calculateListOrder(tag);
 
             if (tagSort !== definedSort) {
-                await this.setAndPersistTagSorting(tag, tagSort);
+                this.setAndPersistTagSorting(tag, tagSort);
             }
             if (listOrder !== definedOrder) {
-                await this.setAndPersistListOrder(tag, listOrder);
+                this.setAndPersistListOrder(tag, listOrder);
             }
         }
     }
@@ -632,7 +632,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
      * @param trigger Set to false to prevent a list update from being sent. Should only
      * be used if the calling code will manually trigger the update.
      */
-    public async regenerateAllLists({ trigger = true }) {
+    public regenerateAllLists({ trigger = true }) {
         console.warn("Regenerating all room lists");
 
         const rooms = this.getPlausibleRooms();
@@ -656,8 +656,8 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
             RoomListLayoutStore.instance.ensureLayoutExists(tagId);
         }
 
-        await this.algorithm.populateTags(sorts, orders);
-        await this.algorithm.setKnownRooms(rooms);
+        this.algorithm.populateTags(sorts, orders);
+        this.algorithm.setKnownRooms(rooms);
 
         this.initialListsGenerated = true;
 
diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts
index f50d112248..8574f095d6 100644
--- a/src/stores/room-list/algorithms/Algorithm.ts
+++ b/src/stores/room-list/algorithms/Algorithm.ts
@@ -16,8 +16,9 @@ limitations under the License.
 
 import { Room } from "matrix-js-sdk/src/models/room";
 import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
-import DMRoomMap from "../../../utils/DMRoomMap";
 import { EventEmitter } from "events";
+
+import DMRoomMap from "../../../utils/DMRoomMap";
 import { arrayDiff, arrayHasDiff } from "../../../utils/arrays";
 import { DefaultTagID, RoomUpdateCause, TagID } from "../models";
 import {
@@ -122,8 +123,8 @@ export class Algorithm extends EventEmitter {
      * Awaitable version of the sticky room setter.
      * @param val The new room to sticky.
      */
-    public async setStickyRoom(val: Room) {
-        await this.updateStickyRoom(val);
+    public setStickyRoom(val: Room) {
+        this.updateStickyRoom(val);
     }
 
     public getTagSorting(tagId: TagID): SortAlgorithm {
@@ -131,13 +132,13 @@ export class Algorithm extends EventEmitter {
         return this.sortAlgorithms[tagId];
     }
 
-    public async setTagSorting(tagId: TagID, sort: SortAlgorithm) {
+    public setTagSorting(tagId: TagID, sort: SortAlgorithm) {
         if (!tagId) throw new Error("Tag ID must be defined");
         if (!sort) throw new Error("Algorithm must be defined");
         this.sortAlgorithms[tagId] = sort;
 
         const algorithm: OrderingAlgorithm = this.algorithms[tagId];
-        await algorithm.setSortAlgorithm(sort);
+        algorithm.setSortAlgorithm(sort);
         this._cachedRooms[tagId] = algorithm.orderedRooms;
         this.recalculateFilteredRoomsForTag(tagId); // update filter to re-sort the list
         this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed
@@ -148,7 +149,7 @@ export class Algorithm extends EventEmitter {
         return this.listAlgorithms[tagId];
     }
 
-    public async setListOrdering(tagId: TagID, order: ListAlgorithm) {
+    public setListOrdering(tagId: TagID, order: ListAlgorithm) {
         if (!tagId) throw new Error("Tag ID must be defined");
         if (!order) throw new Error("Algorithm must be defined");
         this.listAlgorithms[tagId] = order;
@@ -156,7 +157,7 @@ export class Algorithm extends EventEmitter {
         const algorithm = getListAlgorithmInstance(order, tagId, this.sortAlgorithms[tagId]);
         this.algorithms[tagId] = algorithm;
 
-        await algorithm.setRooms(this._cachedRooms[tagId]);
+        algorithm.setRooms(this._cachedRooms[tagId]);
         this._cachedRooms[tagId] = algorithm.orderedRooms;
         this.recalculateFilteredRoomsForTag(tagId); // update filter to re-sort the list
         this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed
@@ -183,31 +184,25 @@ export class Algorithm extends EventEmitter {
         }
     }
 
-    private async handleFilterChange() {
-        await this.recalculateFilteredRooms();
+    private handleFilterChange() {
+        this.recalculateFilteredRooms();
 
         // re-emit the update so the list store can fire an off-cycle update if needed
         if (this.updatesInhibited) return;
         this.emit(FILTER_CHANGED);
     }
 
-    private async updateStickyRoom(val: Room) {
-        try {
-            return await this.doUpdateStickyRoom(val);
-        } finally {
-            this._lastStickyRoom = null; // clear to indicate we're done changing
-        }
+    private updateStickyRoom(val: Room) {
+        this.doUpdateStickyRoom(val);
+        this._lastStickyRoom = null; // clear to indicate we're done changing
     }
 
-    private async doUpdateStickyRoom(val: Room) {
+    private doUpdateStickyRoom(val: Room) {
         if (SpaceStore.spacesEnabled && val?.isSpaceRoom() && val.getMyMembership() !== "invite") {
             // no-op sticky rooms for spaces - they're effectively virtual rooms
             val = null;
         }
 
-        // Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing,
-        // otherwise we risk duplicating rooms.
-
         if (val && !VisibilityProvider.instance.isRoomVisible(val)) {
             val = null; // the room isn't visible - lie to the rest of this function
         }
@@ -223,7 +218,7 @@ export class Algorithm extends EventEmitter {
                 this._stickyRoom = null; // clear before we go to update the algorithm
 
                 // Lie to the algorithm and re-add the room to the algorithm
-                await this.handleRoomUpdate(stickyRoom, RoomUpdateCause.NewRoom);
+                this.handleRoomUpdate(stickyRoom, RoomUpdateCause.NewRoom);
                 return;
             }
             return;
@@ -269,10 +264,10 @@ export class Algorithm extends EventEmitter {
         // referential checks as the references can differ through the lifecycle.
         if (lastStickyRoom && lastStickyRoom.room && lastStickyRoom.room.roomId !== val.roomId) {
             // Lie to the algorithm and re-add the room to the algorithm
-            await this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom);
+            this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom);
         }
         // Lie to the algorithm and remove the room from it's field of view
-        await this.handleRoomUpdate(val, RoomUpdateCause.RoomRemoved);
+        this.handleRoomUpdate(val, RoomUpdateCause.RoomRemoved);
 
         // Check for tag & position changes while we're here. We also check the room to ensure
         // it is still the same room.
@@ -462,9 +457,8 @@ export class Algorithm extends EventEmitter {
      * them.
      * @param {ITagSortingMap} tagSortingMap The tags to generate.
      * @param {IListOrderingMap} listOrderingMap The ordering of those tags.
-     * @returns {Promise<*>} A promise which resolves when complete.
      */
-    public async populateTags(tagSortingMap: ITagSortingMap, listOrderingMap: IListOrderingMap): Promise<any> {
+    public populateTags(tagSortingMap: ITagSortingMap, listOrderingMap: IListOrderingMap): void {
         if (!tagSortingMap) throw new Error(`Sorting map cannot be null or empty`);
         if (!listOrderingMap) throw new Error(`Ordering ma cannot be null or empty`);
         if (arrayHasDiff(Object.keys(tagSortingMap), Object.keys(listOrderingMap))) {
@@ -513,9 +507,8 @@ export class Algorithm extends EventEmitter {
      * Seeds the Algorithm with a set of rooms. The algorithm will discard all
      * previously known information and instead use these rooms instead.
      * @param {Room[]} rooms The rooms to force the algorithm to use.
-     * @returns {Promise<*>} A promise which resolves when complete.
      */
-    public async setKnownRooms(rooms: Room[]): Promise<any> {
+    public setKnownRooms(rooms: Room[]): void {
         if (isNullOrUndefined(rooms)) throw new Error(`Array of rooms cannot be null`);
         if (!this.sortAlgorithms) throw new Error(`Cannot set known rooms without a tag sorting map`);
 
@@ -529,7 +522,7 @@ export class Algorithm extends EventEmitter {
         // Before we go any further we need to clear (but remember) the sticky room to
         // avoid accidentally duplicating it in the list.
         const oldStickyRoom = this._stickyRoom;
-        await this.updateStickyRoom(null);
+        if (oldStickyRoom) this.updateStickyRoom(null);
 
         this.rooms = rooms;
 
@@ -541,7 +534,7 @@ export class Algorithm extends EventEmitter {
 
         // If we can avoid doing work, do so.
         if (!rooms.length) {
-            await this.generateFreshTags(newTags); // just in case it wants to do something
+            this.generateFreshTags(newTags); // just in case it wants to do something
             this.cachedRooms = newTags;
             return;
         }
@@ -578,7 +571,7 @@ export class Algorithm extends EventEmitter {
             }
         }
 
-        await this.generateFreshTags(newTags);
+        this.generateFreshTags(newTags);
 
         this.cachedRooms = newTags; // this recalculates the filtered rooms for us
         this.updateTagsFromCache();
@@ -587,7 +580,7 @@ export class Algorithm extends EventEmitter {
         // it was. It's entirely possible that it changed lists though, so if it did then
         // we also have to update the position of it.
         if (oldStickyRoom && oldStickyRoom.room) {
-            await this.updateStickyRoom(oldStickyRoom.room);
+            this.updateStickyRoom(oldStickyRoom.room);
             if (this._stickyRoom && this._stickyRoom.room) { // just in case the update doesn't go according to plan
                 if (this._stickyRoom.tag !== oldStickyRoom.tag) {
                     // We put the sticky room at the top of the list to treat it as an obvious tag change.
@@ -652,16 +645,15 @@ export class Algorithm extends EventEmitter {
      * @param {ITagMap} updatedTagMap The tag map which needs populating. Each tag
      * will already have the rooms which belong to it - they just need ordering. Must
      * be mutated in place.
-     * @returns {Promise<*>} A promise which resolves when complete.
      */
-    private async generateFreshTags(updatedTagMap: ITagMap): Promise<any> {
+    private generateFreshTags(updatedTagMap: ITagMap): void {
         if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
 
         for (const tag of Object.keys(updatedTagMap)) {
             const algorithm: OrderingAlgorithm = this.algorithms[tag];
             if (!algorithm) throw new Error(`No algorithm for ${tag}`);
 
-            await algorithm.setRooms(updatedTagMap[tag]);
+            algorithm.setRooms(updatedTagMap[tag]);
             updatedTagMap[tag] = algorithm.orderedRooms;
         }
     }
@@ -673,11 +665,10 @@ export class Algorithm extends EventEmitter {
      * may no-op this request if no changes are required.
      * @param {Room} room The room which might have affected sorting.
      * @param {RoomUpdateCause} cause The reason for the update being triggered.
-     * @returns {Promise<boolean>} A promise which resolve to true or false
-     * depending on whether or not getOrderedRooms() should be called after
-     * processing.
+     * @returns {Promise<boolean>} A boolean of whether or not getOrderedRooms()
+     * should be called after processing.
      */
-    public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
+    public handleRoomUpdate(room: Room, cause: RoomUpdateCause): boolean {
         if (SettingsStore.getValue("advancedRoomListLogging")) {
             // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
             console.log(`Handle room update for ${room.roomId} called with cause ${cause}`);
@@ -685,9 +676,9 @@ export class Algorithm extends EventEmitter {
         if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
 
         // Note: check the isSticky against the room ID just in case the reference is wrong
-        const isSticky = this._stickyRoom && this._stickyRoom.room && this._stickyRoom.room.roomId === room.roomId;
+        const isSticky = this._stickyRoom?.room?.roomId === room.roomId;
         if (cause === RoomUpdateCause.NewRoom) {
-            const isForLastSticky = this._lastStickyRoom && this._lastStickyRoom.room === room;
+            const isForLastSticky = this._lastStickyRoom?.room === room;
             const roomTags = this.roomIdsToTags[room.roomId];
             const hasTags = roomTags && roomTags.length > 0;
 
@@ -744,7 +735,7 @@ export class Algorithm extends EventEmitter {
                     }
                     const algorithm: OrderingAlgorithm = this.algorithms[rmTag];
                     if (!algorithm) throw new Error(`No algorithm for ${rmTag}`);
-                    await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
+                    algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
                     this._cachedRooms[rmTag] = algorithm.orderedRooms;
                     this.recalculateFilteredRoomsForTag(rmTag); // update filter to re-sort the list
                     this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed
@@ -756,7 +747,7 @@ export class Algorithm extends EventEmitter {
                     }
                     const algorithm: OrderingAlgorithm = this.algorithms[addTag];
                     if (!algorithm) throw new Error(`No algorithm for ${addTag}`);
-                    await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom);
+                    algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom);
                     this._cachedRooms[addTag] = algorithm.orderedRooms;
                 }
 
@@ -789,7 +780,7 @@ export class Algorithm extends EventEmitter {
                     };
                 } else {
                     // We have to clear the lock as the sticky room change will trigger updates.
-                    await this.setStickyRoom(room);
+                    this.setStickyRoom(room);
                 }
             }
         }
@@ -852,7 +843,7 @@ export class Algorithm extends EventEmitter {
             const algorithm: OrderingAlgorithm = this.algorithms[tag];
             if (!algorithm) throw new Error(`No algorithm for ${tag}`);
 
-            await algorithm.handleRoomUpdate(room, cause);
+            algorithm.handleRoomUpdate(room, cause);
             this._cachedRooms[tag] = algorithm.orderedRooms;
 
             // Flag that we've done something
diff --git a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts
index 80bdf74afb..1d35df331d 100644
--- a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts
+++ b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts
@@ -94,15 +94,15 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
         return state.color;
     }
 
-    public async setRooms(rooms: Room[]): Promise<any> {
+    public setRooms(rooms: Room[]): void {
         if (this.sortingAlgorithm === SortAlgorithm.Manual) {
-            this.cachedOrderedRooms = await sortRoomsWithAlgorithm(rooms, this.tagId, this.sortingAlgorithm);
+            this.cachedOrderedRooms = sortRoomsWithAlgorithm(rooms, this.tagId, this.sortingAlgorithm);
         } else {
             // Every other sorting type affects the categories, not the whole tag.
             const categorized = this.categorizeRooms(rooms);
             for (const category of Object.keys(categorized)) {
                 const roomsToOrder = categorized[category];
-                categorized[category] = await sortRoomsWithAlgorithm(roomsToOrder, this.tagId, this.sortingAlgorithm);
+                categorized[category] = sortRoomsWithAlgorithm(roomsToOrder, this.tagId, this.sortingAlgorithm);
             }
 
             const newlyOrganized: Room[] = [];
@@ -118,12 +118,12 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
         }
     }
 
-    private async handleSplice(room: Room, cause: RoomUpdateCause): Promise<boolean> {
+    private handleSplice(room: Room, cause: RoomUpdateCause): boolean {
         if (cause === RoomUpdateCause.NewRoom) {
             const category = this.getRoomCategory(room);
             this.alterCategoryPositionBy(category, 1, this.indices);
             this.cachedOrderedRooms.splice(this.indices[category], 0, room); // splice in the new room (pre-adjusted)
-            await this.sortCategory(category);
+            this.sortCategory(category);
         } else if (cause === RoomUpdateCause.RoomRemoved) {
             const roomIdx = this.getRoomIndex(room);
             if (roomIdx === -1) {
@@ -141,55 +141,49 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
         return true;
     }
 
-    public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
-        try {
-            await this.updateLock.acquireAsync();
-
-            if (cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved) {
-                return this.handleSplice(room, cause);
-            }
-
-            if (cause !== RoomUpdateCause.Timeline && cause !== RoomUpdateCause.ReadReceipt) {
-                throw new Error(`Unsupported update cause: ${cause}`);
-            }
-
-            const category = this.getRoomCategory(room);
-            if (this.sortingAlgorithm === SortAlgorithm.Manual) {
-                return; // Nothing to do here.
-            }
-
-            const roomIdx = this.getRoomIndex(room);
-            if (roomIdx === -1) {
-                throw new Error(`Room ${room.roomId} has no index in ${this.tagId}`);
-            }
-
-            // Try to avoid doing array operations if we don't have to: only move rooms within
-            // the categories if we're jumping categories
-            const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices);
-            if (oldCategory !== category) {
-                // Move the room and update the indices
-                this.moveRoomIndexes(1, oldCategory, category, this.indices);
-                this.cachedOrderedRooms.splice(roomIdx, 1); // splice out the old index (fixed position)
-                this.cachedOrderedRooms.splice(this.indices[category], 0, room); // splice in the new room (pre-adjusted)
-                // Note: if moveRoomIndexes() is called after the splice then the insert operation
-                // will happen in the wrong place. Because we would have already adjusted the index
-                // for the category, we don't need to determine how the room is moving in the list.
-                // If we instead tried to insert before updating the indices, we'd have to determine
-                // whether the room was moving later (towards IDLE) or earlier (towards RED) from its
-                // current position, as it'll affect the category's start index after we remove the
-                // room from the array.
-            }
-
-            // Sort the category now that we've dumped the room in
-            await this.sortCategory(category);
-
-            return true; // change made
-        } finally {
-            await this.updateLock.release();
+    public handleRoomUpdate(room: Room, cause: RoomUpdateCause): boolean {
+        if (cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved) {
+            return this.handleSplice(room, cause);
         }
+
+        if (cause !== RoomUpdateCause.Timeline && cause !== RoomUpdateCause.ReadReceipt) {
+            throw new Error(`Unsupported update cause: ${cause}`);
+        }
+
+        const category = this.getRoomCategory(room);
+        if (this.sortingAlgorithm === SortAlgorithm.Manual) {
+            return; // Nothing to do here.
+        }
+
+        const roomIdx = this.getRoomIndex(room);
+        if (roomIdx === -1) {
+            throw new Error(`Room ${room.roomId} has no index in ${this.tagId}`);
+        }
+
+        // Try to avoid doing array operations if we don't have to: only move rooms within
+        // the categories if we're jumping categories
+        const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices);
+        if (oldCategory !== category) {
+            // Move the room and update the indices
+            this.moveRoomIndexes(1, oldCategory, category, this.indices);
+            this.cachedOrderedRooms.splice(roomIdx, 1); // splice out the old index (fixed position)
+            this.cachedOrderedRooms.splice(this.indices[category], 0, room); // splice in the new room (pre-adjusted)
+            // Note: if moveRoomIndexes() is called after the splice then the insert operation
+            // will happen in the wrong place. Because we would have already adjusted the index
+            // for the category, we don't need to determine how the room is moving in the list.
+            // If we instead tried to insert before updating the indices, we'd have to determine
+            // whether the room was moving later (towards IDLE) or earlier (towards RED) from its
+            // current position, as it'll affect the category's start index after we remove the
+            // room from the array.
+        }
+
+        // Sort the category now that we've dumped the room in
+        this.sortCategory(category);
+
+        return true; // change made
     }
 
-    private async sortCategory(category: NotificationColor) {
+    private sortCategory(category: NotificationColor) {
         // This should be relatively quick because the room is usually inserted at the top of the
         // category, and most popular sorting algorithms will deal with trying to keep the active
         // room at the top/start of the category. For the few algorithms that will have to move the
@@ -201,7 +195,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
         const startIdx = this.indices[category];
         const numSort = nextCategoryStartIdx - startIdx; // splice() returns up to the max, so MAX_SAFE_INT is fine
         const unsortedSlice = this.cachedOrderedRooms.splice(startIdx, numSort);
-        const sorted = await sortRoomsWithAlgorithm(unsortedSlice, this.tagId, this.sortingAlgorithm);
+        const sorted = sortRoomsWithAlgorithm(unsortedSlice, this.tagId, this.sortingAlgorithm);
         this.cachedOrderedRooms.splice(startIdx, 0, ...sorted);
     }
 
diff --git a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts
index cc2a28d892..91182dee16 100644
--- a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts
+++ b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts
@@ -29,42 +29,32 @@ export class NaturalAlgorithm extends OrderingAlgorithm {
         super(tagId, initialSortingAlgorithm);
     }
 
-    public async setRooms(rooms: Room[]): Promise<any> {
-        this.cachedOrderedRooms = await sortRoomsWithAlgorithm(rooms, this.tagId, this.sortingAlgorithm);
+    public setRooms(rooms: Room[]): void {
+        this.cachedOrderedRooms = sortRoomsWithAlgorithm(rooms, this.tagId, this.sortingAlgorithm);
     }
 
-    public async handleRoomUpdate(room, cause): Promise<boolean> {
-        try {
-            await this.updateLock.acquireAsync();
-
-            const isSplice = cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved;
-            const isInPlace = cause === RoomUpdateCause.Timeline || cause === RoomUpdateCause.ReadReceipt;
-            if (!isSplice && !isInPlace) {
-                throw new Error(`Unsupported update cause: ${cause}`);
-            }
-
-            if (cause === RoomUpdateCause.NewRoom) {
-                this.cachedOrderedRooms.push(room);
-            } else if (cause === RoomUpdateCause.RoomRemoved) {
-                const idx = this.getRoomIndex(room);
-                if (idx >= 0) {
-                    this.cachedOrderedRooms.splice(idx, 1);
-                } else {
-                    console.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`);
-                }
-            }
-
-            // TODO: Optimize this to avoid useless operations: https://github.com/vector-im/element-web/issues/14457
-            // For example, we can skip updates to alphabetic (sometimes) and manually ordered tags
-            this.cachedOrderedRooms = await sortRoomsWithAlgorithm(
-                this.cachedOrderedRooms,
-                this.tagId,
-                this.sortingAlgorithm,
-            );
-
-            return true;
-        } finally {
-            await this.updateLock.release();
+    public handleRoomUpdate(room, cause): boolean {
+        const isSplice = cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved;
+        const isInPlace = cause === RoomUpdateCause.Timeline || cause === RoomUpdateCause.ReadReceipt;
+        if (!isSplice && !isInPlace) {
+            throw new Error(`Unsupported update cause: ${cause}`);
         }
+
+        if (cause === RoomUpdateCause.NewRoom) {
+            this.cachedOrderedRooms.push(room);
+        } else if (cause === RoomUpdateCause.RoomRemoved) {
+            const idx = this.getRoomIndex(room);
+            if (idx >= 0) {
+                this.cachedOrderedRooms.splice(idx, 1);
+            } else {
+                console.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`);
+            }
+        }
+
+        // TODO: Optimize this to avoid useless operations: https://github.com/vector-im/element-web/issues/14457
+        // For example, we can skip updates to alphabetic (sometimes) and manually ordered tags
+        this.cachedOrderedRooms = sortRoomsWithAlgorithm(this.cachedOrderedRooms, this.tagId, this.sortingAlgorithm);
+
+        return true;
     }
 }
diff --git a/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts
index c47a35523c..9d7b5f9ddb 100644
--- a/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts
+++ b/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts
@@ -17,7 +17,6 @@ limitations under the License.
 import { Room } from "matrix-js-sdk/src/models/room";
 import { RoomUpdateCause, TagID } from "../../models";
 import { SortAlgorithm } from "../models";
-import AwaitLock from "await-lock";
 
 /**
  * Represents a list ordering algorithm. Subclasses should populate the
@@ -26,7 +25,6 @@ import AwaitLock from "await-lock";
 export abstract class OrderingAlgorithm {
     protected cachedOrderedRooms: Room[];
     protected sortingAlgorithm: SortAlgorithm;
-    protected readonly updateLock = new AwaitLock();
 
     protected constructor(protected tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
         // noinspection JSIgnoredPromiseFromCall
@@ -45,21 +43,20 @@ export abstract class OrderingAlgorithm {
      * @param newAlgorithm The new algorithm. Must be defined.
      * @returns Resolves when complete.
      */
-    public async setSortAlgorithm(newAlgorithm: SortAlgorithm) {
+    public setSortAlgorithm(newAlgorithm: SortAlgorithm) {
         if (!newAlgorithm) throw new Error("A sorting algorithm must be defined");
         this.sortingAlgorithm = newAlgorithm;
 
         // Force regeneration of the rooms
-        await this.setRooms(this.orderedRooms);
+        this.setRooms(this.orderedRooms);
     }
 
     /**
      * Sets the rooms the algorithm should be handling, implying a reconstruction
      * of the ordering.
      * @param rooms The rooms to use going forward.
-     * @returns Resolves when complete.
      */
-    public abstract setRooms(rooms: Room[]): Promise<any>;
+    public abstract setRooms(rooms: Room[]): void;
 
     /**
      * Handle a room update. The Algorithm will only call this for causes which
@@ -69,7 +66,7 @@ export abstract class OrderingAlgorithm {
      * @param cause The cause of the update.
      * @returns True if the update requires the Algorithm to update the presentation layers.
      */
-    public abstract handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean>;
+    public abstract handleRoomUpdate(room: Room, cause: RoomUpdateCause): boolean;
 
     protected getRoomIndex(room: Room): number {
         let roomIdx = this.cachedOrderedRooms.indexOf(room);
diff --git a/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts
index b016a4256c..45f6eaf843 100644
--- a/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts
+++ b/src/stores/room-list/algorithms/tag-sorting/AlphabeticAlgorithm.ts
@@ -23,7 +23,7 @@ import { compare } from "../../../../utils/strings";
  * Sorts rooms according to the browser's determination of alphabetic.
  */
 export class AlphabeticAlgorithm implements IAlgorithm {
-    public async sortRooms(rooms: Room[], tagId: TagID): Promise<Room[]> {
+    public sortRooms(rooms: Room[], tagId: TagID): Room[] {
         return rooms.sort((a, b) => {
             return compare(a.name, b.name);
         });
diff --git a/src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts
index 6c22ee0c9c..588bbbffc9 100644
--- a/src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts
+++ b/src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts
@@ -25,7 +25,7 @@ export interface IAlgorithm {
      * Sorts the given rooms according to the sorting rules of the algorithm.
      * @param {Room[]} rooms The rooms to sort.
      * @param {TagID} tagId The tag ID in which the rooms are being sorted.
-     * @returns {Promise<Room[]>} Resolves to the sorted rooms.
+     * @returns {Room[]} Returns the sorted rooms.
      */
-    sortRooms(rooms: Room[], tagId: TagID): Promise<Room[]>;
+    sortRooms(rooms: Room[], tagId: TagID): Room[];
 }
diff --git a/src/stores/room-list/algorithms/tag-sorting/ManualAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/ManualAlgorithm.ts
index b8c0357633..9be8ba5262 100644
--- a/src/stores/room-list/algorithms/tag-sorting/ManualAlgorithm.ts
+++ b/src/stores/room-list/algorithms/tag-sorting/ManualAlgorithm.ts
@@ -22,7 +22,7 @@ import { IAlgorithm } from "./IAlgorithm";
  * Sorts rooms according to the tag's `order` property on the room.
  */
 export class ManualAlgorithm implements IAlgorithm {
-    public async sortRooms(rooms: Room[], tagId: TagID): Promise<Room[]> {
+    public sortRooms(rooms: Room[], tagId: TagID): Room[] {
         const getOrderProp = (r: Room) => r.tags[tagId].order || 0;
         return rooms.sort((a, b) => {
             return getOrderProp(a) - getOrderProp(b);
diff --git a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts
index 49cfd9e520..f47458d1b1 100644
--- a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts
+++ b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts
@@ -97,7 +97,7 @@ export const sortRooms = (rooms: Room[]): Room[] => {
  * useful to the user.
  */
 export class RecentAlgorithm implements IAlgorithm {
-    public async sortRooms(rooms: Room[], tagId: TagID): Promise<Room[]> {
+    public sortRooms(rooms: Room[], tagId: TagID): Room[] {
         return sortRooms(rooms);
     }
 }
diff --git a/src/stores/room-list/algorithms/tag-sorting/index.ts b/src/stores/room-list/algorithms/tag-sorting/index.ts
index c22865f5ba..368c76f111 100644
--- a/src/stores/room-list/algorithms/tag-sorting/index.ts
+++ b/src/stores/room-list/algorithms/tag-sorting/index.ts
@@ -46,8 +46,8 @@ export function getSortingAlgorithmInstance(algorithm: SortAlgorithm): IAlgorith
  * @param {Room[]} rooms The rooms to sort.
  * @param {TagID} tagId The tag in which the sorting is occurring.
  * @param {SortAlgorithm} algorithm The algorithm to use for sorting.
- * @returns {Promise<Room[]>} Resolves to the sorted rooms.
+ * @returns {Room[]} Returns the sorted rooms.
  */
-export function sortRoomsWithAlgorithm(rooms: Room[], tagId: TagID, algorithm: SortAlgorithm): Promise<Room[]> {
+export function sortRoomsWithAlgorithm(rooms: Room[], tagId: TagID, algorithm: SortAlgorithm): Room[] {
     return getSortingAlgorithmInstance(algorithm).sortRooms(rooms, tagId);
 }
diff --git a/src/utils/HostingLink.js b/src/utils/HostingLink.js
index ff7b0c221c..134e045ca2 100644
--- a/src/utils/HostingLink.js
+++ b/src/utils/HostingLink.js
@@ -14,9 +14,6 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import url from 'url';
-import qs from 'qs';
-
 import SdkConfig from '../SdkConfig';
 import { MatrixClientPeg } from '../MatrixClientPeg';
 
@@ -28,11 +25,8 @@ export function getHostingLink(campaign) {
     if (MatrixClientPeg.get().getDomain() !== 'matrix.org') return null;
 
     try {
-        const hostingUrl = url.parse(hostingLink);
-        const params = qs.parse(hostingUrl.query);
-        params.utm_campaign = campaign;
-        hostingUrl.search = undefined;
-        hostingUrl.query = params;
+        const hostingUrl = new URL(hostingLink);
+        hostingUrl.searchParams.set("utm_campaign", campaign);
         return hostingUrl.format();
     } catch (e) {
         return hostingLink;
diff --git a/test/test-utils.js b/test/test-utils.js
index ad56522965..d75abc80f0 100644
--- a/test/test-utils.js
+++ b/test/test-utils.js
@@ -96,6 +96,7 @@ export function createTestClient() {
             },
         },
         decryptEventIfNeeded: () => Promise.resolve(),
+        isUserIgnored: jest.fn().mockReturnValue(false),
     };
 }