diff --git a/package.json b/package.json
index 41ba3f47c1..3d1fb535c0 100644
--- a/package.json
+++ b/package.json
@@ -89,11 +89,11 @@
     "prop-types": "^15.5.8",
     "qrcode": "^1.4.4",
     "qs": "^6.6.0",
+    "re-resizable": "^6.5.2",
     "react": "^16.9.0",
     "react-beautiful-dnd": "^4.0.1",
     "react-dom": "^16.9.0",
     "react-focus-lock": "^2.2.1",
-    "react-resizable": "^1.10.1",
     "react-transition-group": "^4.4.1",
     "resize-observer-polyfill": "^1.5.0",
     "sanitize-html": "^1.18.4",
@@ -120,7 +120,9 @@
     "@babel/register": "^7.7.4",
     "@peculiar/webcrypto": "^1.0.22",
     "@types/classnames": "^2.2.10",
+    "@types/counterpart": "^0.18.1",
     "@types/flux": "^3.1.9",
+    "@types/linkifyjs": "^2.1.3",
     "@types/lodash": "^4.14.152",
     "@types/modernizr": "^3.5.3",
     "@types/node": "^12.12.41",
@@ -128,6 +130,7 @@
     "@types/react": "^16.9",
     "@types/react-dom": "^16.9.8",
     "@types/react-transition-group": "^4.4.0",
+    "@types/sanitize-html": "^1.23.3",
     "@types/zxcvbn": "^4.4.0",
     "babel-eslint": "^10.0.3",
     "babel-jest": "^24.9.0",
diff --git a/res/css/_components.scss b/res/css/_components.scss
index 8288cf34f6..85e08110ea 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -51,6 +51,7 @@
 @import "./views/avatars/_BaseAvatar.scss";
 @import "./views/avatars/_DecoratedRoomAvatar.scss";
 @import "./views/avatars/_MemberStatusMessageAvatar.scss";
+@import "./views/avatars/_PulsedAvatar.scss";
 @import "./views/context_menus/_MessageContextMenu.scss";
 @import "./views/context_menus/_RoomTileContextMenu.scss";
 @import "./views/context_menus/_StatusMessageContextMenu.scss";
@@ -225,6 +226,8 @@
 @import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss";
 @import "./views/terms/_InlineTermsAgreement.scss";
 @import "./views/verification/_VerificationShowSas.scss";
+@import "./views/voip/_CallContainer.scss";
 @import "./views/voip/_CallView.scss";
+@import "./views/voip/_CallView2.scss";
 @import "./views/voip/_IncomingCallbox.scss";
 @import "./views/voip/_VideoView.scss";
diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss
index bdaada0d15..935511b160 100644
--- a/res/css/structures/_LeftPanel2.scss
+++ b/res/css/structures/_LeftPanel2.scss
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
 
 $tagPanelWidth: 70px; // only applies in this file, used for calculations
 
@@ -54,7 +54,11 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
         flex-direction: column;
 
         .mx_LeftPanel2_userHeader {
-            padding: 12px 12px 20px; // 12px top, 12px sides, 20px bottom
+            /* 12px top, 12px sides, 20px bottom (using 13px bottom to account
+             * for internal whitespace in the breadcrumbs)
+             */
+            padding: 12px 12px 13px;
+            flex-shrink: 0; // to convince safari's layout engine the flexbox is fine
 
             // Create another flexbox column for the rows to stack within
             display: flex;
@@ -72,7 +76,20 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
                 width: 100%;
                 overflow-y: hidden;
                 overflow-x: scroll;
-                margin-top: 8px;
+                margin-top: 20px;
+                padding-bottom: 2px;
+
+                &.mx_IndicatorScrollbar_leftOverflow {
+                    mask-image: linear-gradient(90deg, transparent, black 10%);
+                }
+
+                &.mx_IndicatorScrollbar_rightOverflow {
+                    mask-image: linear-gradient(90deg, black, black 90%, transparent);
+                }
+
+                &.mx_IndicatorScrollbar_rightOverflow.mx_IndicatorScrollbar_leftOverflow {
+                    mask-image: linear-gradient(90deg, transparent, black 10%, black 90%, transparent);
+                }
             }
         }
 
@@ -80,17 +97,23 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
             margin-left: 12px;
             margin-right: 12px;
 
+            flex-shrink: 0; // to convince safari's layout engine the flexbox is fine
+
             // Create a flexbox to organize the inputs
             display: flex;
             align-items: center;
 
             .mx_RoomSearch_expanded + .mx_LeftPanel2_exploreButton {
                 // Cheaty way to return the occupied space to the filter input
+                flex-basis: 0;
                 margin: 0;
                 width: 0;
 
-                // Don't forget to hide the masked ::before icon
-                visibility: hidden;
+                // Don't forget to hide the masked ::before icon,
+                // using display:none or visibility:hidden would break accessibility
+                &::before {
+                    content: none;
+                }
             }
 
             .mx_LeftPanel2_exploreButton {
@@ -117,6 +140,24 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
             }
         }
 
+        .mx_LeftPanel2_roomListWrapper {
+            // Create a flexbox to ensure the containing items cause appropriate overflow.
+            display: flex;
+
+            flex-grow: 1;
+            overflow: hidden;
+            min-height: 0;
+            margin-top: 12px; // so we're not up against the search/filter
+
+            &.mx_LeftPanel2_roomListWrapper_stickyBottom {
+                padding-bottom: 32px;
+            }
+
+            &.mx_LeftPanel2_roomListWrapper_stickyTop {
+                padding-top: 32px;
+            }
+        }
+
         .mx_LeftPanel2_actualRoomListContainer {
             flex-grow: 1; // fill the available space
             overflow-y: auto;
diff --git a/res/css/views/avatars/_DecoratedRoomAvatar.scss b/res/css/views/avatars/_DecoratedRoomAvatar.scss
index b500d44a43..900f351074 100644
--- a/res/css/views/avatars/_DecoratedRoomAvatar.scss
+++ b/res/css/views/avatars/_DecoratedRoomAvatar.scss
@@ -24,7 +24,7 @@ limitations under the License.
         right: 0;
     }
 
-    .mx_NotificationBadge {
+    .mx_NotificationBadge, .mx_RoomTile2_badgeContainer {
         position: absolute;
         top: 0;
         right: 0;
diff --git a/res/css/views/avatars/_PulsedAvatar.scss b/res/css/views/avatars/_PulsedAvatar.scss
new file mode 100644
index 0000000000..ce9e3382ab
--- /dev/null
+++ b/res/css/views/avatars/_PulsedAvatar.scss
@@ -0,0 +1,30 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_PulsedAvatar {
+    @keyframes shadow-pulse {
+        0% {
+            box-shadow: 0 0 0 0px rgba($accent-color, 0.2);
+        }
+        100% {
+            box-shadow: 0 0 0 6px rgba($accent-color, 0);
+        }
+    }
+
+    img {
+        animation: shadow-pulse 1s infinite;
+    }
+}
diff --git a/res/css/views/rooms/_JumpToBottomButton.scss b/res/css/views/rooms/_JumpToBottomButton.scss
index 63cf574596..23018df8da 100644
--- a/res/css/views/rooms/_JumpToBottomButton.scss
+++ b/res/css/views/rooms/_JumpToBottomButton.scss
@@ -41,6 +41,11 @@ limitations under the License.
     // with text-align in parent
     display: inline-block;
     padding: 0 4px;
+    color: $roomtile-badge-fg-color;
+    background-color: $roomtile-name-color;
+}
+
+.mx_JumpToBottomButton_highlight .mx_JumpToBottomButton_badge {
     color: $secondary-accent-color;
     background-color: $warning-color;
 }
diff --git a/res/css/views/rooms/_RoomBreadcrumbs2.scss b/res/css/views/rooms/_RoomBreadcrumbs2.scss
index 6e5a5fbb16..0c3c41622e 100644
--- a/res/css/views/rooms/_RoomBreadcrumbs2.scss
+++ b/res/css/views/rooms/_RoomBreadcrumbs2.scss
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
 
 .mx_RoomBreadcrumbs2 {
     width: 100%;
diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss
index 0e76152f86..633c33feea 100644
--- a/res/css/views/rooms/_RoomSublist2.scss
+++ b/res/css/views/rooms/_RoomSublist2.scss
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
 
 .mx_RoomSublist2 {
     // The sublist is a column of rows, essentially
@@ -24,9 +24,7 @@ limitations under the License.
     margin-left: 8px;
     width: 100%;
 
-    &:first-child {
-        margin-top: 12px; // so we're not up against the search/filter
-    }
+    flex-shrink: 0; // to convince safari's layout engine the flexbox is fine
 
     .mx_RoomSublist2_headerContainer {
         // Create a flexbox to make alignment easy
@@ -49,13 +47,15 @@ limitations under the License.
         padding-bottom: 8px;
         height: 24px;
 
+        // Hide the header container if the contained element is stickied.
+        // We don't use display:none as that causes the header to go away too.
+        &.mx_RoomSublist2_headerContainer_hasSticky {
+            height: 0;
+        }
+
         .mx_RoomSublist2_stickable {
             flex: 1;
             max-width: 100%;
-            z-index: 2; // Prioritize headers in the visible list over sticky ones
-
-            // Set the same background color as the room list for sticky headers
-            background-color: $roomlist2-bg-color;
 
             // Create a flexbox to make ordering easy
             display: flex;
@@ -67,7 +67,6 @@ limitations under the License.
             // when sticky scrolls instead of collapses the list.
             &.mx_RoomSublist2_headerContainer_sticky {
                 position: fixed;
-                z-index: 1; // over top of other elements, but still under the ones in the visible list
                 height: 32px; // to match the header container
                 // width set by JS
             }
@@ -182,7 +181,6 @@ limitations under the License.
     }
 
     .mx_RoomSublist2_resizeBox {
-        margin-bottom: 4px; // for the resize handle
         position: relative;
 
         // Create another flexbox column for the tiles
@@ -190,93 +188,89 @@ limitations under the License.
         flex-direction: column;
         overflow: hidden;
 
-        .mx_RoomSublist2_showNButton {
-            cursor: pointer;
-            font-size: $font-13px;
-            line-height: $font-18px;
-            color: $roomtile2-preview-color;
-
-            // This is the same color as the left panel background because it needs
-            // to occlude the lastmost tile in the list.
-            background-color: $roomlist2-bg-color;
-
-            // Update the render() function for RoomSublist2 if these change
-            // Update the ListLayout class for minVisibleTiles if these change.
-            //
-            // At 24px high and 8px padding on the top this equates to 0.65 of
-            // a tile due to how the padding calculations work.
-            height: 24px;
-            padding-top: 8px;
-
-            // We force this to the bottom so it will overlap rooms as needed.
-            // We account for the space it takes up (24px) in the code through padding.
-            position: absolute;
-            bottom: 4px; // the height of the resize handle
-            left: 0;
-            right: 0;
-
-            // We create a flexbox to cheat at alignment
+        .mx_RoomSublist2_tiles {
+            flex: 1 0 0;
+            overflow: hidden;
+            // need this to be flex otherwise the overflow hidden from above
+            // sometimes vertically centers the clipped list ... no idea why it would do this
+            // as the box model should be top aligned. Happens in both FF and Chromium
             display: flex;
-            align-items: center;
+            flex-direction: column;
+        }
 
-            .mx_RoomSublist2_showNButtonChevron {
-                position: relative;
-                width: 16px;
-                height: 16px;
-                margin-left: 12px;
-                margin-right: 18px;
-                mask-position: center;
-                mask-size: contain;
-                mask-repeat: no-repeat;
-                background: $roomtile2-preview-color;
-            }
+        .mx_RoomSublist2_resizerHandles_showNButton {
+            flex: 0 0 32px;
+        }
 
-            .mx_RoomSublist2_showMoreButtonChevron {
-                mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
-            }
-
-            .mx_RoomSublist2_showLessButtonChevron {
-                mask-image: url('$(res)/img/feather-customised/chevron-up.svg');
-            }
-
-            &.mx_RoomSublist2_isCutting::before {
-                content: '';
-                position: absolute;
-                top: 0;
-                left: 0;
-                right: 0;
-                height: 4px;
-                box-shadow: 0px -2px 3px rgba(46, 47, 50, 0.08);
-            }
+        .mx_RoomSublist2_resizerHandles {
+            flex: 0 0 4px;
         }
 
         // Class name comes from the ResizableBox component
         // The hover state needs to use the whole sublist, not just the resizable box,
         // so that selector is below and one level higher.
-        .react-resizable-handle {
+        .mx_RoomSublist2_resizerHandle {
             cursor: ns-resize;
             border-radius: 3px;
 
-            // Update RESIZE_HANDLE_HEIGHT if this changes
-            height: 4px;
+            // Override styles from library
+            width: unset !important;
+            height: 4px !important; // Update RESIZE_HANDLE_HEIGHT if this changes
 
             // This is positioned directly below the 'show more' button.
             position: absolute;
-            bottom: 0;
+            bottom: 0 !important; // override from library
 
             // Together, these make the bar 64px wide
-            left: calc(50% - 32px);
-            right: calc(50% - 32px);
+            // These are also overridden from the library
+            left: calc(50% - 32px) !important;
+            right: calc(50% - 32px) !important;
         }
 
         &:hover, &.mx_RoomSublist2_hasMenuOpen {
-            .react-resizable-handle {
+            .mx_RoomSublist2_resizerHandle {
                 opacity: 0.8;
                 background-color: $primary-fg-color;
             }
         }
     }
 
+    .mx_RoomSublist2_showNButton {
+        cursor: pointer;
+        font-size: $font-13px;
+        line-height: $font-18px;
+        color: $roomtile2-preview-color;
+
+        // Update the render() function for RoomSublist2 if these change
+        // Update the ListLayout class for minVisibleTiles if these change.
+        height: 24px;
+        padding-bottom: 4px;
+
+        // We create a flexbox to cheat at alignment
+        display: flex;
+        align-items: center;
+
+        .mx_RoomSublist2_showNButtonChevron {
+            position: relative;
+            width: 16px;
+            height: 16px;
+            margin-left: 12px;
+            margin-right: 18px;
+            mask-position: center;
+            mask-size: contain;
+            mask-repeat: no-repeat;
+            background: $roomtile2-preview-color;
+        }
+
+        .mx_RoomSublist2_showMoreButtonChevron {
+            mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
+        }
+
+        .mx_RoomSublist2_showLessButtonChevron {
+            mask-image: url('$(res)/img/feather-customised/chevron-up.svg');
+        }
+    }
+
     &.mx_RoomSublist2_hasMenuOpen,
     &:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:focus-within,
     &:not(.mx_RoomSublist2_minimized) > .mx_RoomSublist2_headerContainer:hover {
@@ -322,13 +316,13 @@ limitations under the License.
 
         .mx_RoomSublist2_resizeBox {
             align-items: center;
+        }
 
-            .mx_RoomSublist2_showNButton {
-                flex-direction: column;
+        .mx_RoomSublist2_showNButton {
+            flex-direction: column;
 
-                .mx_RoomSublist2_showNButtonChevron {
-                    margin-right: 12px; // to center
-                }
+            .mx_RoomSublist2_showNButtonChevron {
+                margin-right: 12px; // to center
             }
         }
 
diff --git a/res/css/views/rooms/_RoomTile2.scss b/res/css/views/rooms/_RoomTile2.scss
index 7b606ab947..7348398a10 100644
--- a/res/css/views/rooms/_RoomTile2.scss
+++ b/res/css/views/rooms/_RoomTile2.scss
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
 
 // Note: the room tile expects to be in a flexbox column container
 .mx_RoomTile2 {
@@ -77,7 +77,7 @@ limitations under the License.
         }
     }
 
-    .mx_RoomTile2_menuButton {
+    .mx_RoomTile2_notificationsButton {
         margin-left: 4px; // spacing between buttons
     }
 
@@ -85,7 +85,6 @@ limitations under the License.
         height: 16px;
         // don't set width so that it takes no space when there is no badge to show
         margin: auto 0; // vertically align
-        position: relative; // fixes badge alignment in some scenarios
 
         // Create a flexbox to make aligning dot badges easier
         display: flex;
@@ -108,7 +107,8 @@ limitations under the License.
         width: 20px;
         min-width: 20px; // yay flex
         height: 20px;
-        margin: auto 0;
+        margin-top: auto;
+        margin-bottom: auto;
         position: relative;
         display: none;
 
@@ -223,6 +223,10 @@ limitations under the License.
         mask-image: url('$(res)/img/feather-customised/star.svg');
     }
 
+    .mx_RoomTile2_iconFavorite::before {
+        mask-image: url('$(res)/img/feather-customised/favourites.svg');
+    }
+
     .mx_RoomTile2_iconArrowDown::before {
         mask-image: url('$(res)/img/feather-customised/arrow-down.svg');
     }
diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss
new file mode 100644
index 0000000000..e13c851716
--- /dev/null
+++ b/res/css/views/voip/_CallContainer.scss
@@ -0,0 +1,89 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_CallContainer {
+    position: absolute;
+    right: 20px;
+    bottom: 72px;
+    border-radius: 8px;
+    overflow: hidden;
+    z-index: 100;
+    box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08);
+
+    cursor: pointer;
+
+    .mx_CallPreview {
+        .mx_VideoView {
+            width: 350px;
+        }
+
+        .mx_VideoView_localVideoFeed {
+            border-radius: 8px;
+            overflow: hidden;
+        }
+    }
+
+    .mx_IncomingCallBox2 {
+        min-width: 250px;
+        background-color: $primary-bg-color;
+        padding: 8px;
+
+        .mx_IncomingCallBox2_CallerInfo {
+            display: flex;
+            direction: row;
+
+            img {
+                margin: 8px;
+            }
+
+            > div {
+                display: flex;
+                flex-direction: column;
+
+                justify-content: center;
+            }
+
+            h1, p {
+                margin: 0px;
+                padding: 0px;
+                font-size: $font-14px;
+                line-height: $font-16px;
+            }
+
+            h1 {
+                font-weight: bold;
+            }
+        }
+
+        .mx_IncomingCallBox2_buttons {
+            padding: 8px;
+            display: flex;
+            flex-direction: row;
+
+            > .mx_IncomingCallBox2_spacer {
+                width: 8px;
+            }
+
+            > * {
+                flex-shrink: 0;
+                flex-grow: 1;
+                margin-right: 0;
+                font-size: $font-15px;
+                line-height: $font-24px;
+            }
+        }
+    }
+}
diff --git a/res/css/views/voip/_CallView2.scss b/res/css/views/voip/_CallView2.scss
new file mode 100644
index 0000000000..3b66e7a175
--- /dev/null
+++ b/res/css/views/voip/_CallView2.scss
@@ -0,0 +1,96 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+
+.mx_CallView2_voice {
+    background-color: $accent-color;
+    color: $accent-fg-color;
+    cursor: pointer;
+    padding: 6px;
+    font-weight: bold;
+
+    border-radius: 8px;
+    min-width: 200px;
+
+    display: flex;
+    align-items: center;
+
+    img {
+        margin: 4px;
+        margin-right: 10px;
+    }
+
+    > div {
+        display: flex;
+        flex-direction: column;
+        // Hacky vertical align
+        padding-top: 3px;
+    }
+
+    > div > p,
+    > div > h1 {
+        padding: 0;
+        margin: 0;
+        font-size: $font-13px;
+        line-height: $font-15px;
+    }
+
+    > div > p {
+        font-weight: bold;
+    }
+
+    > * {
+        flex-grow: 0;
+        flex-shrink: 0;
+    }
+}
+
+.mx_CallView2_hangup {
+    position: absolute;
+
+    right: 8px;
+    bottom: 10px;
+
+    height: 35px;
+    width: 35px;
+
+    border-radius: 35px;
+
+    background-color: $notice-primary-color;
+
+    z-index: 101;
+
+    cursor: pointer;
+
+    &::before {
+        content: '';
+        position: absolute;
+
+        height: 20px;
+        width: 20px;
+
+        top: 6.5px;
+        left: 7.5px;
+
+        mask: url('$(res)/img/hangup.svg');
+        mask-size: contain;
+        background-size: contain;
+
+        background-color: $primary-fg-color;
+    }
+}
diff --git a/res/img/feather-customised/favourites.svg b/res/img/feather-customised/favourites.svg
new file mode 100644
index 0000000000..80f08f6e55
--- /dev/null
+++ b/res/img/feather-customised/favourites.svg
@@ -0,0 +1,3 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M7.41411 0.432179C7.59217 -0.144061 8.40783 -0.144059 8.58589 0.43218L10.1715 5.56319H15.3856C15.9721 5.56319 16.224 6.30764 15.7578 6.66373L11.5135 9.90611L13.1185 15.1001C13.2948 15.6705 12.6348 16.1309 12.1604 15.7684L8 12.5902L3.83965 15.7684C3.3652 16.1309 2.70521 15.6705 2.88148 15.1001L4.4865 9.90611L0.242159 6.66373C-0.223967 6.30764 0.0278507 5.56319 0.614427 5.56319H5.82854L7.41411 0.432179Z" fill="black"/>
+</svg>
diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss
index c4b4262642..8469a85bfe 100644
--- a/res/themes/light/css/_light.scss
+++ b/res/themes/light/css/_light.scss
@@ -36,7 +36,7 @@ $focus-bg-color: #dddddd;
 $accent-fg-color: #ffffff;
 $accent-color-50pct: rgba(3, 179, 129, 0.5);    //#03b381 in rgb
 $accent-color-darker: #92caad;
-$accent-color-alt: #238CF5;
+$accent-color-alt: #238cf5;
 
 $selection-fg-color: $primary-bg-color;
 
@@ -46,8 +46,8 @@ $focus-brightness: 105%;
 $warning-color: $notice-primary-color; // red
 $orange-warning-color: #ff8d13; // used for true warnings
 // background colour for warnings
-$warning-bg-color: #DF2A8B;
-$info-bg-color: #2A9EDF;
+$warning-bg-color: #df2a8b;
+$info-bg-color: #2a9edf;
 $mention-user-pill-bg-color: $warning-color;
 $other-user-pill-bg-color: rgba(0, 0, 0, 0.1);
 
@@ -71,7 +71,7 @@ $tagpanel-bg-color: #27303a;
 $plinth-bg-color: $secondary-accent-color;
 
 // used by RoomDropTarget
-$droptarget-bg-color: rgba(255,255,255,0.5);
+$droptarget-bg-color: rgba(255, 255, 255, 0.5);
 
 // used by AddressSelector
 $selected-color: $secondary-accent-color;
@@ -157,18 +157,18 @@ $rte-group-pill-color: #aaa;
 
 $topleftmenu-color: #212121;
 $roomheader-color: #45474a;
-$roomheader-addroom-bg-color: #91A1C0;
+$roomheader-addroom-bg-color: #91a1c0;
 $roomheader-addroom-fg-color: $accent-fg-color;
-$tagpanel-button-color: #91A1C0;
-$roomheader-button-color: #91A1C0;
-$groupheader-button-color: #91A1C0;
-$rightpanel-button-color: #91A1C0;
-$composer-button-color: #91A1C0;
+$tagpanel-button-color: #91a1c0;
+$roomheader-button-color: #91a1c0;
+$groupheader-button-color: #91a1c0;
+$rightpanel-button-color: #91a1c0;
+$composer-button-color: #91a1c0;
 $roomtopic-color: #9e9e9e;
 $eventtile-meta-color: $roomtopic-color;
 
 $composer-e2e-icon-color: #c9ced6;
-$header-divider-color: #91A1C0;
+$header-divider-color: #91a1c0;
 
 // ********************
 
@@ -184,11 +184,11 @@ $roomsublist2-divider-color: $primary-fg-color;
 
 $roomtile2-preview-color: #9e9e9e;
 $roomtile2-default-badge-bg-color: #61708b;
-$roomtile2-selected-bg-color: #FFF;
+$roomtile2-selected-bg-color: #fff;
 
 $presence-online: $accent-color;
-$presence-away: orange; // TODO: Get color
-$presence-offline: #E3E8F0;
+$presence-away: #d9b072;
+$presence-offline: #e3e8f0;
 
 // ********************
 
diff --git a/src/@types/common.ts b/src/@types/common.ts
index 9109993541..a24d47ac9e 100644
--- a/src/@types/common.ts
+++ b/src/@types/common.ts
@@ -17,3 +17,4 @@ limitations under the License.
 // Based on https://stackoverflow.com/a/53229857/3532235
 export type Without<T, U> = {[P in Exclude<keyof T, keyof U>] ? : never};
 export type XOR<T, U> = (T | U) extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U;
+export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts
index ffd3277892..3f970ea8c3 100644
--- a/src/@types/global.d.ts
+++ b/src/@types/global.d.ts
@@ -20,6 +20,8 @@ import { IMatrixClientPeg } from "../MatrixClientPeg";
 import ToastStore from "../stores/ToastStore";
 import DeviceListener from "../DeviceListener";
 import { RoomListStore2 } from "../stores/room-list/RoomListStore2";
+import { PlatformPeg } from "../PlatformPeg";
+import RoomListLayoutStore from "../stores/room-list/RoomListLayoutStore";
 
 declare global {
     interface Window {
@@ -33,6 +35,11 @@ declare global {
         mx_ToastStore: ToastStore;
         mx_DeviceListener: DeviceListener;
         mx_RoomListStore2: RoomListStore2;
+        mx_RoomListLayoutStore: RoomListLayoutStore;
+        mxPlatformPeg: PlatformPeg;
+
+        // TODO: Remove flag before launch: https://github.com/vector-im/riot-web/issues/14231
+        mx_QuietRoomListLogging: boolean;
     }
 
     // workaround for https://github.com/microsoft/TypeScript/issues/30933
@@ -45,6 +52,10 @@ declare global {
         hasStorageAccess?: () => Promise<boolean>;
     }
 
+    interface Navigator {
+        userLanguage?: string;
+    }
+
     interface StorageEstimate {
         usageDetails?: {[key: string]: number};
     }
diff --git a/src/@types/polyfill.ts b/src/@types/polyfill.ts
new file mode 100644
index 0000000000..3ce05d9c2f
--- /dev/null
+++ b/src/@types/polyfill.ts
@@ -0,0 +1,38 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// This is intended to fix re-resizer because of its unguarded `instanceof TouchEvent` checks.
+export function polyfillTouchEvent() {
+    // Firefox doesn't have touch events without touch devices being present, so create a fake
+    // one we can rely on lying about.
+    if (!window.TouchEvent) {
+        // We have no intention of actually using this, so just lie.
+        window.TouchEvent = class TouchEvent extends UIEvent {
+            public get altKey(): boolean { return false; }
+            public get changedTouches(): any { return []; }
+            public get ctrlKey(): boolean { return false; }
+            public get metaKey(): boolean { return false; }
+            public get shiftKey(): boolean { return false; }
+            public get targetTouches(): any { return []; }
+            public get touches(): any { return []; }
+            public get rotation(): number { return 0.0; }
+            public get scale(): number { return 0.0; }
+            constructor(eventType: string, params?: any) {
+                super(eventType, params);
+            }
+        };
+    }
+}
diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts
index 1d11495e61..acf72a986c 100644
--- a/src/BasePlatform.ts
+++ b/src/BasePlatform.ts
@@ -53,6 +53,10 @@ export default abstract class BasePlatform {
         this.startUpdateCheck = this.startUpdateCheck.bind(this);
     }
 
+    abstract async getConfig(): Promise<{}>;
+
+    abstract getDefaultDeviceDisplayName(): string;
+
     protected onAction = (payload: ActionPayload) => {
         switch (payload.action) {
             case 'on_client_not_viable':
diff --git a/src/HtmlUtils.js b/src/HtmlUtils.tsx
similarity index 83%
rename from src/HtmlUtils.js
rename to src/HtmlUtils.tsx
index 34e9e55d25..6dba041685 100644
--- a/src/HtmlUtils.js
+++ b/src/HtmlUtils.tsx
@@ -17,10 +17,6 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-'use strict';
-
-import ReplyThread from "./components/views/elements/ReplyThread";
-
 import React from 'react';
 import sanitizeHtml from 'sanitize-html';
 import * as linkify from 'linkifyjs';
@@ -28,12 +24,13 @@ import linkifyMatrix from './linkify-matrix';
 import _linkifyElement from 'linkifyjs/element';
 import _linkifyString from 'linkifyjs/string';
 import classNames from 'classnames';
-import {MatrixClientPeg} from './MatrixClientPeg';
+import EMOJIBASE_REGEX from 'emojibase-regex';
 import url from 'url';
 
-import EMOJIBASE_REGEX from 'emojibase-regex';
+import {MatrixClientPeg} from './MatrixClientPeg';
 import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks";
 import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji";
+import ReplyThread from "./components/views/elements/ReplyThread";
 
 linkifyMatrix(linkify);
 
@@ -64,7 +61,7 @@ const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
  * need emojification.
  * unicodeToImage uses this function.
  */
-function mightContainEmoji(str) {
+function mightContainEmoji(str: string) {
     return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str);
 }
 
@@ -74,7 +71,7 @@ function mightContainEmoji(str) {
  * @param {String} char The emoji character
  * @return {String} The shortcode (such as :thumbup:)
  */
-export function unicodeToShortcode(char) {
+export function unicodeToShortcode(char: string) {
     const data = getEmojiFromUnicode(char);
     return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : '');
 }
@@ -85,7 +82,7 @@ export function unicodeToShortcode(char) {
  * @param {String} shortcode The shortcode (such as :thumbup:)
  * @return {String} The emoji character; null if none exists
  */
-export function shortcodeToUnicode(shortcode) {
+export function shortcodeToUnicode(shortcode: string) {
     shortcode = shortcode.slice(1, shortcode.length - 1);
     const data = SHORTCODE_TO_EMOJI.get(shortcode);
     return data ? data.unicode : null;
@@ -100,7 +97,7 @@ export function processHtmlForSending(html: string): string {
     }
 
     let contentHTML = "";
-    for (let i=0; i < contentDiv.children.length; i++) {
+    for (let i = 0; i < contentDiv.children.length; i++) {
         const element = contentDiv.children[i];
         if (element.tagName.toLowerCase() === 'p') {
             contentHTML += element.innerHTML;
@@ -122,12 +119,19 @@ export function processHtmlForSending(html: string): string {
  * Given an untrusted HTML string, return a React node with an sanitized version
  * of that HTML.
  */
-export function sanitizedHtmlNode(insaneHtml) {
+export function sanitizedHtmlNode(insaneHtml: string) {
     const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
 
     return <div dangerouslySetInnerHTML={{ __html: saneHtml }} dir="auto" />;
 }
 
+export function sanitizedHtmlNodeInnerText(insaneHtml: string) {
+    const saneHtml = sanitizeHtml(insaneHtml, sanitizeHtmlParams);
+    const contentDiv = document.createElement("div");
+    contentDiv.innerHTML = saneHtml;
+    return contentDiv.innerText;
+}
+
 /**
  * Tests if a URL from an untrusted source may be safely put into the DOM
  * The biggest threat here is javascript: URIs.
@@ -136,7 +140,7 @@ export function sanitizedHtmlNode(insaneHtml) {
  * other places we need to sanitise URLs.
  * @return true if permitted, otherwise false
  */
-export function isUrlPermitted(inputUrl) {
+export function isUrlPermitted(inputUrl: string) {
     try {
         const parsed = url.parse(inputUrl);
         if (!parsed.protocol) return false;
@@ -147,9 +151,9 @@ export function isUrlPermitted(inputUrl) {
     }
 }
 
-const transformTags = { // custom to matrix
+const transformTags: sanitizeHtml.IOptions["transformTags"] = { // custom to matrix
     // add blank targets to all hyperlinks except vector URLs
-    'a': function(tagName, attribs) {
+    'a': function(tagName: string, attribs: sanitizeHtml.Attributes) {
         if (attribs.href) {
             attribs.target = '_blank'; // by default
 
@@ -162,7 +166,7 @@ const transformTags = { // custom to matrix
         attribs.rel = 'noreferrer noopener'; // https://mathiasbynens.github.io/rel-noopener/
         return { tagName, attribs };
     },
-    'img': function(tagName, attribs) {
+    'img': function(tagName: string, attribs: sanitizeHtml.Attributes) {
         // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
         // because transformTags is used _before_ we filter by allowedSchemesByTag and
         // we don't want to allow images with `https?` `src`s.
@@ -176,7 +180,7 @@ const transformTags = { // custom to matrix
         );
         return { tagName, attribs };
     },
-    'code': function(tagName, attribs) {
+    'code': function(tagName: string, attribs: sanitizeHtml.Attributes) {
         if (typeof attribs.class !== 'undefined') {
             // Filter out all classes other than ones starting with language- for syntax highlighting.
             const classes = attribs.class.split(/\s/).filter(function(cl) {
@@ -186,7 +190,7 @@ const transformTags = { // custom to matrix
         }
         return { tagName, attribs };
     },
-    '*': function(tagName, attribs) {
+    '*': function(tagName: string, attribs: sanitizeHtml.Attributes) {
         // Delete any style previously assigned, style is an allowedTag for font and span
         // because attributes are stripped after transforming
         delete attribs.style;
@@ -220,7 +224,7 @@ const transformTags = { // custom to matrix
     },
 };
 
-const sanitizeHtmlParams = {
+const sanitizeHtmlParams: sanitizeHtml.IOptions = {
     allowedTags: [
         'font', // custom to matrix for IRC-style font coloring
         'del', // for markdown
@@ -247,16 +251,16 @@ const sanitizeHtmlParams = {
 };
 
 // this is the same as the above except with less rewriting
-const composerSanitizeHtmlParams = Object.assign({}, sanitizeHtmlParams);
-composerSanitizeHtmlParams.transformTags = {
-    'code': transformTags['code'],
-    '*': transformTags['*'],
+const composerSanitizeHtmlParams: sanitizeHtml.IOptions = {
+    ...sanitizeHtmlParams,
+    transformTags: {
+        'code': transformTags['code'],
+        '*': transformTags['*'],
+    },
 };
 
-class BaseHighlighter {
-    constructor(highlightClass, highlightLink) {
-        this.highlightClass = highlightClass;
-        this.highlightLink = highlightLink;
+abstract class BaseHighlighter<T extends React.ReactNode> {
+    constructor(public highlightClass: string, public highlightLink: string) {
     }
 
     /**
@@ -270,47 +274,49 @@ class BaseHighlighter {
      * returns a list of results (strings for HtmlHighligher, react nodes for
      * TextHighlighter).
      */
-    applyHighlights(safeSnippet, safeHighlights) {
+    public applyHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
         let lastOffset = 0;
         let offset;
-        let nodes = [];
+        let nodes: T[] = [];
 
         const safeHighlight = safeHighlights[0];
         while ((offset = safeSnippet.toLowerCase().indexOf(safeHighlight.toLowerCase(), lastOffset)) >= 0) {
             // handle preamble
             if (offset > lastOffset) {
-                var subSnippet = safeSnippet.substring(lastOffset, offset);
-                nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights));
+                const subSnippet = safeSnippet.substring(lastOffset, offset);
+                nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
             }
 
             // do highlight. use the original string rather than safeHighlight
             // to preserve the original casing.
             const endOffset = offset + safeHighlight.length;
-            nodes.push(this._processSnippet(safeSnippet.substring(offset, endOffset), true));
+            nodes.push(this.processSnippet(safeSnippet.substring(offset, endOffset), true));
 
             lastOffset = endOffset;
         }
 
         // handle postamble
         if (lastOffset !== safeSnippet.length) {
-            subSnippet = safeSnippet.substring(lastOffset, undefined);
-            nodes = nodes.concat(this._applySubHighlights(subSnippet, safeHighlights));
+            const subSnippet = safeSnippet.substring(lastOffset, undefined);
+            nodes = nodes.concat(this.applySubHighlights(subSnippet, safeHighlights));
         }
         return nodes;
     }
 
-    _applySubHighlights(safeSnippet, safeHighlights) {
+    private applySubHighlights(safeSnippet: string, safeHighlights: string[]): T[] {
         if (safeHighlights[1]) {
             // recurse into this range to check for the next set of highlight matches
             return this.applyHighlights(safeSnippet, safeHighlights.slice(1));
         } else {
             // no more highlights to be found, just return the unhighlighted string
-            return [this._processSnippet(safeSnippet, false)];
+            return [this.processSnippet(safeSnippet, false)];
         }
     }
+
+    protected abstract processSnippet(snippet: string, highlight: boolean): T;
 }
 
-class HtmlHighlighter extends BaseHighlighter {
+class HtmlHighlighter extends BaseHighlighter<string> {
     /* highlight the given snippet if required
      *
      * snippet: content of the span; must have been sanitised
@@ -318,28 +324,23 @@ class HtmlHighlighter extends BaseHighlighter {
      *
      * returns an HTML string
      */
-    _processSnippet(snippet, highlight) {
+    protected processSnippet(snippet: string, highlight: boolean): string {
         if (!highlight) {
             // nothing required here
             return snippet;
         }
 
-        let span = "<span class=\""+this.highlightClass+"\">"
-            + snippet + "</span>";
+        let span = `<span class="${this.highlightClass}">${snippet}</span>`;
 
         if (this.highlightLink) {
-            span = "<a href=\""+encodeURI(this.highlightLink)+"\">"
-                +span+"</a>";
+            span = `<a href="${encodeURI(this.highlightLink)}">${span}</a>`;
         }
         return span;
     }
 }
 
-class TextHighlighter extends BaseHighlighter {
-    constructor(highlightClass, highlightLink) {
-        super(highlightClass, highlightLink);
-        this._key = 0;
-    }
+class TextHighlighter extends BaseHighlighter<React.ReactNode> {
+    private key = 0;
 
     /* create a <span> node to hold the given content
      *
@@ -348,13 +349,12 @@ class TextHighlighter extends BaseHighlighter {
      *
      * returns a React node
      */
-    _processSnippet(snippet, highlight) {
-        const key = this._key++;
+    protected processSnippet(snippet: string, highlight: boolean): React.ReactNode {
+        const key = this.key++;
 
-        let node =
-            <span key={key} className={highlight ? this.highlightClass : null}>
-                { snippet }
-            </span>;
+        let node = <span key={key} className={highlight ? this.highlightClass : null}>
+            { snippet }
+        </span>;
 
         if (highlight && this.highlightLink) {
             node = <a key={key} href={this.highlightLink}>{ node }</a>;
@@ -364,6 +364,20 @@ class TextHighlighter extends BaseHighlighter {
     }
 }
 
+interface IContent {
+    format?: string;
+    formatted_body?: string;
+    body: string;
+}
+
+interface IOpts {
+    highlightLink?: string;
+    disableBigEmoji?: boolean;
+    stripReplyFallback?: boolean;
+    returnString?: boolean;
+    forComposerQuote?: boolean;
+    ref?: React.Ref<any>;
+}
 
 /* turn a matrix event body into html
  *
@@ -378,7 +392,7 @@ class TextHighlighter extends BaseHighlighter {
  * opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer
  * opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString)
  */
-export function bodyToHtml(content, highlights, opts={}) {
+export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts = {}) {
     const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
     let bodyHasEmoji = false;
 
@@ -387,9 +401,9 @@ export function bodyToHtml(content, highlights, opts={}) {
         sanitizeParams = composerSanitizeHtmlParams;
     }
 
-    let strippedBody;
-    let safeBody;
-    let isDisplayedWithHtml;
+    let strippedBody: string;
+    let safeBody: string;
+    let isDisplayedWithHtml: boolean;
     // XXX: We sanitize the HTML whilst also highlighting its text nodes, to avoid accidentally trying
     // to highlight HTML tags themselves.  However, this does mean that we don't highlight textnodes which
     // are interrupted by HTML tags (not that we did before) - e.g. foo<span/>bar won't get highlighted
@@ -471,7 +485,7 @@ export function bodyToHtml(content, highlights, opts={}) {
  * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
  * @returns {string} Linkified string
  */
-export function linkifyString(str, options = linkifyMatrix.options) {
+export function linkifyString(str: string, options = linkifyMatrix.options) {
     return _linkifyString(str, options);
 }
 
@@ -482,7 +496,7 @@ export function linkifyString(str, options = linkifyMatrix.options) {
  * @param {object} [options] Options for linkifyElement. Default: linkifyMatrix.options
  * @returns {object}
  */
-export function linkifyElement(element, options = linkifyMatrix.options) {
+export function linkifyElement(element: HTMLElement, options = linkifyMatrix.options) {
     return _linkifyElement(element, options);
 }
 
@@ -493,7 +507,7 @@ export function linkifyElement(element, options = linkifyMatrix.options) {
  * @param {object} [options] Options for linkifyString. Default: linkifyMatrix.options
  * @returns {string}
  */
-export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.options) {
+export function linkifyAndSanitizeHtml(dirtyHtml: string, options = linkifyMatrix.options) {
     return sanitizeHtml(linkifyString(dirtyHtml, options), sanitizeHtmlParams);
 }
 
@@ -504,7 +518,7 @@ export function linkifyAndSanitizeHtml(dirtyHtml, options = linkifyMatrix.option
  * @param {Node} node
  * @returns {bool}
  */
-export function checkBlockNode(node) {
+export function checkBlockNode(node: Node) {
     switch (node.nodeName) {
         case "H1":
         case "H2":
diff --git a/src/PlatformPeg.js b/src/PlatformPeg.ts
similarity index 80%
rename from src/PlatformPeg.js
rename to src/PlatformPeg.ts
index 34131fde7d..1d2b813ebc 100644
--- a/src/PlatformPeg.js
+++ b/src/PlatformPeg.ts
@@ -1,5 +1,6 @@
 /*
 Copyright 2016 OpenMarket Ltd
+Copyright 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -14,6 +15,8 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+import BasePlatform from "./BasePlatform";
+
 /*
  * Holds the current Platform object used by the code to do anything
  * specific to the platform we're running on (eg. web, electron)
@@ -21,10 +24,8 @@ limitations under the License.
  * This allows the app layer to set a Platform without necessarily
  * having to have a MatrixChat object
  */
-class PlatformPeg {
-    constructor() {
-        this.platform = null;
-    }
+export class PlatformPeg {
+    platform: BasePlatform = null;
 
     /**
      * Returns the current Platform object for the application.
@@ -39,12 +40,12 @@ class PlatformPeg {
      * application.
      * This should be an instance of a class extending BasePlatform.
      */
-    set(plaf) {
+    set(plaf: BasePlatform) {
         this.platform = plaf;
     }
 }
 
-if (!global.mxPlatformPeg) {
-    global.mxPlatformPeg = new PlatformPeg();
+if (!window.mxPlatformPeg) {
+    window.mxPlatformPeg = new PlatformPeg();
 }
-export default global.mxPlatformPeg;
+export default window.mxPlatformPeg;
diff --git a/src/RoomNotifsTypes.ts b/src/RoomNotifsTypes.ts
new file mode 100644
index 0000000000..0e7093e434
--- /dev/null
+++ b/src/RoomNotifsTypes.ts
@@ -0,0 +1,24 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import {
+    ALL_MESSAGES,
+    ALL_MESSAGES_LOUD,
+    MENTIONS_ONLY,
+    MUTE,
+} from "./RoomNotifs";
+
+export type Volume = ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE;
diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx
index f667c47b3c..11c955749d 100644
--- a/src/SlashCommands.tsx
+++ b/src/SlashCommands.tsx
@@ -660,7 +660,7 @@ export const Commands = [
             if (args) {
                 const cli = MatrixClientPeg.get();
 
-                const matches = args.match(/^(\S+)$/);
+                const matches = args.match(/^(@[^:]+:\S+)$/);
                 if (matches) {
                     const userId = matches[1];
                     const ignoredUsers = cli.getIgnoredUsers();
@@ -690,7 +690,7 @@ export const Commands = [
             if (args) {
                 const cli = MatrixClientPeg.get();
 
-                const matches = args.match(/^(\S+)$/);
+                const matches = args.match(/(^@[^:]+:\S+$)/);
                 if (matches) {
                     const userId = matches[1];
                     const ignoredUsers = cli.getIgnoredUsers();
diff --git a/src/accessibility/RovingTabIndex.js b/src/accessibility/RovingTabIndex.tsx
similarity index 75%
rename from src/accessibility/RovingTabIndex.js
rename to src/accessibility/RovingTabIndex.tsx
index b481f08fe2..388d67d9f3 100644
--- a/src/accessibility/RovingTabIndex.js
+++ b/src/accessibility/RovingTabIndex.tsx
@@ -22,9 +22,13 @@ import React, {
     useMemo,
     useRef,
     useReducer,
+    Reducer,
+    RefObject,
+    Dispatch,
 } from "react";
-import PropTypes from "prop-types";
+
 import {Key} from "../Keyboard";
+import AccessibleButton from "../components/views/elements/AccessibleButton";
 
 /**
  * Module to simplify implementing the Roving TabIndex accessibility technique
@@ -41,7 +45,19 @@ import {Key} from "../Keyboard";
 
 const DOCUMENT_POSITION_PRECEDING = 2;
 
-const RovingTabIndexContext = createContext({
+type Ref = RefObject<HTMLElement>;
+
+interface IState {
+    activeRef: Ref;
+    refs: Ref[];
+}
+
+interface IContext {
+    state: IState;
+    dispatch: Dispatch<IAction>;
+}
+
+const RovingTabIndexContext = createContext<IContext>({
     state: {
         activeRef: null,
         refs: [], // list of refs in DOM order
@@ -50,16 +66,22 @@ const RovingTabIndexContext = createContext({
 });
 RovingTabIndexContext.displayName = "RovingTabIndexContext";
 
-// TODO use a TypeScript type here
-const types = {
-    REGISTER: "REGISTER",
-    UNREGISTER: "UNREGISTER",
-    SET_FOCUS: "SET_FOCUS",
-};
+enum Type {
+    Register = "REGISTER",
+    Unregister = "UNREGISTER",
+    SetFocus = "SET_FOCUS",
+}
 
-const reducer = (state, action) => {
+interface IAction {
+    type: Type;
+    payload: {
+        ref: Ref;
+    };
+}
+
+const reducer = (state: IState, action: IAction) => {
     switch (action.type) {
-        case types.REGISTER: {
+        case Type.Register: {
             if (state.refs.length === 0) {
                 // Our list of refs was empty, set activeRef to this first item
                 return {
@@ -92,7 +114,7 @@ const reducer = (state, action) => {
                 ],
             };
         }
-        case types.UNREGISTER: {
+        case Type.Unregister: {
             // filter out the ref which we are removing
             const refs = state.refs.filter(r => r !== action.payload.ref);
 
@@ -117,7 +139,7 @@ const reducer = (state, action) => {
                 refs,
             };
         }
-        case types.SET_FOCUS: {
+        case Type.SetFocus: {
             // update active ref
             return {
                 ...state,
@@ -129,13 +151,21 @@ const reducer = (state, action) => {
     }
 };
 
-export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) => {
-    const [state, dispatch] = useReducer(reducer, {
+interface IProps {
+    handleHomeEnd?: boolean;
+    children(renderProps: {
+        onKeyDownHandler(ev: React.KeyboardEvent);
+    });
+    onKeyDown?(ev: React.KeyboardEvent);
+}
+
+export const RovingTabIndexProvider: React.FC<IProps> = ({children, handleHomeEnd, onKeyDown}) => {
+    const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
         activeRef: null,
         refs: [],
     });
 
-    const context = useMemo(() => ({state, dispatch}), [state]);
+    const context = useMemo<IContext>(() => ({state, dispatch}), [state]);
 
     const onKeyDownHandler = useCallback((ev) => {
         let handled = false;
@@ -171,19 +201,17 @@ export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) =>
         { children({onKeyDownHandler}) }
     </RovingTabIndexContext.Provider>;
 };
-RovingTabIndexProvider.propTypes = {
-    handleHomeEnd: PropTypes.bool,
-    onKeyDown: PropTypes.func,
-};
+
+type FocusHandler = () => void;
 
 // Hook to register a roving tab index
 // inputRef parameter specifies the ref to use
 // onFocus should be called when the index gained focus in any manner
 // isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
 // ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
-export const useRovingTabIndex = (inputRef) => {
+export const useRovingTabIndex = (inputRef: Ref): [FocusHandler, boolean, Ref] => {
     const context = useContext(RovingTabIndexContext);
-    let ref = useRef(null);
+    let ref = useRef<HTMLElement>(null);
 
     if (inputRef) {
         // if we are given a ref, use it instead of ours
@@ -193,13 +221,13 @@ export const useRovingTabIndex = (inputRef) => {
     // setup (after refs)
     useLayoutEffect(() => {
         context.dispatch({
-            type: types.REGISTER,
+            type: Type.Register,
             payload: {ref},
         });
         // teardown
         return () => {
             context.dispatch({
-                type: types.UNREGISTER,
+                type: Type.Unregister,
                 payload: {ref},
             });
         };
@@ -207,7 +235,7 @@ export const useRovingTabIndex = (inputRef) => {
 
     const onFocus = useCallback(() => {
         context.dispatch({
-            type: types.SET_FOCUS,
+            type: Type.SetFocus,
             payload: {ref},
         });
     }, [ref, context]);
@@ -216,9 +244,28 @@ export const useRovingTabIndex = (inputRef) => {
     return [onFocus, isActive, ref];
 };
 
+interface IRovingTabIndexWrapperProps {
+    inputRef?: Ref;
+    children(renderProps: {
+        onFocus: FocusHandler;
+        isActive: boolean;
+        ref: Ref;
+    });
+}
+
 // Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
-export const RovingTabIndexWrapper = ({children, inputRef}) => {
+export const RovingTabIndexWrapper: React.FC<IRovingTabIndexWrapperProps> = ({children, inputRef}) => {
     const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
     return children({onFocus, isActive, ref});
 };
 
+interface IRovingAccessibleButtonProps extends React.ComponentProps<typeof AccessibleButton> {
+    inputRef?: Ref;
+}
+
+// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
+export const RovingAccessibleButton: React.FC<IRovingAccessibleButtonProps> = ({inputRef, ...props}) => {
+    const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
+    return <AccessibleButton {...props} onFocus={onFocus} inputRef={ref} tabIndex={isActive ? 0 : -1} />;
+};
+
diff --git a/src/accessibility/context_menu/ContextMenuButton.tsx b/src/accessibility/context_menu/ContextMenuButton.tsx
new file mode 100644
index 0000000000..c358155e10
--- /dev/null
+++ b/src/accessibility/context_menu/ContextMenuButton.tsx
@@ -0,0 +1,51 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+
+import AccessibleButton, {IProps as IAccessibleButtonProps} from "../../components/views/elements/AccessibleButton";
+
+interface IProps extends IAccessibleButtonProps {
+    label?: string;
+    // whether or not the context menu is currently open
+    isExpanded: boolean;
+}
+
+// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
+export const ContextMenuButton: React.FC<IProps> = ({
+    label,
+    isExpanded,
+    children,
+    onClick,
+    onContextMenu,
+    ...props
+}) => {
+    return (
+        <AccessibleButton
+            {...props}
+            onClick={onClick}
+            onContextMenu={onContextMenu || onClick}
+            title={label}
+            aria-label={label}
+            aria-haspopup={true}
+            aria-expanded={isExpanded}
+        >
+            { children }
+        </AccessibleButton>
+    );
+};
diff --git a/src/accessibility/context_menu/MenuGroup.tsx b/src/accessibility/context_menu/MenuGroup.tsx
new file mode 100644
index 0000000000..9334e17a18
--- /dev/null
+++ b/src/accessibility/context_menu/MenuGroup.tsx
@@ -0,0 +1,30 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+
+interface IProps extends React.HTMLAttributes<HTMLDivElement> {
+    label: string;
+}
+
+// Semantic component for representing a role=group for grouping menu radios/checkboxes
+export const MenuGroup: React.FC<IProps> = ({children, label, ...props}) => {
+    return <div {...props} role="group" aria-label={label}>
+        { children }
+    </div>;
+};
diff --git a/src/accessibility/context_menu/MenuItem.tsx b/src/accessibility/context_menu/MenuItem.tsx
new file mode 100644
index 0000000000..64233e51ad
--- /dev/null
+++ b/src/accessibility/context_menu/MenuItem.tsx
@@ -0,0 +1,35 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+
+import AccessibleButton from "../../components/views/elements/AccessibleButton";
+
+interface IProps extends React.ComponentProps<typeof AccessibleButton> {
+    label?: string;
+}
+
+// Semantic component for representing a role=menuitem
+export const MenuItem: React.FC<IProps> = ({children, label, ...props}) => {
+    return (
+        <AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={label}>
+            { children }
+        </AccessibleButton>
+    );
+};
+
diff --git a/src/accessibility/context_menu/MenuItemCheckbox.tsx b/src/accessibility/context_menu/MenuItemCheckbox.tsx
new file mode 100644
index 0000000000..5eb8cc4819
--- /dev/null
+++ b/src/accessibility/context_menu/MenuItemCheckbox.tsx
@@ -0,0 +1,43 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+
+import AccessibleButton from "../../components/views/elements/AccessibleButton";
+
+interface IProps extends React.ComponentProps<typeof AccessibleButton> {
+    label?: string;
+    active: boolean;
+}
+
+// Semantic component for representing a role=menuitemcheckbox
+export const MenuItemCheckbox: React.FC<IProps> = ({children, label, active, disabled, ...props}) => {
+    return (
+        <AccessibleButton
+            {...props}
+            role="menuitemcheckbox"
+            aria-checked={active}
+            aria-disabled={disabled}
+            disabled={disabled}
+            tabIndex={-1}
+            aria-label={label}
+        >
+            { children }
+        </AccessibleButton>
+    );
+};
diff --git a/src/accessibility/context_menu/MenuItemRadio.tsx b/src/accessibility/context_menu/MenuItemRadio.tsx
new file mode 100644
index 0000000000..472f13ff14
--- /dev/null
+++ b/src/accessibility/context_menu/MenuItemRadio.tsx
@@ -0,0 +1,43 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+
+import AccessibleButton from "../../components/views/elements/AccessibleButton";
+
+interface IProps extends React.ComponentProps<typeof AccessibleButton> {
+    label?: string;
+    active: boolean;
+}
+
+// Semantic component for representing a role=menuitemradio
+export const MenuItemRadio: React.FC<IProps> = ({children, label, active, disabled, ...props}) => {
+    return (
+        <AccessibleButton
+            {...props}
+            role="menuitemradio"
+            aria-checked={active}
+            aria-disabled={disabled}
+            disabled={disabled}
+            tabIndex={-1}
+            aria-label={label}
+        >
+            { children }
+        </AccessibleButton>
+    );
+};
diff --git a/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx b/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx
new file mode 100644
index 0000000000..d373f892c9
--- /dev/null
+++ b/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx
@@ -0,0 +1,64 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+
+import {Key} from "../../Keyboard";
+import StyledCheckbox from "../../components/views/elements/StyledCheckbox";
+
+interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
+    label?: string;
+    onChange(); // we handle keyup/down ourselves so lose the ChangeEvent
+    onClose(): void; // gets called after onChange on Key.ENTER
+}
+
+// Semantic component for representing a styled role=menuitemcheckbox
+export const StyledMenuItemCheckbox: React.FC<IProps> = ({children, label, onChange, onClose, ...props}) => {
+    const onKeyDown = (e: React.KeyboardEvent) => {
+        if (e.key === Key.ENTER || e.key === Key.SPACE) {
+            e.stopPropagation();
+            e.preventDefault();
+            onChange();
+            // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
+            if (e.key === Key.ENTER) {
+                onClose();
+            }
+        }
+    };
+    const onKeyUp = (e: React.KeyboardEvent) => {
+        // prevent the input default handler as we handle it on keydown to match
+        // https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
+        if (e.key === Key.SPACE || e.key === Key.ENTER) {
+            e.stopPropagation();
+            e.preventDefault();
+        }
+    };
+    return (
+        <StyledCheckbox
+            {...props}
+            role="menuitemcheckbox"
+            tabIndex={-1}
+            aria-label={label}
+            onChange={onChange}
+            onKeyDown={onKeyDown}
+            onKeyUp={onKeyUp}
+        >
+            { children }
+        </StyledCheckbox>
+    );
+};
diff --git a/src/accessibility/context_menu/StyledMenuItemRadio.tsx b/src/accessibility/context_menu/StyledMenuItemRadio.tsx
new file mode 100644
index 0000000000..5e5aa90a38
--- /dev/null
+++ b/src/accessibility/context_menu/StyledMenuItemRadio.tsx
@@ -0,0 +1,64 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 New Vector Ltd
+Copyright 2019 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+
+import {Key} from "../../Keyboard";
+import StyledRadioButton from "../../components/views/elements/StyledRadioButton";
+
+interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
+    label?: string;
+    onChange(); // we handle keyup/down ourselves so lose the ChangeEvent
+    onClose(): void; // gets called after onChange on Key.ENTER
+}
+
+// Semantic component for representing a styled role=menuitemradio
+export const StyledMenuItemRadio: React.FC<IProps> = ({children, label, onChange, onClose, ...props}) => {
+    const onKeyDown = (e: React.KeyboardEvent) => {
+        if (e.key === Key.ENTER || e.key === Key.SPACE) {
+            e.stopPropagation();
+            e.preventDefault();
+            onChange();
+            // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
+            if (e.key === Key.ENTER) {
+                onClose();
+            }
+        }
+    };
+    const onKeyUp = (e: React.KeyboardEvent) => {
+        // prevent the input default handler as we handle it on keydown to match
+        // https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
+        if (e.key === Key.SPACE || e.key === Key.ENTER) {
+            e.stopPropagation();
+            e.preventDefault();
+        }
+    };
+    return (
+        <StyledRadioButton
+            {...props}
+            role="menuitemradio"
+            tabIndex={-1}
+            aria-label={label}
+            onChange={onChange}
+            onKeyDown={onKeyDown}
+            onKeyUp={onKeyUp}
+        >
+            { children }
+        </StyledRadioButton>
+    );
+};
diff --git a/src/components/structures/ContextMenu.js b/src/components/structures/ContextMenu.tsx
similarity index 64%
rename from src/components/structures/ContextMenu.js
rename to src/components/structures/ContextMenu.tsx
index e43b0d1431..cb1349da4b 100644
--- a/src/components/structures/ContextMenu.js
+++ b/src/components/structures/ContextMenu.tsx
@@ -16,13 +16,12 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React, {useRef, useState} from 'react';
-import ReactDOM from 'react-dom';
-import PropTypes from 'prop-types';
-import classNames from 'classnames';
+import React, {CSSProperties, useRef, useState} from "react";
+import ReactDOM from "react-dom";
+import classNames from "classnames";
+
 import {Key} from "../../Keyboard";
-import * as sdk from "../../index";
-import AccessibleButton from "../views/elements/AccessibleButton";
+import {Writeable} from "../../@types/common";
 
 // Shamelessly ripped off Modal.js.  There's probably a better way
 // of doing reusable widgets like dialog boxes & menus where we go and
@@ -30,8 +29,8 @@ import AccessibleButton from "../views/elements/AccessibleButton";
 
 const ContextualMenuContainerId = "mx_ContextualMenu_Container";
 
-function getOrCreateContainer() {
-    let container = document.getElementById(ContextualMenuContainerId);
+function getOrCreateContainer(): HTMLDivElement {
+    let container = document.getElementById(ContextualMenuContainerId) as HTMLDivElement;
 
     if (!container) {
         container = document.createElement("div");
@@ -43,50 +42,70 @@ function getOrCreateContainer() {
 }
 
 const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
+
+interface IPosition {
+    top?: number;
+    bottom?: number;
+    left?: number;
+    right?: number;
+}
+
+export enum ChevronFace {
+    Top = "top",
+    Bottom = "bottom",
+    Left = "left",
+    Right = "right",
+    None = "none",
+}
+
+interface IProps extends IPosition {
+    menuWidth?: number;
+    menuHeight?: number;
+
+    chevronOffset?: number;
+    chevronFace?: ChevronFace;
+
+    menuPaddingTop?: number;
+    menuPaddingBottom?: number;
+    menuPaddingLeft?: number;
+    menuPaddingRight?: number;
+
+    zIndex?: number;
+
+    // If true, insert an invisible screen-sized element behind the menu that when clicked will close it.
+    hasBackground?: boolean;
+    // whether this context menu should be focus managed. If false it must handle itself
+    managed?: boolean;
+
+    // Function to be called on menu close
+    onFinished();
+    // on resize callback
+    windowResize?();
+}
+
+interface IState {
+    contextMenuElem: HTMLDivElement;
+}
+
 // Generic ContextMenu Portal wrapper
 // all options inside the menu should be of role=menuitem/menuitemcheckbox/menuitemradiobutton and have tabIndex={-1}
 // this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
-export class ContextMenu extends React.Component {
-    static propTypes = {
-        top: PropTypes.number,
-        bottom: PropTypes.number,
-        left: PropTypes.number,
-        right: PropTypes.number,
-        menuWidth: PropTypes.number,
-        menuHeight: PropTypes.number,
-        chevronOffset: PropTypes.number,
-        chevronFace: PropTypes.string, // top, bottom, left, right or none
-        // Function to be called on menu close
-        onFinished: PropTypes.func.isRequired,
-        menuPaddingTop: PropTypes.number,
-        menuPaddingRight: PropTypes.number,
-        menuPaddingBottom: PropTypes.number,
-        menuPaddingLeft: PropTypes.number,
-        zIndex: PropTypes.number,
-
-        // If true, insert an invisible screen-sized element behind the
-        // menu that when clicked will close it.
-        hasBackground: PropTypes.bool,
-
-        // on resize callback
-        windowResize: PropTypes.func,
-
-        managed: PropTypes.bool, // whether this context menu should be focus managed. If false it must handle itself
-    };
+export class ContextMenu extends React.PureComponent<IProps, IState> {
+    private initialFocus: HTMLElement;
 
     static defaultProps = {
         hasBackground: true,
         managed: true,
     };
 
-    constructor() {
-        super();
+    constructor(props, context) {
+        super(props, context);
         this.state = {
             contextMenuElem: null,
         };
 
         // persist what had focus when we got initialized so we can return it after
-        this.initialFocus = document.activeElement;
+        this.initialFocus = document.activeElement as HTMLElement;
     }
 
     componentWillUnmount() {
@@ -94,7 +113,7 @@ export class ContextMenu extends React.Component {
         this.initialFocus.focus();
     }
 
-    collectContextMenuRect = (element) => {
+    private collectContextMenuRect = (element) => {
         // We don't need to clean up when unmounting, so ignore
         if (!element) return;
 
@@ -111,7 +130,7 @@ export class ContextMenu extends React.Component {
         });
     };
 
-    onContextMenu = (e) => {
+    private onContextMenu = (e) => {
         if (this.props.onFinished) {
             this.props.onFinished();
 
@@ -134,20 +153,20 @@ export class ContextMenu extends React.Component {
         }
     };
 
-    onContextMenuPreventBubbling = (e) => {
+    private onContextMenuPreventBubbling = (e) => {
         // stop propagation so that any context menu handlers don't leak out of this context menu
         // but do not inhibit the default browser menu
         e.stopPropagation();
     };
 
     // Prevent clicks on the background from going through to the component which opened the menu.
-    _onFinished = (ev: InputEvent) => {
+    private onFinished = (ev: React.MouseEvent) => {
         ev.stopPropagation();
         ev.preventDefault();
         if (this.props.onFinished) this.props.onFinished();
     };
 
-    _onMoveFocus = (element, up) => {
+    private onMoveFocus = (element: Element, up: boolean) => {
         let descending = false; // are we currently descending or ascending through the DOM tree?
 
         do {
@@ -181,25 +200,25 @@ export class ContextMenu extends React.Component {
         } while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role")));
 
         if (element) {
-            element.focus();
+            (element as HTMLElement).focus();
         }
     };
 
-    _onMoveFocusHomeEnd = (element, up) => {
+    private onMoveFocusHomeEnd = (element: Element, up: boolean) => {
         let results = element.querySelectorAll('[role^="menuitem"]');
         if (!results) {
             results = element.querySelectorAll('[tab-index]');
         }
         if (results && results.length) {
             if (up) {
-                results[0].focus();
+                (results[0] as HTMLElement).focus();
             } else {
-                results[results.length - 1].focus();
+                (results[results.length - 1] as HTMLElement).focus();
             }
         }
     };
 
-    _onKeyDown = (ev) => {
+    private onKeyDown = (ev: React.KeyboardEvent) => {
         if (!this.props.managed) {
             if (ev.key === Key.ESCAPE) {
                 this.props.onFinished();
@@ -217,16 +236,16 @@ export class ContextMenu extends React.Component {
                 this.props.onFinished();
                 break;
             case Key.ARROW_UP:
-                this._onMoveFocus(ev.target, true);
+                this.onMoveFocus(ev.target as Element, true);
                 break;
             case Key.ARROW_DOWN:
-                this._onMoveFocus(ev.target, false);
+                this.onMoveFocus(ev.target as Element, false);
                 break;
             case Key.HOME:
-                this._onMoveFocusHomeEnd(this.state.contextMenuElem, true);
+                this.onMoveFocusHomeEnd(this.state.contextMenuElem, true);
                 break;
             case Key.END:
-                this._onMoveFocusHomeEnd(this.state.contextMenuElem, false);
+                this.onMoveFocusHomeEnd(this.state.contextMenuElem, false);
                 break;
             default:
                 handled = false;
@@ -239,9 +258,8 @@ export class ContextMenu extends React.Component {
         }
     };
 
-    renderMenu(hasBackground=this.props.hasBackground) {
-        const position = {};
-        let chevronFace = null;
+    protected renderMenu(hasBackground = this.props.hasBackground) {
+        const position: Partial<Writeable<DOMRect>> = {};
         const props = this.props;
 
         if (props.top) {
@@ -250,23 +268,24 @@ export class ContextMenu extends React.Component {
             position.bottom = props.bottom;
         }
 
+        let chevronFace: ChevronFace;
         if (props.left) {
             position.left = props.left;
-            chevronFace = 'left';
+            chevronFace = ChevronFace.Left;
         } else {
             position.right = props.right;
-            chevronFace = 'right';
+            chevronFace = ChevronFace.Right;
         }
 
         const contextMenuRect = this.state.contextMenuElem ? this.state.contextMenuElem.getBoundingClientRect() : null;
 
-        const chevronOffset = {};
+        const chevronOffset: CSSProperties = {};
         if (props.chevronFace) {
             chevronFace = props.chevronFace;
         }
-        const hasChevron = chevronFace && chevronFace !== "none";
+        const hasChevron = chevronFace && chevronFace !== ChevronFace.None;
 
-        if (chevronFace === 'top' || chevronFace === 'bottom') {
+        if (chevronFace === ChevronFace.Top || chevronFace === ChevronFace.Bottom) {
             chevronOffset.left = props.chevronOffset;
         } else if (position.top !== undefined) {
             const target = position.top;
@@ -296,13 +315,13 @@ export class ContextMenu extends React.Component {
             'mx_ContextualMenu_right': !hasChevron && position.right,
             'mx_ContextualMenu_top': !hasChevron && position.top,
             'mx_ContextualMenu_bottom': !hasChevron && position.bottom,
-            'mx_ContextualMenu_withChevron_left': chevronFace === 'left',
-            'mx_ContextualMenu_withChevron_right': chevronFace === 'right',
-            'mx_ContextualMenu_withChevron_top': chevronFace === 'top',
-            'mx_ContextualMenu_withChevron_bottom': chevronFace === 'bottom',
+            'mx_ContextualMenu_withChevron_left': chevronFace === ChevronFace.Left,
+            'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
+            'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
+            'mx_ContextualMenu_withChevron_bottom': chevronFace === ChevronFace.Bottom,
         });
 
-        const menuStyle = {};
+        const menuStyle: CSSProperties = {};
         if (props.menuWidth) {
             menuStyle.width = props.menuWidth;
         }
@@ -333,13 +352,28 @@ export class ContextMenu extends React.Component {
         let background;
         if (hasBackground) {
             background = (
-                <div className="mx_ContextualMenu_background" style={wrapperStyle} onClick={this._onFinished} onContextMenu={this.onContextMenu} />
+                <div
+                    className="mx_ContextualMenu_background"
+                    style={wrapperStyle}
+                    onClick={this.onFinished}
+                    onContextMenu={this.onContextMenu}
+                />
             );
         }
 
         return (
-            <div className="mx_ContextualMenu_wrapper" style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown} onContextMenu={this.onContextMenuPreventBubbling}>
-                <div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} role={this.props.managed ? "menu" : undefined}>
+            <div
+                className="mx_ContextualMenu_wrapper"
+                style={{...position, ...wrapperStyle}}
+                onKeyDown={this.onKeyDown}
+                onContextMenu={this.onContextMenuPreventBubbling}
+            >
+                <div
+                    className={menuClasses}
+                    style={menuStyle}
+                    ref={this.collectContextMenuRect}
+                    role={this.props.managed ? "menu" : undefined}
+                >
                     { chevron }
                     { props.children }
                 </div>
@@ -348,99 +382,13 @@ export class ContextMenu extends React.Component {
         );
     }
 
-    render() {
+    render(): React.ReactChild {
         return ReactDOM.createPortal(this.renderMenu(), getOrCreateContainer());
     }
 }
 
-// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
-export const ContextMenuButton = ({ label, isExpanded, children, onClick, onContextMenu, ...props }) => {
-    const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
-    return (
-        <AccessibleButton
-            {...props}
-            onClick={onClick}
-            onContextMenu={onContextMenu || onClick}
-            title={label}
-            aria-label={label}
-            aria-haspopup={true}
-            aria-expanded={isExpanded}
-        >
-            { children }
-        </AccessibleButton>
-    );
-};
-ContextMenuButton.propTypes = {
-    ...AccessibleButton.propTypes,
-    label: PropTypes.string,
-    isExpanded: PropTypes.bool.isRequired, // whether or not the context menu is currently open
-};
-
-// Semantic component for representing a role=menuitem
-export const MenuItem = ({children, label, ...props}) => {
-    const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
-    return (
-        <AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={label}>
-            { children }
-        </AccessibleButton>
-    );
-};
-MenuItem.propTypes = {
-    ...AccessibleButton.propTypes,
-    label: PropTypes.string, // optional
-    className: PropTypes.string, // optional
-    onClick: PropTypes.func.isRequired,
-};
-
-// Semantic component for representing a role=group for grouping menu radios/checkboxes
-export const MenuGroup = ({children, label, ...props}) => {
-    return <div {...props} role="group" aria-label={label}>
-        { children }
-    </div>;
-};
-MenuGroup.propTypes = {
-    label: PropTypes.string.isRequired,
-    className: PropTypes.string, // optional
-};
-
-// Semantic component for representing a role=menuitemcheckbox
-export const MenuItemCheckbox = ({children, label, active=false, disabled=false, ...props}) => {
-    const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
-    return (
-        <AccessibleButton {...props} role="menuitemcheckbox" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
-            { children }
-        </AccessibleButton>
-    );
-};
-MenuItemCheckbox.propTypes = {
-    ...AccessibleButton.propTypes,
-    label: PropTypes.string, // optional
-    active: PropTypes.bool.isRequired,
-    disabled: PropTypes.bool, // optional
-    className: PropTypes.string, // optional
-    onClick: PropTypes.func.isRequired,
-};
-
-// Semantic component for representing a role=menuitemradio
-export const MenuItemRadio = ({children, label, active=false, disabled=false, ...props}) => {
-    const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
-    return (
-        <AccessibleButton {...props} role="menuitemradio" aria-checked={active} aria-disabled={disabled} tabIndex={-1} aria-label={label}>
-            { children }
-        </AccessibleButton>
-    );
-};
-MenuItemRadio.propTypes = {
-    ...AccessibleButton.propTypes,
-    label: PropTypes.string, // optional
-    active: PropTypes.bool.isRequired,
-    disabled: PropTypes.bool, // optional
-    className: PropTypes.string, // optional
-    onClick: PropTypes.func.isRequired,
-};
-
 // Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
-export const toRightOf = (elementRect, chevronOffset=12) => {
+export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => {
     const left = elementRect.right + window.pageXOffset + 3;
     let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
     top -= chevronOffset + 8; // where 8 is half the height of the chevron
@@ -448,8 +396,8 @@ export const toRightOf = (elementRect, chevronOffset=12) => {
 };
 
 // Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect
-export const aboveLeftOf = (elementRect, chevronFace="none") => {
-    const menuOptions = { chevronFace };
+export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None) => {
+    const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
 
     const buttonRight = elementRect.right + window.pageXOffset;
     const buttonBottom = elementRect.bottom + window.pageYOffset;
@@ -507,3 +455,12 @@ export function createMenu(ElementClass, props) {
 
     return {close: onFinished};
 }
+
+// re-export the semantic helper components for simplicity
+export {ContextMenuButton} from "../../accessibility/context_menu/ContextMenuButton";
+export {MenuGroup} from "../../accessibility/context_menu/MenuGroup";
+export {MenuItem} from "../../accessibility/context_menu/MenuItem";
+export {MenuItemCheckbox} from "../../accessibility/context_menu/MenuItemCheckbox";
+export {MenuItemRadio} from "../../accessibility/context_menu/MenuItemRadio";
+export {StyledMenuItemCheckbox} from "../../accessibility/context_menu/StyledMenuItemCheckbox";
+export {StyledMenuItemRadio} from "../../accessibility/context_menu/StyledMenuItemRadio";
diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx
index 23a9e74646..3c8994b1c0 100644
--- a/src/components/structures/LeftPanel2.tsx
+++ b/src/components/structures/LeftPanel2.tsx
@@ -21,6 +21,7 @@ import classNames from "classnames";
 import dis from "../../dispatcher/dispatcher";
 import { _t } from "../../languageHandler";
 import RoomList2 from "../views/rooms/RoomList2";
+import { HEADER_HEIGHT } from "../views/rooms/RoomSublist2";
 import { Action } from "../../dispatcher/actions";
 import UserMenu from "./UserMenu";
 import RoomSearch from "./RoomSearch";
@@ -32,9 +33,10 @@ import ResizeNotifier from "../../utils/ResizeNotifier";
 import SettingsStore from "../../settings/SettingsStore";
 import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore2";
 import {Key} from "../../Keyboard";
+import IndicatorScrollbar from "../structures/IndicatorScrollbar";
 
-// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
-// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14367
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
 
 /*******************************************************************
  *   CAUTION                                                       *
@@ -55,12 +57,20 @@ interface IState {
     showTagPanel: boolean;
 }
 
+// List of CSS classes which should be included in keyboard navigation within the room list
+const cssClasses = [
+    "mx_RoomSearch_input",
+    "mx_RoomSearch_icon", // minimized <RoomSearch />
+    "mx_RoomSublist2_headerText",
+    "mx_RoomTile2",
+    "mx_RoomSublist2_showNButton",
+];
+
 export default class LeftPanel2 extends React.Component<IProps, IState> {
     private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
     private tagPanelWatcherRef: string;
     private focusedElement = null;
-
-    // TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
+    private isDoingStickyHeaders = false;
 
     constructor(props: IProps) {
         super(props);
@@ -105,40 +115,131 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
     };
 
     private handleStickyHeaders(list: HTMLDivElement) {
-        const rlRect = list.getBoundingClientRect();
-        const bottom = rlRect.bottom;
-        const top = rlRect.top;
+        if (this.isDoingStickyHeaders) return;
+        this.isDoingStickyHeaders = true;
+        window.requestAnimationFrame(() => {
+            this.doStickyHeaders(list);
+            this.isDoingStickyHeaders = false;
+        });
+    }
+
+    private doStickyHeaders(list: HTMLDivElement) {
+        const topEdge = list.scrollTop;
+        const bottomEdge = list.offsetHeight + list.scrollTop;
         const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist2");
-        const headerHeight = 32; // Note: must match the CSS!
-        const headerRightMargin = 24; // calculated from margins and widths to align with non-sticky tiles
 
-        const headerStickyWidth = rlRect.width - headerRightMargin;
+        const headerRightMargin = 16; // calculated from margins and widths to align with non-sticky tiles
+        const headerStickyWidth = list.clientWidth - headerRightMargin;
 
-        let gotBottom = false;
+        // We track which styles we want on a target before making the changes to avoid
+        // excessive layout updates.
+        const targetStyles = new Map<HTMLDivElement, {
+            stickyTop?: boolean;
+            stickyBottom?: boolean;
+            makeInvisible?: boolean;
+        }>();
+
+        let lastTopHeader;
+        let firstBottomHeader;
         for (const sublist of sublists) {
-            const slRect = sublist.getBoundingClientRect();
-
             const header = sublist.querySelector<HTMLDivElement>(".mx_RoomSublist2_stickable");
+            header.style.removeProperty("display"); // always clear display:none first
 
-            if (slRect.top + headerHeight > bottom && !gotBottom) {
-                header.classList.add("mx_RoomSublist2_headerContainer_sticky");
-                header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom");
-                header.style.width = `${headerStickyWidth}px`;
-                header.style.top = `unset`;
-                gotBottom = true;
-            } else if (slRect.top < top) {
-                header.classList.add("mx_RoomSublist2_headerContainer_sticky");
-                header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
-                header.style.width = `${headerStickyWidth}px`;
-                header.style.top = `${rlRect.top}px`;
+            // When an element is <=40% off screen, make it take over
+            const offScreenFactor = 0.4;
+            const isOffTop = (sublist.offsetTop + (offScreenFactor * HEADER_HEIGHT)) <= topEdge;
+            const isOffBottom = (sublist.offsetTop + (offScreenFactor * HEADER_HEIGHT)) >= bottomEdge;
+
+            if (isOffTop || sublist === sublists[0]) {
+                targetStyles.set(header, { stickyTop: true });
+                if (lastTopHeader) {
+                    lastTopHeader.style.display = "none";
+                    targetStyles.set(lastTopHeader, { makeInvisible: true });
+                }
+                lastTopHeader = header;
+            } else if (isOffBottom && !firstBottomHeader) {
+                targetStyles.set(header, { stickyBottom: true });
+                firstBottomHeader = header;
             } else {
-                header.classList.remove("mx_RoomSublist2_headerContainer_sticky");
-                header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop");
-                header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom");
-                header.style.width = `unset`;
-                header.style.top = `unset`;
+                targetStyles.set(header, {}); // nothing == clear
             }
         }
+
+        // Run over the style changes and make them reality. We check to see if we're about to
+        // cause a no-op update, as adding/removing properties that are/aren't there cause
+        // layout updates.
+        for (const header of targetStyles.keys()) {
+            const style = targetStyles.get(header);
+            const headerContainer = header.parentElement; // .mx_RoomSublist2_headerContainer
+
+            if (style.makeInvisible) {
+                // we will have already removed the 'display: none', so add it back.
+                header.style.display = "none";
+                continue; // nothing else to do, even if sticky somehow
+            }
+
+            if (style.stickyTop) {
+                if (!header.classList.contains("mx_RoomSublist2_headerContainer_stickyTop")) {
+                    header.classList.add("mx_RoomSublist2_headerContainer_stickyTop");
+                }
+
+                const newTop = `${list.parentElement.offsetTop}px`;
+                if (header.style.top !== newTop) {
+                    header.style.top = newTop;
+                }
+            } else if (style.stickyBottom) {
+                if (!header.classList.contains("mx_RoomSublist2_headerContainer_stickyBottom")) {
+                    header.classList.add("mx_RoomSublist2_headerContainer_stickyBottom");
+                }
+            }
+
+            if (style.stickyTop || style.stickyBottom) {
+                if (!header.classList.contains("mx_RoomSublist2_headerContainer_sticky")) {
+                    header.classList.add("mx_RoomSublist2_headerContainer_sticky");
+                }
+                if (!headerContainer.classList.contains("mx_RoomSublist2_headerContainer_hasSticky")) {
+                    headerContainer.classList.add("mx_RoomSublist2_headerContainer_hasSticky");
+                }
+
+                const newWidth = `${headerStickyWidth}px`;
+                if (header.style.width !== newWidth) {
+                    header.style.width = newWidth;
+                }
+            } else if (!style.stickyTop && !style.stickyBottom) {
+                if (header.classList.contains("mx_RoomSublist2_headerContainer_sticky")) {
+                    header.classList.remove("mx_RoomSublist2_headerContainer_sticky");
+                }
+                if (header.classList.contains("mx_RoomSublist2_headerContainer_stickyTop")) {
+                    header.classList.remove("mx_RoomSublist2_headerContainer_stickyTop");
+                }
+                if (header.classList.contains("mx_RoomSublist2_headerContainer_stickyBottom")) {
+                    header.classList.remove("mx_RoomSublist2_headerContainer_stickyBottom");
+                }
+                if (headerContainer.classList.contains("mx_RoomSublist2_headerContainer_hasSticky")) {
+                    headerContainer.classList.remove("mx_RoomSublist2_headerContainer_hasSticky");
+                }
+                if (header.style.width) {
+                    header.style.removeProperty('width');
+                }
+                if (header.style.top) {
+                    header.style.removeProperty('top');
+                }
+            }
+        }
+
+        // add appropriate sticky classes to wrapper so it has
+        // the necessary top/bottom padding to put the sticky header in
+        const listWrapper = list.parentElement; // .mx_LeftPanel2_roomListWrapper
+        if (lastTopHeader) {
+            listWrapper.classList.add("mx_LeftPanel2_roomListWrapper_stickyTop");
+        } else {
+            listWrapper.classList.remove("mx_LeftPanel2_roomListWrapper_stickyTop");
+        }
+        if (firstBottomHeader) {
+            listWrapper.classList.add("mx_LeftPanel2_roomListWrapper_stickyBottom");
+        } else {
+            listWrapper.classList.remove("mx_LeftPanel2_roomListWrapper_stickyBottom");
+        }
     }
 
     // TODO: Improve header reliability: https://github.com/vector-im/riot-web/issues/14232
@@ -173,6 +274,14 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
         }
     };
 
+    private onEnter = () => {
+        const firstRoom = this.listContainerRef.current.querySelector<HTMLDivElement>(".mx_RoomTile2");
+        if (firstRoom) {
+            firstRoom.click();
+            this.onSearch(""); // clear the search field
+        }
+    };
+
     private onMoveFocus = (up: boolean) => {
         let element = this.focusedElement;
 
@@ -204,10 +313,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
             if (element) {
                 classes = element.classList;
             }
-        } while (element && !(
-            classes.contains("mx_RoomTile2") ||
-            classes.contains("mx_RoomSublist2_headerText") ||
-            classes.contains("mx_RoomSearch_input")));
+        } while (element && !cssClasses.some(c => classes.contains(c)));
 
         if (element) {
             element.focus();
@@ -217,11 +323,14 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
 
     private renderHeader(): React.ReactNode {
         let breadcrumbs;
-        if (this.state.showBreadcrumbs) {
+        if (this.state.showBreadcrumbs && !this.props.isMinimized) {
             breadcrumbs = (
-                <div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer mx_AutoHideScrollbar">
-                    {this.props.isMinimized ? null : <RoomBreadcrumbs2 />}
-                </div>
+                <IndicatorScrollbar
+                    className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer mx_AutoHideScrollbar"
+                    verticalScrollsHorizontally={true}
+                >
+                    <RoomBreadcrumbs2 />
+                </IndicatorScrollbar>
             );
         }
 
@@ -235,17 +344,22 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
 
     private renderSearchExplore(): React.ReactNode {
         return (
-            <div className="mx_LeftPanel2_filterContainer" onFocus={this.onFocus} onBlur={this.onBlur}>
+            <div
+                className="mx_LeftPanel2_filterContainer"
+                onFocus={this.onFocus}
+                onBlur={this.onBlur}
+                onKeyDown={this.onKeyDown}
+            >
                 <RoomSearch
                     onQueryUpdate={this.onSearch}
                     isMinimized={this.props.isMinimized}
                     onVerticalArrow={this.onKeyDown}
+                    onEnter={this.onEnter}
                 />
                 <AccessibleButton
-                    // TODO fix the accessibility of this: https://github.com/vector-im/riot-web/issues/14180
                     className="mx_LeftPanel2_exploreButton"
                     onClick={this.onExplore}
-                    alt={_t("Explore rooms")}
+                    title={_t("Explore rooms")}
                 />
             </div>
         );
@@ -266,6 +380,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
             onFocus={this.onFocus}
             onBlur={this.onBlur}
             isMinimized={this.props.isMinimized}
+            onResize={this.onResize}
         />;
 
         // TODO: Conference handling / calls: https://github.com/vector-im/riot-web/issues/14177
@@ -287,15 +402,17 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
                 <aside className="mx_LeftPanel2_roomListContainer">
                     {this.renderHeader()}
                     {this.renderSearchExplore()}
-                    <div
-                        className={roomListClasses}
-                        onScroll={this.onScroll}
-                        ref={this.listContainerRef}
-                        // Firefox sometimes makes this element focusable due to
-                        // overflow:scroll;, so force it out of tab order.
-                        tabIndex={-1}
-                    >
-                        {roomList}
+                    <div className="mx_LeftPanel2_roomListWrapper">
+                        <div
+                            className={roomListClasses}
+                            onScroll={this.onScroll}
+                            ref={this.listContainerRef}
+                            // Firefox sometimes makes this element focusable due to
+                            // overflow:scroll;, so force it out of tab order.
+                            tabIndex={-1}
+                        >
+                            {roomList}
+                        </div>
                     </div>
                 </aside>
             </div>
diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx
index 9fbc98dee3..b65f176089 100644
--- a/src/components/structures/LoggedInView.tsx
+++ b/src/components/structures/LoggedInView.tsx
@@ -19,7 +19,6 @@ limitations under the License.
 import * as React from 'react';
 import * as PropTypes from 'prop-types';
 import { MatrixClient } from 'matrix-js-sdk/src/client';
-import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
 import { DragDropContext } from 'react-beautiful-dnd';
 
 import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent} from '../../Keyboard';
@@ -53,6 +52,8 @@ import {
 } from "../../toasts/ServerLimitToast";
 import { Action } from "../../dispatcher/actions";
 import LeftPanel2 from "./LeftPanel2";
+import CallContainer from '../views/voip/CallContainer';
+import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPayload";
 
 // We need to fetch each pinned message individually (if we don't already have it)
 // so each pinned message may trigger a request. Limit the number per room for sanity.
@@ -409,20 +410,6 @@ class LoggedInView extends React.Component<IProps, IState> {
     };
 
     _onKeyDown = (ev) => {
-            /*
-            // Remove this for now as ctrl+alt = alt-gr so this breaks keyboards which rely on alt-gr for numbers
-            // Will need to find a better meta key if anyone actually cares about using this.
-            if (ev.altKey && ev.ctrlKey && ev.keyCode > 48 && ev.keyCode < 58) {
-                dis.dispatch({
-                    action: 'view_indexed_room',
-                    roomIndex: ev.keyCode - 49,
-                });
-                ev.stopPropagation();
-                ev.preventDefault();
-                return;
-            }
-            */
-
         let handled = false;
         const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev);
         const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey;
@@ -474,8 +461,8 @@ class LoggedInView extends React.Component<IProps, IState> {
             case Key.ARROW_UP:
             case Key.ARROW_DOWN:
                 if (ev.altKey && !ev.ctrlKey && !ev.metaKey) {
-                    dis.dispatch({
-                        action: 'view_room_delta',
+                    dis.dispatch<ViewRoomDeltaPayload>({
+                        action: Action.ViewRoomDelta,
                         delta: ev.key === Key.ARROW_UP ? -1 : 1,
                         unread: ev.shiftKey,
                     });
@@ -681,8 +668,7 @@ class LoggedInView extends React.Component<IProps, IState> {
                 disabled={this.props.leftDisabled}
             />
         );
-        if (SettingsStore.isFeatureEnabled("feature_new_room_list")) {
-            // TODO: Supply props like collapsed and disabled to LeftPanel2
+        if (SettingsStore.getValue("feature_new_room_list")) {
             leftPanel = (
                 <LeftPanel2
                     isMinimized={this.props.collapseLhs || false}
@@ -710,6 +696,7 @@ class LoggedInView extends React.Component<IProps, IState> {
                         </div>
                     </DragDropContext>
                 </div>
+                <CallContainer />
             </MatrixClientContext.Provider>
         );
     }
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx
index 315c648e15..89ee1bc22d 100644
--- a/src/components/structures/MatrixChat.tsx
+++ b/src/components/structures/MatrixChat.tsx
@@ -596,15 +596,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
                 }
                 break;
             }
-            case 'view_prev_room':
-                this.viewNextRoom(-1);
-                break;
             case 'view_next_room':
                 this.viewNextRoom(1);
                 break;
-            case 'view_indexed_room':
-                this.viewIndexedRoom(payload.roomIndex);
-                break;
             case Action.ViewUserSettings: {
                 const tabPayload = payload as OpenToTabPayload;
                 const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
@@ -812,19 +806,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
         });
     }
 
-    // TODO: Move to RoomViewStore
-    private viewIndexedRoom(roomIndex: number) {
-        const allRooms = RoomListSorter.mostRecentActivityFirst(
-            MatrixClientPeg.get().getRooms(),
-        );
-        if (allRooms[roomIndex]) {
-            dis.dispatch({
-                action: 'view_room',
-                room_id: allRooms[roomIndex].roomId,
-            });
-        }
-    }
-
     // switch view to the given room
     //
     // @param {Object} roomInfo Object containing data about the room to be joined
diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx
index 7ed2acf276..231bd92ddf 100644
--- a/src/components/structures/RoomSearch.tsx
+++ b/src/components/structures/RoomSearch.tsx
@@ -25,7 +25,7 @@ import { Key } from "../../Keyboard";
 import AccessibleButton from "../views/elements/AccessibleButton";
 import { Action } from "../../dispatcher/actions";
 
-// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
+// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14367
 
 /*******************************************************************
  *   CAUTION                                                       *
@@ -39,6 +39,7 @@ interface IProps {
     onQueryUpdate: (newQuery: string) => void;
     isMinimized: boolean;
     onVerticalArrow(ev: React.KeyboardEvent);
+    onEnter(ev: React.KeyboardEvent);
 }
 
 interface IState {
@@ -81,6 +82,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
 
     private openSearch = () => {
         defaultDispatcher.dispatch({action: "show_left_panel"});
+        defaultDispatcher.dispatch({action: "focus_room_filter"});
     };
 
     private onChange = () => {
@@ -104,7 +106,7 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
         ev.target.select();
     };
 
-    private onBlur = () => {
+    private onBlur = (ev: React.FocusEvent<HTMLInputElement>) => {
         this.setState({focused: false});
     };
 
@@ -114,6 +116,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
             defaultDispatcher.fire(Action.FocusComposer);
         } else if (ev.key === Key.ARROW_UP || ev.key === Key.ARROW_DOWN) {
             this.props.onVerticalArrow(ev);
+        } else if (ev.key === Key.ENTER) {
+            this.props.onEnter(ev);
         }
     };
 
@@ -149,7 +153,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
         let clearButton = (
             <AccessibleButton
                 tabIndex={-1}
-                className='mx_RoomSearch_clearButton'
+                title={_t("Clear filter")}
+                className="mx_RoomSearch_clearButton"
                 onClick={this.clearInput}
             />
         );
@@ -157,8 +162,8 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
         if (this.props.isMinimized) {
             icon = (
                 <AccessibleButton
-                    tabIndex={-1}
-                    className='mx_RoomSearch_icon'
+                    title={_t("Search rooms")}
+                    className="mx_RoomSearch_icon"
                     onClick={this.openSearch}
                 />
             );
diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js
index 519c4c1f8e..a9f75ce632 100644
--- a/src/components/structures/RoomView.js
+++ b/src/components/structures/RoomView.js
@@ -2044,6 +2044,7 @@ export default createReactClass({
         if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) {
             const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton');
             jumpToBottom = (<JumpToBottomButton
+                highlight={this.state.room.getUnreadNotificationCount('highlight') > 0}
                 numUnreadMessages={this.state.numUnreadMessages}
                 onScrollToBottomClick={this.jumpToLiveTimeline}
             />);
diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx
index 5955a046a4..a6eabe25f7 100644
--- a/src/components/structures/UserMenu.tsx
+++ b/src/components/structures/UserMenu.tsx
@@ -14,14 +14,13 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import * as React from "react";
+import React, { createRef } from "react";
 import { MatrixClientPeg } from "../../MatrixClientPeg";
 import defaultDispatcher from "../../dispatcher/dispatcher";
 import { ActionPayload } from "../../dispatcher/payloads";
 import { Action } from "../../dispatcher/actions";
-import { createRef } from "react";
 import { _t } from "../../languageHandler";
-import {ContextMenu, ContextMenuButton, MenuItem} from "./ContextMenu";
+import { ChevronFace, ContextMenu, ContextMenuButton, MenuItem } from "./ContextMenu";
 import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
 import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
 import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog";
@@ -122,7 +121,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
         }
     };
 
-    private onOpenMenuClick = (ev: InputEvent) => {
+    private onOpenMenuClick = (ev: React.MouseEvent) => {
         ev.preventDefault();
         ev.stopPropagation();
         const target = ev.target as HTMLButtonElement;
@@ -235,7 +234,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
 
         return (
             <ContextMenu
-                chevronFace="none"
+                chevronFace={ChevronFace.None}
                 // -20 to overlap the context menu by just over the width of the `...` icon and make it look connected
                 left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 20}
                 top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
@@ -281,11 +280,11 @@ export default class UserMenu extends React.Component<IProps, IState> {
                             label={_t("All settings")}
                             onClick={(e) => this.onSettingsOpen(e, null)}
                         />
-                        <MenuButton
+                        {/* <MenuButton
                             iconClassName="mx_UserMenu_iconArchive"
                             label={_t("Archived rooms")}
                             onClick={this.onShowArchived}
-                        />
+                        /> */}
                         <MenuButton
                             iconClassName="mx_UserMenu_iconMessage"
                             label={_t("Feedback")}
@@ -329,7 +328,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
                     className={classes}
                     onClick={this.onOpenMenuClick}
                     inputRef={this.buttonRef}
-                    label={_t("Account settings")}
+                    label={_t("User menu")}
                     isExpanded={!!this.state.contextMenuPosition}
                     onContextMenu={this.onContextMenu}
                 >
@@ -348,8 +347,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
                         {name}
                         {buttons}
                     </div>
-                    {this.renderContextMenu()}
                 </ContextMenuButton>
+                {this.renderContextMenu()}
             </React.Fragment>
         );
     }
diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.tsx
similarity index 75%
rename from src/components/views/avatars/BaseAvatar.js
rename to src/components/views/avatars/BaseAvatar.tsx
index 508691e5fd..7f30a7a377 100644
--- a/src/components/views/avatars/BaseAvatar.js
+++ b/src/components/views/avatars/BaseAvatar.tsx
@@ -18,7 +18,7 @@ limitations under the License.
 */
 
 import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react';
-import PropTypes from 'prop-types';
+import classNames from 'classnames';
 import * as AvatarLogic from '../../../Avatar';
 import SettingsStore from "../../../settings/SettingsStore";
 import AccessibleButton from '../elements/AccessibleButton';
@@ -26,9 +26,25 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
 import {useEventEmitter} from "../../../hooks/useEventEmitter";
 import {toPx} from "../../../utils/units";
 
-const useImageUrl = ({url, urls}) => {
-    const [imageUrls, setUrls] = useState([]);
-    const [urlsIndex, setIndex] = useState();
+interface IProps {
+    name: string; // The name (first initial used as default)
+    idName?: string; // ID for generating hash colours
+    title?: string; // onHover title text
+    url?: string; // highest priority of them all, shortcut to set in urls[0]
+    urls?: string[]; // [highest_priority, ... , lowest_priority]
+    width?: number;
+    height?: number;
+    // XXX: resizeMethod not actually used.
+    resizeMethod?: string;
+    defaultToInitialLetter?: boolean; // true to add default url
+    onClick?: React.MouseEventHandler;
+    inputRef?: React.RefObject<HTMLImageElement & HTMLSpanElement>;
+    className?: string;
+}
+
+const useImageUrl = ({url, urls}): [string, () => void] => {
+    const [imageUrls, setUrls] = useState<string[]>([]);
+    const [urlsIndex, setIndex] = useState<number>();
 
     const onError = useCallback(() => {
         setIndex(i => i + 1); // try the next one
@@ -70,19 +86,20 @@ const useImageUrl = ({url, urls}) => {
     return [imageUrl, onError];
 };
 
-const BaseAvatar = (props) => {
+const BaseAvatar = (props: IProps) => {
     const {
         name,
         idName,
         title,
         url,
         urls,
-        width=40,
-        height=40,
-        resizeMethod="crop", // eslint-disable-line no-unused-vars
-        defaultToInitialLetter=true,
+        width = 40,
+        height = 40,
+        resizeMethod = "crop", // eslint-disable-line no-unused-vars
+        defaultToInitialLetter = true,
         onClick,
         inputRef,
+        className,
         ...otherProps
     } = props;
 
@@ -117,12 +134,12 @@ const BaseAvatar = (props) => {
                 aria-hidden="true" />
         );
 
-        if (onClick != null) {
+        if (onClick !== null) {
             return (
                 <AccessibleButton
                     {...otherProps}
                     element="span"
-                    className="mx_BaseAvatar"
+                    className={classNames("mx_BaseAvatar", className)}
                     onClick={onClick}
                     inputRef={inputRef}
                 >
@@ -132,7 +149,12 @@ const BaseAvatar = (props) => {
             );
         } else {
             return (
-                <span className="mx_BaseAvatar" ref={inputRef} {...otherProps}>
+                <span
+                    className={classNames("mx_BaseAvatar", className)}
+                    ref={inputRef}
+                    {...otherProps}
+                    role="presentation"
+                >
                     { textNode }
                     { imgNode }
                 </span>
@@ -140,10 +162,10 @@ const BaseAvatar = (props) => {
         }
     }
 
-    if (onClick != null) {
+    if (onClick !== null) {
         return (
             <AccessibleButton
-                className="mx_BaseAvatar mx_BaseAvatar_image"
+                className={classNames("mx_BaseAvatar mx_BaseAvatar_image", className)}
                 element='img'
                 src={imageUrl}
                 onClick={onClick}
@@ -159,7 +181,7 @@ const BaseAvatar = (props) => {
     } else {
         return (
             <img
-                className="mx_BaseAvatar mx_BaseAvatar_image"
+                className={classNames("mx_BaseAvatar mx_BaseAvatar_image", className)}
                 src={imageUrl}
                 onError={onError}
                 style={{
@@ -173,26 +195,5 @@ const BaseAvatar = (props) => {
     }
 };
 
-BaseAvatar.displayName = "BaseAvatar";
-
-BaseAvatar.propTypes = {
-    name: PropTypes.string.isRequired, // The name (first initial used as default)
-    idName: PropTypes.string, // ID for generating hash colours
-    title: PropTypes.string, // onHover title text
-    url: PropTypes.string, // highest priority of them all, shortcut to set in urls[0]
-    urls: PropTypes.array, // [highest_priority, ... , lowest_priority]
-    width: PropTypes.number,
-    height: PropTypes.number,
-    // XXX resizeMethod not actually used.
-    resizeMethod: PropTypes.string,
-    defaultToInitialLetter: PropTypes.bool, // true to add default url
-    onClick: PropTypes.func,
-    inputRef: PropTypes.oneOfType([
-        // Either a function
-        PropTypes.func,
-        // Or the instance of a DOM native element
-        PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
-    ]),
-};
-
 export default BaseAvatar;
+export type BaseAvatarType = React.FC<IProps>;
\ No newline at end of file
diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx
index e0ad3202b8..40ba15af33 100644
--- a/src/components/views/avatars/DecoratedRoomAvatar.tsx
+++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx
@@ -21,8 +21,8 @@ import { TagID } from '../../../stores/room-list/models';
 import RoomAvatar from "./RoomAvatar";
 import RoomTileIcon from "../rooms/RoomTileIcon";
 import NotificationBadge from '../rooms/NotificationBadge';
-import { INotificationState } from "../../../stores/notifications/INotificationState";
-import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState";
+import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
+import { NotificationState } from "../../../stores/notifications/NotificationState";
 
 interface IProps {
     room: Room;
@@ -33,7 +33,7 @@ interface IProps {
 }
 
 interface IState {
-    notificationState?: INotificationState;
+    notificationState?: NotificationState;
 }
 
 export default class DecoratedRoomAvatar extends React.PureComponent<IProps, IState> {
@@ -42,7 +42,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt
         super(props);
 
         this.state = {
-            notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag),
+            notificationState: RoomNotificationStateStore.instance.getRoomState(this.props.room, this.props.tag),
         };
     }
 
diff --git a/src/components/views/avatars/GroupAvatar.js b/src/components/views/avatars/GroupAvatar.tsx
similarity index 64%
rename from src/components/views/avatars/GroupAvatar.js
rename to src/components/views/avatars/GroupAvatar.tsx
index 0da57bcb99..e55e2e6fac 100644
--- a/src/components/views/avatars/GroupAvatar.js
+++ b/src/components/views/avatars/GroupAvatar.tsx
@@ -15,43 +15,36 @@ limitations under the License.
 */
 
 import React from 'react';
-import PropTypes from 'prop-types';
-import createReactClass from 'create-react-class';
-import * as sdk from '../../../index';
 import {MatrixClientPeg} from '../../../MatrixClientPeg';
+import BaseAvatar from './BaseAvatar';
 
-export default createReactClass({
-    displayName: 'GroupAvatar',
+export interface IProps {
+        groupId?: string;
+        groupName?: string;
+        groupAvatarUrl?: string;
+        width?: number;
+        height?: number;
+        resizeMethod?: string;
+        onClick?: React.MouseEventHandler;
+}
 
-    propTypes: {
-        groupId: PropTypes.string,
-        groupName: PropTypes.string,
-        groupAvatarUrl: PropTypes.string,
-        width: PropTypes.number,
-        height: PropTypes.number,
-        resizeMethod: PropTypes.string,
-        onClick: PropTypes.func,
-    },
+export default class GroupAvatar extends React.Component<IProps> {
+    public static defaultProps = {
+        width: 36,
+        height: 36,
+        resizeMethod: 'crop',
+    };
 
-    getDefaultProps: function() {
-        return {
-            width: 36,
-            height: 36,
-            resizeMethod: 'crop',
-        };
-    },
-
-    getGroupAvatarUrl: function() {
+    getGroupAvatarUrl() {
         return MatrixClientPeg.get().mxcUrlToHttp(
             this.props.groupAvatarUrl,
             this.props.width,
             this.props.height,
             this.props.resizeMethod,
         );
-    },
+    }
 
-    render: function() {
-        const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
+    render() {
         // extract the props we use from props so we can pass any others through
         // should consider adding this as a global rule in js-sdk?
         /*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
@@ -65,5 +58,5 @@ export default createReactClass({
                 {...otherProps}
             />
         );
-    },
-});
+    }
+}
diff --git a/src/components/views/avatars/MemberAvatar.js b/src/components/views/avatars/MemberAvatar.tsx
similarity index 64%
rename from src/components/views/avatars/MemberAvatar.js
rename to src/components/views/avatars/MemberAvatar.tsx
index b763129dd8..1d23d85b0f 100644
--- a/src/components/views/avatars/MemberAvatar.js
+++ b/src/components/views/avatars/MemberAvatar.tsx
@@ -16,48 +16,50 @@ limitations under the License.
 */
 
 import React from 'react';
-import PropTypes from 'prop-types';
-import createReactClass from 'create-react-class';
-import * as sdk from "../../../index";
 import dis from "../../../dispatcher/dispatcher";
 import {Action} from "../../../dispatcher/actions";
 import {MatrixClientPeg} from "../../../MatrixClientPeg";
+import BaseAvatar from "./BaseAvatar";
 
-export default createReactClass({
-    displayName: 'MemberAvatar',
+interface IProps {
+    // TODO: replace with correct type
+    member: any;
+    fallbackUserId: string;
+    width: number;
+    height: number;
+    resizeMethod: string;
+    // The onClick to give the avatar
+    onClick: React.MouseEventHandler;
+    // Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser`
+    viewUserOnClick: boolean;
+    title: string;
+}
 
-    propTypes: {
-        member: PropTypes.object,
-        fallbackUserId: PropTypes.string,
-        width: PropTypes.number,
-        height: PropTypes.number,
-        resizeMethod: PropTypes.string,
-        // The onClick to give the avatar
-        onClick: PropTypes.func,
-        // Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser`
-        viewUserOnClick: PropTypes.bool,
-        title: PropTypes.string,
-    },
+interface IState {
+    name: string;
+    title: string;
+    imageUrl?: string;
+}
 
-    getDefaultProps: function() {
-        return {
-            width: 40,
-            height: 40,
-            resizeMethod: 'crop',
-            viewUserOnClick: false,
-        };
-    },
+export default class MemberAvatar extends React.Component<IProps, IState> {
+    public static defaultProps = {
+        width: 40,
+        height: 40,
+        resizeMethod: 'crop',
+        viewUserOnClick: false,
+    };
 
-    getInitialState: function() {
-        return this._getState(this.props);
-    },
+    constructor(props: IProps) {
+        super(props);
 
-    // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
-    UNSAFE_componentWillReceiveProps: function(nextProps) {
-        this.setState(this._getState(nextProps));
-    },
+        this.state = MemberAvatar.getState(props);
+    }
 
-    _getState: function(props) {
+    public static getDerivedStateFromProps(nextProps: IProps): IState {
+        return MemberAvatar.getState(nextProps);
+    }
+
+    private static getState(props: IProps): IState {
         if (props.member && props.member.name) {
             return {
                 name: props.member.name,
@@ -79,11 +81,9 @@ export default createReactClass({
         } else {
             console.error("MemberAvatar called somehow with null member or fallbackUserId");
         }
-    },
-
-    render: function() {
-        const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
+    }
 
+    render() {
         let {member, fallbackUserId, onClick, viewUserOnClick, ...otherProps} = this.props;
         const userId = member ? member.userId : fallbackUserId;
 
@@ -100,5 +100,5 @@ export default createReactClass({
             <BaseAvatar {...otherProps} name={this.state.name} title={this.state.title}
                 idName={userId} url={this.state.imageUrl} onClick={onClick} />
         );
-    },
-});
+    }
+}
diff --git a/src/stores/notifications/INotificationState.ts b/src/components/views/avatars/PulsedAvatar.tsx
similarity index 67%
rename from src/stores/notifications/INotificationState.ts
rename to src/components/views/avatars/PulsedAvatar.tsx
index 65bd7b7957..94a6c87687 100644
--- a/src/stores/notifications/INotificationState.ts
+++ b/src/components/views/avatars/PulsedAvatar.tsx
@@ -14,13 +14,15 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { EventEmitter } from "events";
-import { NotificationColor } from "./NotificationColor";
+import React from 'react';
 
-export const NOTIFICATION_STATE_UPDATE = "update";
-
-export interface INotificationState extends EventEmitter {
-    symbol?: string;
-    count: number;
-    color: NotificationColor;
+interface IProps {
 }
+
+const PulsedAvatar: React.FC<IProps> = (props) => {
+    return <div className="mx_PulsedAvatar">
+        {props.children}
+    </div>;
+};
+
+export default PulsedAvatar;
\ No newline at end of file
diff --git a/src/components/views/avatars/RoomAvatar.js b/src/components/views/avatars/RoomAvatar.tsx
similarity index 56%
rename from src/components/views/avatars/RoomAvatar.js
rename to src/components/views/avatars/RoomAvatar.tsx
index a72d318b8d..3317ed3a60 100644
--- a/src/components/views/avatars/RoomAvatar.js
+++ b/src/components/views/avatars/RoomAvatar.tsx
@@ -13,90 +13,96 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
-import React from "react";
-import PropTypes from 'prop-types';
-import createReactClass from 'create-react-class';
-import {MatrixClientPeg} from "../../../MatrixClientPeg";
+import React from 'react';
+import Room from 'matrix-js-sdk/src/models/room';
+import {getHttpUriForMxc} from 'matrix-js-sdk/src/content-repo';
+
+import BaseAvatar from './BaseAvatar';
+import ImageView from '../elements/ImageView';
+import {MatrixClientPeg} from '../../../MatrixClientPeg';
 import Modal from '../../../Modal';
-import * as sdk from "../../../index";
 import * as Avatar from '../../../Avatar';
-import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
-
-export default createReactClass({
-    displayName: 'RoomAvatar',
 
+interface IProps {
     // Room may be left unset here, but if it is,
     // oobData.avatarUrl should be set (else there
     // would be nowhere to get the avatar from)
-    propTypes: {
-        room: PropTypes.object,
-        oobData: PropTypes.object,
-        width: PropTypes.number,
-        height: PropTypes.number,
-        resizeMethod: PropTypes.string,
-        viewAvatarOnClick: PropTypes.bool,
-    },
+    room?: Room;
+    // TODO: type when js-sdk has types
+    oobData?: any;
+    width?: number;
+    height?: number;
+    resizeMethod?: string;
+    viewAvatarOnClick?: boolean;
+}
 
-    getDefaultProps: function() {
-        return {
-            width: 36,
-            height: 36,
-            resizeMethod: 'crop',
-            oobData: {},
+interface IState {
+    urls: string[];
+}
+
+export default class RoomAvatar extends React.Component<IProps, IState> {
+    public static defaultProps = {
+        width: 36,
+        height: 36,
+        resizeMethod: 'crop',
+        oobData: {},
+    };
+
+    constructor(props: IProps) {
+        super(props);
+
+        this.state = {
+            urls: RoomAvatar.getImageUrls(this.props),
         };
-    },
+    }
 
-    getInitialState: function() {
-        return {
-            urls: this.getImageUrls(this.props),
-        };
-    },
-
-    componentDidMount: function() {
+    public componentDidMount() {
         MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
-    },
+    }
 
-    componentWillUnmount: function() {
+    public componentWillUnmount() {
         const cli = MatrixClientPeg.get();
         if (cli) {
             cli.removeListener("RoomState.events", this.onRoomStateEvents);
         }
-    },
+    }
 
-    // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
-    UNSAFE_componentWillReceiveProps: function(newProps) {
-        this.setState({
-            urls: this.getImageUrls(newProps),
-        });
-    },
+    public static getDerivedStateFromProps(nextProps: IProps): IState {
+        return {
+            urls: RoomAvatar.getImageUrls(nextProps),
+        };
+    }
 
-    onRoomStateEvents: function(ev) {
+    // TODO: type when js-sdk has types
+    private onRoomStateEvents = (ev: any) => {
         if (!this.props.room ||
             ev.getRoomId() !== this.props.room.roomId ||
             ev.getType() !== 'm.room.avatar'
         ) return;
 
         this.setState({
-            urls: this.getImageUrls(this.props),
+            urls: RoomAvatar.getImageUrls(this.props),
         });
-    },
+    };
 
-    getImageUrls: function(props) {
+    private static getImageUrls(props: IProps): string[] {
         return [
             getHttpUriForMxc(
                 MatrixClientPeg.get().getHomeserverUrl(),
+                // Default props don't play nicely with getDerivedStateFromProps
+                //props.oobData !== undefined ? props.oobData.avatarUrl : {},
                 props.oobData.avatarUrl,
                 Math.floor(props.width * window.devicePixelRatio),
                 Math.floor(props.height * window.devicePixelRatio),
                 props.resizeMethod,
             ), // highest priority
-            this.getRoomAvatarUrl(props),
+            RoomAvatar.getRoomAvatarUrl(props),
         ].filter(function(url) {
-            return (url != null && url != "");
+            return (url !== null && url !== "");
         });
-    },
+    }
 
-    getRoomAvatarUrl: function(props) {
+    private static getRoomAvatarUrl(props: IProps): string {
         if (!props.room) return null;
 
         return Avatar.avatarUrlForRoom(
@@ -105,35 +111,32 @@ export default createReactClass({
             Math.floor(props.height * window.devicePixelRatio),
             props.resizeMethod,
         );
-    },
+    }
 
-    onRoomAvatarClick: function() {
+    private onRoomAvatarClick = () => {
         const avatarUrl = this.props.room.getAvatarUrl(
             MatrixClientPeg.get().getHomeserverUrl(),
             null, null, null, false);
-        const ImageView = sdk.getComponent("elements.ImageView");
         const params = {
             src: avatarUrl,
             name: this.props.room.name,
         };
 
         Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
-    },
+    };
 
-    render: function() {
-        const BaseAvatar = sdk.getComponent("avatars.BaseAvatar");
-
-        /*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/
+    public render() {
         const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props;
 
         const roomName = room ? room.name : oobData.name;
 
         return (
-            <BaseAvatar {...otherProps} name={roomName}
+            <BaseAvatar {...otherProps}
+                name={roomName}
                 idName={room ? room.roomId : null}
                 urls={this.state.urls}
-                onClick={this.props.viewAvatarOnClick ? this.onRoomAvatarClick : null}
-                disabled={!this.state.urls[0]} />
+                onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : null}
+            />
         );
-    },
-});
+    }
+}
diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx
index 040147bb16..34481601f7 100644
--- a/src/components/views/elements/AccessibleButton.tsx
+++ b/src/components/views/elements/AccessibleButton.tsx
@@ -64,7 +64,6 @@ export default function AccessibleButton({
     className,
     ...restProps
 }: IProps) {
-
     const newProps: IAccessibleButtonProps = restProps;
     if (!disabled) {
         newProps.onClick = onClick;
diff --git a/src/components/views/elements/EditableItemList.js b/src/components/views/elements/EditableItemList.js
index 50d5a3d10f..34e53906a2 100644
--- a/src/components/views/elements/EditableItemList.js
+++ b/src/components/views/elements/EditableItemList.js
@@ -16,7 +16,7 @@ limitations under the License.
 
 import React from 'react';
 import PropTypes from 'prop-types';
-import {_t} from '../../../languageHandler.js';
+import {_t} from '../../../languageHandler';
 import Field from "./Field";
 import AccessibleButton from "./AccessibleButton";
 
diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.js
index fc79fc87d0..956b69ca7b 100644
--- a/src/components/views/elements/MemberEventListSummary.js
+++ b/src/components/views/elements/MemberEventListSummary.js
@@ -1,6 +1,6 @@
 /*
 Copyright 2016 OpenMarket Ltd
-Copyright 2019 The Matrix.org Foundation C.I.C.
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
 Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
 
 Licensed under the Apache License, Version 2.0 (the "License");
@@ -23,6 +23,7 @@ import { _t } from '../../../languageHandler';
 import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
 import * as sdk from "../../../index";
 import {MatrixEvent} from "matrix-js-sdk";
+import {isValid3pidInvite} from "../../../RoomInvite";
 
 export default createReactClass({
     displayName: 'MemberEventListSummary',
@@ -284,6 +285,9 @@ export default createReactClass({
     _getTransition: function(e) {
         if (e.mxEvent.getType() === 'm.room.third_party_invite') {
             // Handle 3pid invites the same as invites so they get bundled together
+            if (!isValid3pidInvite(e.mxEvent)) {
+                return 'invite_withdrawal';
+            }
             return 'invited';
         }
 
diff --git a/src/components/views/rooms/JumpToBottomButton.js b/src/components/views/rooms/JumpToBottomButton.js
index d3305f498a..b6cefc1231 100644
--- a/src/components/views/rooms/JumpToBottomButton.js
+++ b/src/components/views/rooms/JumpToBottomButton.js
@@ -16,13 +16,18 @@ limitations under the License.
 
 import { _t } from '../../../languageHandler';
 import AccessibleButton from '../elements/AccessibleButton';
+import classNames from 'classnames';
 
 export default (props) => {
+    const className = classNames({
+        'mx_JumpToBottomButton': true,
+        'mx_JumpToBottomButton_highlight': props.highlight,
+    });
     let badge;
     if (props.numUnreadMessages) {
         badge = (<div className="mx_JumpToBottomButton_badge">{props.numUnreadMessages}</div>);
     }
-    return (<div className="mx_JumpToBottomButton">
+    return (<div className={className}>
         <AccessibleButton className="mx_JumpToBottomButton_scrollDown"
             title={_t("Scroll to most recent messages")}
             onClick={props.onScrollToBottomClick}>
diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx
index 829b05fbfc..941a057927 100644
--- a/src/components/views/rooms/NotificationBadge.tsx
+++ b/src/components/views/rooms/NotificationBadge.tsx
@@ -22,11 +22,10 @@ import { DefaultTagID, TagID } from "../../../stores/room-list/models";
 import { readReceiptChangeIsFor } from "../../../utils/read-receipts";
 import AccessibleButton from "../elements/AccessibleButton";
 import { XOR } from "../../../@types/common";
-import { INotificationState, NOTIFICATION_STATE_UPDATE } from "../../../stores/notifications/INotificationState";
-import { NotificationColor } from "../../../stores/notifications/NotificationColor";
+import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState";
 
 interface IProps {
-    notification: INotificationState;
+    notification: NotificationState;
 
     /**
      * If true, the badge will show a count if at all possible. This is typically
@@ -97,19 +96,17 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
         const {notification, forceCount, roomId, onClick, ...props} = this.props;
 
         // Don't show a badge if we don't need to
-        if (notification.color <= NotificationColor.None) return null;
+        if (notification.isIdle) return null;
 
         // TODO: Update these booleans for FTUE Notifications: https://github.com/vector-im/riot-web/issues/14261
         // As of writing, that is "if red, show count always" and "optionally show counts instead of dots".
         // See git diff for what that boolean state looks like.
         // XXX: We ignore this.state.showCounts (the setting which controls counts vs dots).
-        const hasNotif = notification.color >= NotificationColor.Red;
-        const hasCount = notification.color >= NotificationColor.Grey;
         const hasAnySymbol = notification.symbol || notification.count > 0;
-        let isEmptyBadge = !hasAnySymbol || !hasCount;
+        let isEmptyBadge = !hasAnySymbol || !notification.hasUnreadCount;
         if (forceCount) {
             isEmptyBadge = false;
-            if (!hasCount) return null; // Can't render a badge
+            if (!notification.hasUnreadCount) return null; // Can't render a badge
         }
 
         let symbol = notification.symbol || formatMinimalBadgeCount(notification.count);
@@ -117,8 +114,8 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
 
         const classes = classNames({
             'mx_NotificationBadge': true,
-            'mx_NotificationBadge_visible': isEmptyBadge ? true : hasCount,
-            'mx_NotificationBadge_highlighted': hasNotif,
+            'mx_NotificationBadge_visible': isEmptyBadge ? true : notification.hasUnreadCount,
+            'mx_NotificationBadge_highlighted': notification.hasMentions,
             'mx_NotificationBadge_dot': isEmptyBadge,
             'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3,
             'mx_NotificationBadge_3char': symbol.length > 2,
diff --git a/src/components/views/rooms/RoomBreadcrumbs2.tsx b/src/components/views/rooms/RoomBreadcrumbs2.tsx
index 687f4dd73e..7d0584ef66 100644
--- a/src/components/views/rooms/RoomBreadcrumbs2.tsx
+++ b/src/components/views/rooms/RoomBreadcrumbs2.tsx
@@ -16,7 +16,6 @@ limitations under the License.
 
 import React from "react";
 import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
-import AccessibleButton from "../elements/AccessibleButton";
 import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
 import { _t } from "../../../languageHandler";
 import { Room } from "matrix-js-sdk/src/models/room";
@@ -28,8 +27,8 @@ import RoomListStore from "../../../stores/room-list/RoomListStore2";
 import { DefaultTagID } from "../../../stores/room-list/models";
 import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
 
-// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
-// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14367
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
 
 /*******************************************************************
  *   CAUTION                                                       *
@@ -92,9 +91,6 @@ export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState
     };
 
     public render(): React.ReactElement {
-        // TODO: Decorate crumbs with icons: https://github.com/vector-im/riot-web/issues/14040
-        // TODO: Scrolling: https://github.com/vector-im/riot-web/issues/14040
-        // TODO: Tooltips: https://github.com/vector-im/riot-web/issues/14040
         const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => {
             const roomTags = RoomListStore.instance.getTagsForRoom(r);
             const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0];
diff --git a/src/components/views/rooms/RoomList2.tsx b/src/components/views/rooms/RoomList2.tsx
index b0bb70c9a0..67787963a3 100644
--- a/src/components/views/rooms/RoomList2.tsx
+++ b/src/components/views/rooms/RoomList2.tsx
@@ -17,27 +17,32 @@ limitations under the License.
 */
 
 import * as React from "react";
+import { Dispatcher } from "flux";
+import { Room } from "matrix-js-sdk/src/models/room";
+
 import { _t, _td } from "../../../languageHandler";
 import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
 import { ResizeNotifier } from "../../../utils/ResizeNotifier";
-import RoomListStore, { LISTS_UPDATE_EVENT, RoomListStore2 } from "../../../stores/room-list/RoomListStore2";
+import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore2";
+import RoomViewStore from "../../../stores/RoomViewStore";
 import { ITagMap } from "../../../stores/room-list/algorithms/models";
 import { DefaultTagID, TagID } from "../../../stores/room-list/models";
-import { Dispatcher } from "flux";
 import dis from "../../../dispatcher/dispatcher";
 import defaultDispatcher from "../../../dispatcher/dispatcher";
 import RoomSublist2 from "./RoomSublist2";
 import { ActionPayload } from "../../../dispatcher/payloads";
 import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition";
-import { ListLayout } from "../../../stores/room-list/ListLayout";
 import { MatrixClientPeg } from "../../../MatrixClientPeg";
 import GroupAvatar from "../avatars/GroupAvatar";
 import TemporaryTile from "./TemporaryTile";
 import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
 import { NotificationColor } from "../../../stores/notifications/NotificationColor";
+import { Action } from "../../../dispatcher/actions";
+import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
+import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
 
-// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
-// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14367
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
 
 /*******************************************************************
  *   CAUTION                                                       *
@@ -51,6 +56,7 @@ interface IProps {
     onKeyDown: (ev: React.KeyboardEvent) => void;
     onFocus: (ev: React.FocusEvent) => void;
     onBlur: (ev: React.FocusEvent) => void;
+    onResize: () => void;
     resizeNotifier: ResizeNotifier;
     collapsed: boolean;
     searchFilter: string;
@@ -59,12 +65,9 @@ interface IProps {
 
 interface IState {
     sublists: ITagMap;
-    layouts: Map<TagID, ListLayout>;
 }
 
 const TAG_ORDER: TagID[] = [
-    // -- Community Invites Placeholder --
-
     DefaultTagID.Invite,
     DefaultTagID.Favourite,
     DefaultTagID.DM,
@@ -76,7 +79,6 @@ const TAG_ORDER: TagID[] = [
     DefaultTagID.ServerNotice,
     DefaultTagID.Archived,
 ];
-const COMMUNITY_TAGS_BEFORE_TAG = DefaultTagID.Invite;
 const CUSTOM_TAGS_BEFORE_TAG = DefaultTagID.LowPriority;
 const ALWAYS_VISIBLE_TAGS: TagID[] = [
     DefaultTagID.DM,
@@ -140,14 +142,16 @@ const TAG_AESTHETICS: {
 
 export default class RoomList2 extends React.Component<IProps, IState> {
     private searchFilter: NameFilterCondition = new NameFilterCondition();
+    private dispatcherRef;
 
     constructor(props: IProps) {
         super(props);
 
         this.state = {
             sublists: {},
-            layouts: new Map<TagID, ListLayout>(),
         };
+
+        this.dispatcherRef = defaultDispatcher.register(this.onAction);
     }
 
     public componentDidUpdate(prevProps: Readonly<IProps>): void {
@@ -172,25 +176,64 @@ export default class RoomList2 extends React.Component<IProps, IState> {
 
     public componentWillUnmount() {
         RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists);
+        defaultDispatcher.unregister(this.dispatcherRef);
     }
 
+    private onAction = (payload: ActionPayload) => {
+        if (payload.action === Action.ViewRoomDelta) {
+            const viewRoomDeltaPayload = payload as ViewRoomDeltaPayload;
+            const currentRoomId = RoomViewStore.getRoomId();
+            const room = this.getRoomDelta(currentRoomId, viewRoomDeltaPayload.delta, viewRoomDeltaPayload.unread);
+            if (room) {
+                dis.dispatch({
+                    action: 'view_room',
+                    room_id: room.roomId,
+                    show_room_tile: true, // to make sure the room gets scrolled into view
+                });
+            }
+        }
+    };
+
+    private getRoomDelta = (roomId: string, delta: number, unread = false) => {
+        const lists = RoomListStore.instance.orderedLists;
+        let rooms: Room = [];
+        TAG_ORDER.forEach(t => {
+            let listRooms = lists[t];
+
+            if (unread) {
+                // filter to only notification rooms (and our current active room so we can index properly)
+                listRooms = listRooms.filter(r => {
+                    const state = RoomNotificationStateStore.instance.getRoomState(r, t);
+                    return state.room.roomId === roomId || state.isUnread;
+                });
+            }
+
+            rooms.push(...listRooms);
+        });
+
+        const currentIndex = rooms.findIndex(r => r.roomId === roomId);
+        // use slice to account for looping around the start
+        const [room] = rooms.slice((currentIndex + delta) % rooms.length);
+        return room;
+    };
+
     private updateLists = () => {
         const newLists = RoomListStore.instance.orderedLists;
-        console.log("new lists", newLists);
-
-        const layoutMap = new Map<TagID, ListLayout>();
-        for (const tagId of Object.keys(newLists)) {
-            layoutMap.set(tagId, new ListLayout(tagId));
+        if (!window.mx_QuietRoomListLogging) {
+            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+            console.log("new lists", newLists);
         }
 
-        this.setState({sublists: newLists, layouts: layoutMap});
+        this.setState({sublists: newLists}, () => {
+            this.props.onResize();
+        });
     };
 
     private renderCommunityInvites(): React.ReactElement[] {
         // TODO: Put community invites in a more sensible place (not in the room list)
         return MatrixClientPeg.get().getGroups().filter(g => {
            if (g.myMembership !== 'invite') return false;
-           return !this.searchFilter || this.searchFilter.matches(g.name);
+           return !this.searchFilter || this.searchFilter.matches(g.name || "");
         }).map(g => {
             const avatar = (
                 <GroupAvatar
@@ -224,17 +267,15 @@ export default class RoomList2 extends React.Component<IProps, IState> {
         const components: React.ReactElement[] = [];
 
         for (const orderedTagId of TAG_ORDER) {
-            if (COMMUNITY_TAGS_BEFORE_TAG === orderedTagId) {
-                // Populate community invites if we have the chance
-                // TODO: Community invites: https://github.com/vector-im/riot-web/issues/14179
-            }
             if (CUSTOM_TAGS_BEFORE_TAG === orderedTagId) {
                 // Populate custom tags if needed
                 // TODO: Custom tags: https://github.com/vector-im/riot-web/issues/14091
             }
 
             const orderedRooms = this.state.sublists[orderedTagId] || [];
-            if (orderedRooms.length === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) {
+            const extraTiles = orderedTagId === DefaultTagID.Invite ? this.renderCommunityInvites() : null;
+            const totalTiles = orderedRooms.length + (extraTiles ? extraTiles.length : 0);
+            if (totalTiles === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) {
                 continue; // skip tag - not needed
             }
 
@@ -242,7 +283,6 @@ export default class RoomList2 extends React.Component<IProps, IState> {
             if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
 
             const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null;
-            const extraTiles = orderedTagId === DefaultTagID.Invite ? this.renderCommunityInvites() : null;
             components.push(
                 <RoomSublist2
                     key={`sublist-${orderedTagId}`}
@@ -253,10 +293,10 @@ export default class RoomList2 extends React.Component<IProps, IState> {
                     label={_t(aesthetics.sectionLabel)}
                     onAddRoom={onAddRoomFn}
                     addRoomLabel={aesthetics.addRoomLabel}
-                    isInvite={aesthetics.isInvite}
-                    layout={this.state.layouts.get(orderedTagId)}
                     isMinimized={this.props.isMinimized}
+                    onResize={this.props.onResize}
                     extraBadTilesThatShouldntExist={extraTiles}
+                    isFiltered={!!this.searchFilter.search}
                 />
             );
         }
@@ -276,9 +316,6 @@ export default class RoomList2 extends React.Component<IProps, IState> {
                         className="mx_RoomList2"
                         role="tree"
                         aria-label={_t("Rooms")}
-                        // Firefox sometimes makes this element focusable due to
-                        // overflow:scroll;, so force it out of tab order.
-                        tabIndex={-1}
                     >{sublists}</div>
                 )}
             </RovingTabIndexProvider>
diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx
index 21e7c581f0..3623b8d48d 100644
--- a/src/components/views/rooms/RoomSublist2.tsx
+++ b/src/components/views/rooms/RoomSublist2.tsx
@@ -17,30 +17,39 @@ limitations under the License.
 */
 
 import * as React from "react";
-import { createRef } from "react";
+import {createRef, UIEventHandler} from "react";
 import { Room } from "matrix-js-sdk/src/models/room";
 import classNames from 'classnames';
-import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
+import { RovingAccessibleButton, RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
 import { _t } from "../../../languageHandler";
 import AccessibleButton from "../../views/elements/AccessibleButton";
 import RoomTile2 from "./RoomTile2";
-import { ResizableBox, ResizeCallbackData } from "react-resizable";
 import { ListLayout } from "../../../stores/room-list/ListLayout";
-import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
-import StyledCheckbox from "../elements/StyledCheckbox";
-import StyledRadioButton from "../elements/StyledRadioButton";
+import {
+    ChevronFace,
+    ContextMenu,
+    ContextMenuButton,
+    StyledMenuItemCheckbox,
+    StyledMenuItemRadio,
+} from "../../structures/ContextMenu";
 import RoomListStore from "../../../stores/room-list/RoomListStore2";
 import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models";
 import { DefaultTagID, TagID } from "../../../stores/room-list/models";
 import dis from "../../../dispatcher/dispatcher";
+import defaultDispatcher from "../../../dispatcher/dispatcher";
 import NotificationBadge from "./NotificationBadge";
 import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
-import Tooltip from "../elements/Tooltip";
 import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
 import { Key } from "../../../Keyboard";
+import { ActionPayload } from "../../../dispatcher/payloads";
+import { Enable, Resizable } from "re-resizable";
+import { Direction } from "re-resizable/lib/resizer";
+import { polyfillTouchEvent } from "../../../@types/polyfill";
+import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
+import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore";
 
-// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
-// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14367
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
 
 /*******************************************************************
  *   CAUTION                                                       *
@@ -50,11 +59,15 @@ import { Key } from "../../../Keyboard";
  * warning disappears.                                             *
  *******************************************************************/
 
-const SHOW_N_BUTTON_HEIGHT = 32; // As defined by CSS
+const SHOW_N_BUTTON_HEIGHT = 28; // As defined by CSS
 const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS
+export const HEADER_HEIGHT = 32; // As defined by CSS
 
 const MAX_PADDING_HEIGHT = SHOW_N_BUTTON_HEIGHT + RESIZE_HANDLE_HEIGHT;
 
+// HACK: We really shouldn't have to do this.
+polyfillTouchEvent();
+
 interface IProps {
     forRooms: boolean;
     rooms?: Room[];
@@ -62,10 +75,10 @@ interface IProps {
     label: string;
     onAddRoom?: () => void;
     addRoomLabel: string;
-    isInvite: boolean;
-    layout: ListLayout;
     isMinimized: boolean;
     tagId: TagID;
+    onResize: () => void;
+    isFiltered: boolean;
 
     // TODO: Don't use this. It's for community invites, and community invites shouldn't be here.
     // You should feel bad if you use this.
@@ -74,78 +87,178 @@ interface IProps {
     // TODO: Account for https://github.com/vector-im/riot-web/issues/14179
 }
 
+// TODO: Use re-resizer's NumberSize when it is exposed as the type
+interface ResizeDelta {
+    width: number;
+    height: number;
+}
+
 type PartialDOMRect = Pick<DOMRect, "left" | "top" | "height">;
 
 interface IState {
     notificationState: ListNotificationState;
     contextMenuPosition: PartialDOMRect;
     isResizing: boolean;
+    isExpanded: boolean; // used for the for expand of the sublist when the room list is being filtered
+    height: number;
 }
 
 export default class RoomSublist2 extends React.Component<IProps, IState> {
     private headerButton = createRef<HTMLDivElement>();
     private sublistRef = createRef<HTMLDivElement>();
+    private dispatcherRef: string;
+    private layout: ListLayout;
+    private heightAtStart: number;
 
     constructor(props: IProps) {
         super(props);
 
+        this.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId);
+        this.heightAtStart = 0;
+        const height = this.calculateInitialHeight();
         this.state = {
-            notificationState: new ListNotificationState(this.props.isInvite, this.props.tagId),
+            notificationState: RoomNotificationStateStore.instance.getListState(this.props.tagId),
             contextMenuPosition: null,
             isResizing: false,
+            isExpanded: this.props.isFiltered ? this.props.isFiltered : !this.layout.isCollapsed,
+            height,
         };
         this.state.notificationState.setRooms(this.props.rooms);
+        this.dispatcherRef = defaultDispatcher.register(this.onAction);
+    }
+
+    private calculateInitialHeight() {
+        const requestedVisibleTiles = Math.max(Math.floor(this.layout.visibleTiles), this.layout.minVisibleTiles);
+        const tileCount = Math.min(this.numTiles, requestedVisibleTiles);
+        return this.layout.tilesToPixelsWithPadding(tileCount, this.padding);
+    }
+
+    private get padding() {
+        let padding = RESIZE_HANDLE_HEIGHT;
+        // this is used for calculating the max height of the whole container,
+        // and takes into account whether there should be room reserved for the show less button
+        // when fully expanded. Note that the show more button might still be shown when not fully expanded,
+        // but in this case it will take the space of a tile and we don't need to reserve space for it.
+        if (this.numTiles > this.layout.defaultVisibleTiles) {
+            padding += SHOW_N_BUTTON_HEIGHT;
+        }
+        return padding;
     }
 
     private get numTiles(): number {
-        return (this.props.rooms || []).length + (this.props.extraBadTilesThatShouldntExist || []).length;
+        return RoomSublist2.calcNumTiles(this.props);
+    }
+
+    private static calcNumTiles(props) {
+        return (props.rooms || []).length + (props.extraBadTilesThatShouldntExist || []).length;
     }
 
     private get numVisibleTiles(): number {
-        if (!this.props.layout) return 0;
-        const nVisible = Math.floor(this.props.layout.visibleTiles);
+        const nVisible = Math.ceil(this.layout.visibleTiles);
         return Math.min(nVisible, this.numTiles);
     }
 
-    public componentDidUpdate() {
+    public componentDidUpdate(prevProps: Readonly<IProps>) {
         this.state.notificationState.setRooms(this.props.rooms);
+        if (prevProps.isFiltered !== this.props.isFiltered) {
+            if (this.props.isFiltered) {
+                this.setState({isExpanded: true});
+            } else {
+                this.setState({isExpanded: !this.layout.isCollapsed});
+            }
+        }
+        // as the rooms can come in one by one we need to reevaluate
+        // the amount of available rooms to cap the amount of requested visible rooms by the layout
+        if (RoomSublist2.calcNumTiles(prevProps) !== this.numTiles) {
+            this.setState({height: this.calculateInitialHeight()});
+        }
     }
 
     public componentWillUnmount() {
         this.state.notificationState.destroy();
+        defaultDispatcher.unregister(this.dispatcherRef);
     }
 
+    private onAction = (payload: ActionPayload) => {
+        if (payload.action === "view_room" && payload.show_room_tile && this.props.rooms) {
+            // XXX: we have to do this a tick later because we have incorrect intermediate props during a room change
+            // where we lose the room we are changing from temporarily and then it comes back in an update right after.
+            setImmediate(() => {
+                const roomIndex = this.props.rooms.findIndex((r) => r.roomId === payload.room_id);
+
+                if (!this.state.isExpanded && roomIndex > -1) {
+                    this.toggleCollapsed();
+                }
+                // extend the visible section to include the room if it is entirely invisible
+                if (roomIndex >= this.numVisibleTiles) {
+                    this.layout.visibleTiles = this.layout.tilesWithPadding(roomIndex + 1, MAX_PADDING_HEIGHT);
+                    this.forceUpdate(); // because the layout doesn't trigger a re-render
+                }
+            });
+        }
+    };
+
     private onAddRoom = (e) => {
         e.stopPropagation();
         if (this.props.onAddRoom) this.props.onAddRoom();
     };
 
-    private onResize = (e: React.MouseEvent, data: ResizeCallbackData) => {
-        const direction = e.movementY < 0 ? -1 : +1;
-        const tileDiff = this.props.layout.pixelsToTiles(Math.abs(e.movementY)) * direction;
-        this.props.layout.setVisibleTilesWithin(tileDiff, this.numTiles);
-        this.forceUpdate(); // because the layout doesn't trigger a re-render
+    private applyHeightChange(newHeight: number) {
+        const heightInTiles = Math.ceil(this.layout.pixelsToTiles(newHeight - this.padding));
+        this.layout.visibleTiles = Math.min(this.numTiles, heightInTiles);
+    }
+
+    private onResize = (
+        e: MouseEvent | TouchEvent,
+        travelDirection: Direction,
+        refToElement: HTMLDivElement,
+        delta: ResizeDelta,
+    ) => {
+        const newHeight = this.heightAtStart + delta.height;
+        this.applyHeightChange(newHeight);
+        this.setState({height: newHeight});
     };
 
     private onResizeStart = () => {
+        this.heightAtStart = this.state.height;
         this.setState({isResizing: true});
     };
 
-    private onResizeStop = () => {
-        this.setState({isResizing: false});
+    private onResizeStop = (
+        e: MouseEvent | TouchEvent,
+        travelDirection: Direction,
+        refToElement: HTMLDivElement,
+        delta: ResizeDelta,
+    ) => {
+        const newHeight = this.heightAtStart + delta.height;
+        this.applyHeightChange(newHeight);
+        this.setState({isResizing: false, height: newHeight});
     };
 
     private onShowAllClick = () => {
-        this.props.layout.visibleTiles = this.props.layout.tilesWithPadding(this.numTiles, MAX_PADDING_HEIGHT);
-        this.forceUpdate(); // because the layout doesn't trigger a re-render
+        const newHeight = this.layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
+        this.applyHeightChange(newHeight);
+        this.setState({height: newHeight}, () => {
+            this.focusRoomTile(this.numTiles - 1);
+        });
     };
 
     private onShowLessClick = () => {
-        this.props.layout.visibleTiles = this.props.layout.defaultVisibleTiles;
-        this.forceUpdate(); // because the layout doesn't trigger a re-render
+        const newHeight = this.layout.tilesToPixelsWithPadding(this.layout.defaultVisibleTiles, this.padding);
+        this.applyHeightChange(newHeight);
+        this.setState({height: newHeight});
     };
 
-    private onOpenMenuClick = (ev: InputEvent) => {
+    private focusRoomTile = (index: number) => {
+        if (!this.sublistRef.current) return;
+        const elements = this.sublistRef.current.querySelectorAll<HTMLDivElement>(".mx_RoomTile2");
+        const element = elements && elements[index];
+        if (element) {
+            element.focus();
+        }
+    };
+
+    private onOpenMenuClick = (ev: React.MouseEvent) => {
         ev.preventDefault();
         ev.stopPropagation();
         const target = ev.target as HTMLButtonElement;
@@ -179,7 +292,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
     };
 
     private onMessagePreviewChanged = () => {
-        this.props.layout.showPreviews = !this.props.layout.showPreviews;
+        this.layout.showPreviews = !this.layout.showPreviews;
         this.forceUpdate(); // because the layout doesn't trigger a re-render
     };
 
@@ -203,6 +316,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
             dis.dispatch({
                 action: 'view_room',
                 room_id: room.roomId,
+                show_room_tile: true, // to make sure the room gets scrolled into view
             });
         }
     };
@@ -216,7 +330,11 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
 
         const possibleSticky = target.parentElement;
         const sublist = possibleSticky.parentElement.parentElement;
-        if (possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky')) {
+        const list = sublist.parentElement.parentElement;
+        // the scrollTop is capped at the height of the header in LeftPanel2
+        const isAtTop = list.scrollTop <= HEADER_HEIGHT;
+        const isSticky = possibleSticky.classList.contains('mx_RoomSublist2_headerContainer_sticky');
+        if (isSticky && !isAtTop) {
             // is sticky - jump to list
             sublist.scrollIntoView({behavior: 'smooth'});
         } else {
@@ -226,23 +344,23 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
     };
 
     private toggleCollapsed = () => {
-        this.props.layout.isCollapsed = !this.props.layout.isCollapsed;
-        this.forceUpdate(); // because the layout doesn't trigger an update
+        this.layout.isCollapsed = this.state.isExpanded;
+        this.setState({isExpanded: !this.layout.isCollapsed});
+        setImmediate(() => this.props.onResize()); // needs to happen when the DOM is updated
     };
 
     private onHeaderKeyDown = (ev: React.KeyboardEvent) => {
-        const isCollapsed = this.props.layout && this.props.layout.isCollapsed;
         switch (ev.key) {
             case Key.ARROW_LEFT:
                 ev.stopPropagation();
-                if (!isCollapsed) {
+                if (this.state.isExpanded) {
                     // On ARROW_LEFT collapse the room sublist if it isn't already
                     this.toggleCollapsed();
                 }
                 break;
             case Key.ARROW_RIGHT: {
                 ev.stopPropagation();
-                if (isCollapsed) {
+                if (!this.state.isExpanded) {
                     // On ARROW_RIGHT expand the room sublist if it isn't already
                     this.toggleCollapsed();
                 } else if (this.sublistRef.current) {
@@ -271,17 +389,13 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
     };
 
     private renderVisibleTiles(): React.ReactElement[] {
-        if (this.props.layout && this.props.layout.isCollapsed) {
+        if (!this.state.isExpanded) {
             // don't waste time on rendering
             return [];
         }
 
         const tiles: React.ReactElement[] = [];
 
-        if (this.props.extraBadTilesThatShouldntExist) {
-            tiles.push(...this.props.extraBadTilesThatShouldntExist);
-        }
-
         if (this.props.rooms) {
             const visibleRooms = this.props.rooms.slice(0, this.numVisibleTiles);
             for (const room of visibleRooms) {
@@ -289,7 +403,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
                     <RoomTile2
                         room={room}
                         key={`room-${room.roomId}`}
-                        showMessagePreview={this.props.layout.showPreviews}
+                        showMessagePreview={this.layout.showPreviews}
                         isMinimized={this.props.isMinimized}
                         tag={this.props.tagId}
                     />
@@ -297,6 +411,10 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
             }
         }
 
+        if (this.props.extraBadTilesThatShouldntExist) {
+            tiles.push(...this.props.extraBadTilesThatShouldntExist);
+        }
+
         // We only have to do this because of the extra tiles. We do it conditionally
         // to avoid spending cycles on slicing. It's generally fine to do this though
         // as users are unlikely to have more than a handful of tiles when the extra
@@ -309,18 +427,45 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
     }
 
     private renderMenu(): React.ReactElement {
-        // TODO: Get a proper invite context menu, or take invites out of the room list.
-        if (this.props.tagId === DefaultTagID.Invite) {
-            return null;
-        }
-
         let contextMenu = null;
         if (this.state.contextMenuPosition) {
             const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
             const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
+
+            // Invites don't get some nonsense options, so only add them if we have to.
+            let otherSections = null;
+            if (this.props.tagId !== DefaultTagID.Invite) {
+                otherSections = (
+                    <React.Fragment>
+                        <hr />
+                        <div>
+                            <div className='mx_RoomSublist2_contextMenu_title'>{_t("Unread rooms")}</div>
+                            <StyledMenuItemCheckbox
+                                onClose={this.onCloseMenu}
+                                onChange={this.onUnreadFirstChanged}
+                                checked={isUnreadFirst}
+                            >
+                                {_t("Always show first")}
+                            </StyledMenuItemCheckbox>
+                        </div>
+                        <hr />
+                        <div>
+                            <div className='mx_RoomSublist2_contextMenu_title'>{_t("Show")}</div>
+                            <StyledMenuItemCheckbox
+                                onClose={this.onCloseMenu}
+                                onChange={this.onMessagePreviewChanged}
+                                checked={this.layout.showPreviews}
+                            >
+                                {_t("Message preview")}
+                            </StyledMenuItemCheckbox>
+                        </div>
+                    </React.Fragment>
+                );
+            }
+
             contextMenu = (
                 <ContextMenu
-                    chevronFace="none"
+                    chevronFace={ChevronFace.None}
                     left={this.state.contextMenuPosition.left}
                     top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
                     onFinished={this.onCloseMenu}
@@ -328,41 +473,24 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
                     <div className="mx_RoomSublist2_contextMenu">
                         <div>
                             <div className='mx_RoomSublist2_contextMenu_title'>{_t("Sort by")}</div>
-                            <StyledRadioButton
+                            <StyledMenuItemRadio
+                                onClose={this.onCloseMenu}
                                 onChange={() => this.onTagSortChanged(SortAlgorithm.Recent)}
                                 checked={!isAlphabetical}
                                 name={`mx_${this.props.tagId}_sortBy`}
                             >
                                 {_t("Activity")}
-                            </StyledRadioButton>
-                            <StyledRadioButton
+                            </StyledMenuItemRadio>
+                            <StyledMenuItemRadio
+                                onClose={this.onCloseMenu}
                                 onChange={() => this.onTagSortChanged(SortAlgorithm.Alphabetic)}
                                 checked={isAlphabetical}
                                 name={`mx_${this.props.tagId}_sortBy`}
                             >
                                 {_t("A-Z")}
-                            </StyledRadioButton>
-                        </div>
-                        <hr />
-                        <div>
-                            <div className='mx_RoomSublist2_contextMenu_title'>{_t("Unread rooms")}</div>
-                            <StyledCheckbox
-                                onChange={this.onUnreadFirstChanged}
-                                checked={isUnreadFirst}
-                            >
-                                {_t("Always show first")}
-                            </StyledCheckbox>
-                        </div>
-                        <hr />
-                        <div>
-                            <div className='mx_RoomSublist2_contextMenu_title'>{_t("Show")}</div>
-                            <StyledCheckbox
-                                onChange={this.onMessagePreviewChanged}
-                                checked={this.props.layout.showPreviews}
-                            >
-                                {_t("Message preview")}
-                            </StyledCheckbox>
+                            </StyledMenuItemRadio>
                         </div>
+                        {otherSections}
                     </div>
                 </ContextMenu>
             );
@@ -383,16 +511,22 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
 
     private renderHeader(): React.ReactElement {
         return (
-            <RovingTabIndexWrapper>
+            <RovingTabIndexWrapper inputRef={this.headerButton}>
                 {({onFocus, isActive, ref}) => {
                     const tabIndex = isActive ? 0 : -1;
 
+                    let ariaLabel = _t("Jump to first unread room.");
+                    if (this.props.tagId === DefaultTagID.Invite) {
+                        ariaLabel = _t("Jump to first invite.");
+                    }
+
                     const badge = (
                         <NotificationBadge
                             forceCount={true}
                             notification={this.state.notificationState}
                             onClick={this.onBadgeClick}
                             tabIndex={tabIndex}
+                            aria-label={ariaLabel}
                         />
                     );
 
@@ -412,7 +546,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
 
                     const collapseClasses = classNames({
                         'mx_RoomSublist2_collapseBtn': true,
-                        'mx_RoomSublist2_collapseBtn_collapsed': this.props.layout && this.props.layout.isCollapsed,
+                        'mx_RoomSublist2_collapseBtn_collapsed': !this.state.isExpanded,
                     });
 
                     const classes = classNames({
@@ -426,14 +560,13 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
                         </div>
                     );
 
-                    // TODO: a11y (see old component): https://github.com/vector-im/riot-web/issues/14180
                     // Note: the addRoomButton conditionally gets moved around
                     // the DOM depending on whether or not the list is minimized.
                     // If we're minimized, we want it below the header so it
                     // doesn't become sticky.
                     // The same applies to the notification badge.
                     return (
-                        <div className={classes} onKeyDown={this.onHeaderKeyDown} onFocus={onFocus}>
+                        <div className={classes} onKeyDown={this.onHeaderKeyDown} onFocus={onFocus} aria-label={this.props.label}>
                             <div className="mx_RoomSublist2_stickable">
                                 <AccessibleButton
                                     onFocus={onFocus}
@@ -441,6 +574,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
                                     tabIndex={tabIndex}
                                     className="mx_RoomSublist2_headerText"
                                     role="treeitem"
+                                    aria-expanded={this.state.isExpanded}
                                     aria-level={1}
                                     onClick={this.onHeaderClick}
                                     onContextMenu={this.onContextMenu}
@@ -461,11 +595,16 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
         );
     }
 
+    private onScrollPrevent(e: React.UIEvent<HTMLDivElement>) {
+        // the RoomTile calls scrollIntoView and the browser may scroll a div we do not wish to be scrollable
+        // this fixes https://github.com/vector-im/riot-web/issues/14413
+        (e.target as HTMLDivElement).scrollTop = 0;
+    }
+
     public render(): React.ReactElement {
         // TODO: Error boundary: https://github.com/vector-im/riot-web/issues/14185
 
         const visibleTiles = this.renderVisibleTiles();
-
         const classes = classNames({
             'mx_RoomSublist2': true,
             'mx_RoomSublist2_hasMenuOpen': !!this.state.contextMenuPosition,
@@ -474,21 +613,26 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
 
         let content = null;
         if (visibleTiles.length > 0) {
-            const layout = this.props.layout; // to shorten calls
+            const layout = this.layout; // to shorten calls
 
-            const maxTilesFactored = layout.tilesWithResizerBoxFactor(this.numTiles);
+            const minTiles = Math.min(layout.minVisibleTiles, this.numTiles);
+            const showMoreAtMinHeight = minTiles < this.numTiles;
+            const minHeightPadding = RESIZE_HANDLE_HEIGHT + (showMoreAtMinHeight ? SHOW_N_BUTTON_HEIGHT : 0);
+            const minTilesPx = layout.tilesToPixelsWithPadding(minTiles, minHeightPadding);
+            const maxTilesPx = layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
             const showMoreBtnClasses = classNames({
                 'mx_RoomSublist2_showNButton': true,
-                'mx_RoomSublist2_isCutting': this.state.isResizing && layout.visibleTiles < maxTilesFactored,
             });
 
             // If we're hiding rooms, show a 'show more' button to the user. This button
             // floats above the resize handle, if we have one present. If the user has all
             // tiles visible, it becomes 'show less'.
             let showNButton = null;
-            if (this.numTiles > visibleTiles.length) {
-                // we have a cutoff condition - add the button to show all
-                const numMissing = this.numTiles - visibleTiles.length;
+
+            if (maxTilesPx > this.state.height) {
+                const nonPaddedHeight = this.state.height - RESIZE_HANDLE_HEIGHT - SHOW_N_BUTTON_HEIGHT;
+                const amountFullyShown = Math.floor(nonPaddedHeight / this.layout.tileHeight);
+                const numMissing = this.numTiles - amountFullyShown;
                 let showMoreText = (
                     <span className='mx_RoomSublist2_showNButtonText'>
                         {_t("Show %(count)s more", {count: numMissing})}
@@ -496,14 +640,14 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
                 );
                 if (this.props.isMinimized) showMoreText = null;
                 showNButton = (
-                    <div onClick={this.onShowAllClick} className={showMoreBtnClasses}>
+                    <RovingAccessibleButton onClick={this.onShowAllClick} className={showMoreBtnClasses}>
                         <span className='mx_RoomSublist2_showMoreButtonChevron mx_RoomSublist2_showNButtonChevron'>
                             {/* set by CSS masking */}
                         </span>
                         {showMoreText}
-                    </div>
+                    </RovingAccessibleButton>
                 );
-            } else if (this.numTiles <= visibleTiles.length && this.numTiles > this.props.layout.defaultVisibleTiles) {
+            } else if (this.numTiles > this.layout.defaultVisibleTiles) {
                 // we have all tiles visible - add a button to show less
                 let showLessText = (
                     <span className='mx_RoomSublist2_showNButtonText'>
@@ -512,19 +656,29 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
                 );
                 if (this.props.isMinimized) showLessText = null;
                 showNButton = (
-                    <div onClick={this.onShowLessClick} className={showMoreBtnClasses}>
+                    <RovingAccessibleButton onClick={this.onShowLessClick} className={showMoreBtnClasses}>
                         <span className='mx_RoomSublist2_showLessButtonChevron mx_RoomSublist2_showNButtonChevron'>
                             {/* set by CSS masking */}
                         </span>
                         {showLessText}
-                    </div>
+                    </RovingAccessibleButton>
                 );
             }
 
             // Figure out if we need a handle
-            let handles = ['s'];
+            const handles: Enable = {
+                bottom: true, // the only one we need, but the others must be explicitly false
+                bottomLeft: false,
+                bottomRight: false,
+                left: false,
+                right: false,
+                top: false,
+                topLeft: false,
+                topRight: false,
+            };
             if (layout.visibleTiles >= this.numTiles && this.numTiles <= layout.minVisibleTiles) {
-                handles = []; // no handles, we're at a minimum
+                // we're at a minimum, don't have a bottom handle
+                handles.bottom = false;
             }
 
             // We have to account for padding so we can accommodate a 'show more' button and
@@ -537,33 +691,31 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
             // goes backwards and can become wildly incorrect (visibleTiles says 18 when there's
             // only mathematically 7 possible).
 
-            // The padding is variable though, so figure out what we need padding for.
-            let padding = 0;
-            if (showNButton) padding += SHOW_N_BUTTON_HEIGHT;
-            padding += RESIZE_HANDLE_HEIGHT; // always append the handle height
-
-            const relativeTiles = layout.tilesWithPadding(this.numTiles, padding);
-            const minTilesPx = layout.calculateTilesToPixelsMin(relativeTiles, layout.minVisibleTiles, padding);
-            const maxTilesPx = layout.tilesToPixelsWithPadding(this.numTiles, padding);
-            const tilesWithoutPadding = Math.min(relativeTiles, layout.visibleTiles);
-            const tilesPx = layout.calculateTilesToPixelsMin(relativeTiles, tilesWithoutPadding, padding);
+            const handleWrapperClasses = classNames({
+                'mx_RoomSublist2_resizerHandles': true,
+                'mx_RoomSublist2_resizerHandles_showNButton': !!showNButton,
+            });
 
             content = (
-                <ResizableBox
-                    width={-1}
-                    height={tilesPx}
-                    axis="y"
-                    minConstraints={[-1, minTilesPx]}
-                    maxConstraints={[-1, maxTilesPx]}
-                    resizeHandles={handles}
-                    onResize={this.onResize}
-                    className="mx_RoomSublist2_resizeBox"
-                    onResizeStart={this.onResizeStart}
-                    onResizeStop={this.onResizeStop}
-                >
-                    {visibleTiles}
-                    {showNButton}
-                </ResizableBox>
+                <React.Fragment>
+                    <Resizable
+                        size={{height: this.state.height} as any}
+                        minHeight={minTilesPx}
+                        maxHeight={maxTilesPx}
+                        onResizeStart={this.onResizeStart}
+                        onResizeStop={this.onResizeStop}
+                        onResize={this.onResize}
+                        handleWrapperClass={handleWrapperClasses}
+                        handleClasses={{bottom: "mx_RoomSublist2_resizerHandle"}}
+                        className="mx_RoomSublist2_resizeBox"
+                        enable={handles}
+                    >
+                        <div className="mx_RoomSublist2_tiles" onScroll={this.onScrollPrevent}>
+                            {visibleTiles}
+                        </div>
+                        {showNButton}
+                    </Resizable>
+                </React.Fragment>
             );
         }
 
diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx
index 8a9712b5a4..ed188e996b 100644
--- a/src/components/views/rooms/RoomTile2.tsx
+++ b/src/components/views/rooms/RoomTile2.tsx
@@ -17,7 +17,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React from "react";
+import React, {createRef} from "react";
 import { Room } from "matrix-js-sdk/src/models/room";
 import classNames from "classnames";
 import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
@@ -26,20 +26,37 @@ import dis from '../../../dispatcher/dispatcher';
 import { Key } from "../../../Keyboard";
 import ActiveRoomObserver from "../../../ActiveRoomObserver";
 import { _t } from "../../../languageHandler";
-import { ContextMenu, ContextMenuButton, MenuItemRadio } from "../../structures/ContextMenu";
+import {
+    ChevronFace,
+    ContextMenu,
+    ContextMenuButton,
+    MenuItemRadio,
+    MenuItemCheckbox,
+    MenuItem,
+} from "../../structures/ContextMenu";
 import { DefaultTagID, TagID } from "../../../stores/room-list/models";
 import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
 import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
-import { getRoomNotifsState, ALL_MESSAGES, ALL_MESSAGES_LOUD, MENTIONS_ONLY, MUTE } from "../../../RoomNotifs";
+import {
+    getRoomNotifsState,
+    setRoomNotifsState,
+    ALL_MESSAGES,
+    ALL_MESSAGES_LOUD,
+    MENTIONS_ONLY,
+    MUTE,
+} from "../../../RoomNotifs";
 import { MatrixClientPeg } from "../../../MatrixClientPeg";
-import { setRoomNotifsState } from "../../../RoomNotifs";
-import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState";
-import { INotificationState } from "../../../stores/notifications/INotificationState";
 import NotificationBadge from "./NotificationBadge";
-import { NotificationColor } from "../../../stores/notifications/NotificationColor";
+import { Volume } from "../../../RoomNotifsTypes";
+import RoomListStore from "../../../stores/room-list/RoomListStore2";
+import RoomListActions from "../../../actions/RoomListActions";
+import defaultDispatcher from "../../../dispatcher/dispatcher";
+import {ActionPayload} from "../../../dispatcher/payloads";
+import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
+import { NotificationState } from "../../../stores/notifications/NotificationState";
 
-// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
-// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14367
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14367
 
 /*******************************************************************
  *   CAUTION                                                       *
@@ -62,17 +79,19 @@ type PartialDOMRect = Pick<DOMRect, "left" | "bottom">;
 
 interface IState {
     hover: boolean;
-    notificationState: INotificationState;
+    notificationState: NotificationState;
     selected: boolean;
     notificationsMenuPosition: PartialDOMRect;
     generalMenuPosition: PartialDOMRect;
 }
 
+const messagePreviewId = (roomId: string) => `mx_RoomTile2_messagePreview_${roomId}`;
+
 const contextMenuBelow = (elementRect: PartialDOMRect) => {
     // align the context menu's icons with the icon which opened the context menu
     const left = elementRect.left + window.pageXOffset - 9;
     const top = elementRect.bottom + window.pageYOffset + 17;
-    const chevronFace = "none";
+    const chevronFace = ChevronFace.None;
     return {left, top, chevronFace};
 };
 
@@ -103,6 +122,8 @@ const NotifOption: React.FC<INotifOptionProps> = ({active, onClick, iconClassNam
 };
 
 export default class RoomTile2 extends React.Component<IProps, IState> {
+    private dispatcherRef: string;
+    private roomTileRef = createRef<HTMLDivElement>();
     // TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
 
     constructor(props: IProps) {
@@ -110,25 +131,54 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
 
         this.state = {
             hover: false,
-            notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag),
+            notificationState: RoomNotificationStateStore.instance.getRoomState(this.props.room, this.props.tag),
             selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
             notificationsMenuPosition: null,
             generalMenuPosition: null,
         };
 
         ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
+        this.dispatcherRef = defaultDispatcher.register(this.onAction);
     }
 
     private get showContextMenu(): boolean {
         return !this.props.isMinimized && this.props.tag !== DefaultTagID.Invite;
     }
 
+    private get showMessagePreview(): boolean {
+        return !this.props.isMinimized && this.props.showMessagePreview;
+    }
+
+    public componentDidMount() {
+        // when we're first rendered (or our sublist is expanded) make sure we are visible if we're active
+        if (this.state.selected) {
+            this.scrollIntoView();
+        }
+    }
+
     public componentWillUnmount() {
         if (this.props.room) {
             ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
         }
+        defaultDispatcher.unregister(this.dispatcherRef);
     }
 
+    private onAction = (payload: ActionPayload) => {
+        if (payload.action === "view_room" && payload.room_id === this.props.room.roomId && payload.show_room_tile) {
+            setImmediate(() => {
+                this.scrollIntoView();
+            });
+        }
+    };
+
+    private scrollIntoView = () => {
+        if (!this.roomTileRef.current) return;
+        this.roomTileRef.current.scrollIntoView({
+            block: "nearest",
+            behavior: "auto",
+        });
+    };
+
     private onTileMouseEnter = () => {
         this.setState({hover: true});
     };
@@ -142,7 +192,6 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
         ev.stopPropagation();
         dis.dispatch({
             action: 'view_room',
-            // TODO: Support show_room_tile in new room list: https://github.com/vector-im/riot-web/issues/14233
             show_room_tile: true, // make sure the room is visible in the list
             room_id: this.props.room.roomId,
             clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)),
@@ -153,7 +202,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
         this.setState({selected: isActive});
     };
 
-    private onNotificationsMenuOpenClick = (ev: InputEvent) => {
+    private onNotificationsMenuOpenClick = (ev: React.MouseEvent) => {
         ev.preventDefault();
         ev.stopPropagation();
         const target = ev.target as HTMLButtonElement;
@@ -164,7 +213,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
         this.setState({notificationsMenuPosition: null});
     };
 
-    private onGeneralMenuOpenClick = (ev: InputEvent) => {
+    private onGeneralMenuOpenClick = (ev: React.MouseEvent) => {
         ev.preventDefault();
         ev.stopPropagation();
         const target = ev.target as HTMLButtonElement;
@@ -193,8 +242,27 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
         ev.preventDefault();
         ev.stopPropagation();
 
-        // TODO: Support tagging: https://github.com/vector-im/riot-web/issues/14211
-        // TODO: XOR favourites and low priority: https://github.com/vector-im/riot-web/issues/14210
+        if (tagId === DefaultTagID.Favourite) {
+            const roomTags = RoomListStore.instance.getTagsForRoom(this.props.room);
+            const isFavourite = roomTags.includes(DefaultTagID.Favourite);
+            const removeTag = isFavourite ? DefaultTagID.Favourite : DefaultTagID.LowPriority;
+            const addTag = isFavourite ? null : DefaultTagID.Favourite;
+            dis.dispatch(RoomListActions.tagRoom(
+                MatrixClientPeg.get(),
+                this.props.room,
+                removeTag,
+                addTag,
+                undefined,
+                0
+            ));
+        } else {
+            console.warn(`Unexpected tag ${tagId} applied to ${this.props.room.room_id}`);
+        }
+
+        if ((ev as React.KeyboardEvent).key === Key.ENTER) {
+            // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
+            this.setState({generalMenuPosition: null}); // hide the menu
+        }
     };
 
     private onLeaveRoomClick = (ev: ButtonEvent) => {
@@ -219,11 +287,13 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
         this.setState({generalMenuPosition: null}); // hide the menu
     };
 
-    private async saveNotifState(ev: ButtonEvent, newState: ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE) {
+    private async saveNotifState(ev: ButtonEvent, newState: Volume) {
         ev.preventDefault();
         ev.stopPropagation();
         if (MatrixClientPeg.get().isGuest()) return;
 
+        // get key before we go async and React discards the nativeEvent
+        const key = (ev as React.KeyboardEvent).key;
         try {
             // TODO add local echo - https://github.com/vector-im/riot-web/issues/14280
             await setRoomNotifsState(this.props.room.roomId, newState);
@@ -233,7 +303,10 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
             console.error(error);
         }
 
-        this.setState({notificationsMenuPosition: null}); // Close the context menu
+        if (key === Key.ENTER) {
+            // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
+            this.setState({notificationsMenuPosition: null}); // hide the menu
+        }
     }
 
     private onClickAllNotifs = ev => this.saveNotifState(ev, ALL_MESSAGES);
@@ -316,26 +389,38 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
 
         // TODO: We could do with a proper invite context menu, unlike what showContextMenu suggests
 
+        const roomTags = RoomListStore.instance.getTagsForRoom(this.props.room);
+
+        const isFavorite = roomTags.includes(DefaultTagID.Favourite);
+        const favouriteIconClassName = isFavorite ? "mx_RoomTile2_iconFavorite" : "mx_RoomTile2_iconStar";
+        const favouriteLabelClassName = isFavorite ? "mx_RoomTile2_contextMenu_activeRow" : "";
+        const favouriteLabel = isFavorite ? _t("Favourited") : _t("Favourite");
+
         let contextMenu = null;
         if (this.state.generalMenuPosition) {
             contextMenu = (
                 <ContextMenu {...contextMenuBelow(this.state.generalMenuPosition)} onFinished={this.onCloseGeneralMenu}>
                     <div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu">
                         <div className="mx_IconizedContextMenu_optionList">
-                            <AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}>
-                                <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" />
-                                <span className="mx_IconizedContextMenu_label">{_t("Favourite")}</span>
-                            </AccessibleButton>
-                            <AccessibleButton onClick={this.onOpenRoomSettings}>
+                            <MenuItemCheckbox
+                                className={favouriteLabelClassName}
+                                onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}
+                                active={isFavorite}
+                                label={favouriteLabel}
+                            >
+                                <span className={classNames("mx_IconizedContextMenu_icon", favouriteIconClassName)} />
+                                <span className="mx_IconizedContextMenu_label">{favouriteLabel}</span>
+                            </MenuItemCheckbox>
+                            <MenuItem onClick={this.onOpenRoomSettings} label={_t("Settings")}>
                                 <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" />
                                 <span className="mx_IconizedContextMenu_label">{_t("Settings")}</span>
-                            </AccessibleButton>
+                            </MenuItem>
                         </div>
                         <div className="mx_IconizedContextMenu_optionList mx_RoomTile2_contextMenu_redRow">
-                            <AccessibleButton onClick={this.onLeaveRoomClick}>
+                            <MenuItem onClick={this.onLeaveRoomClick} label={_t("Leave Room")}>
                                 <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" />
                                 <span className="mx_IconizedContextMenu_label">{_t("Leave Room")}</span>
-                            </AccessibleButton>
+                            </MenuItem>
                         </div>
                     </div>
                 </ContextMenu>
@@ -357,7 +442,6 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
 
     public render(): React.ReactElement {
         // TODO: Invites: https://github.com/vector-im/riot-web/issues/14198
-        // TODO: a11y proper: https://github.com/vector-im/riot-web/issues/14180
 
         const classes = classNames({
             'mx_RoomTile2': true,
@@ -375,8 +459,9 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
 
         let badge: React.ReactNode;
         if (!this.props.isMinimized) {
+            // aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
             badge = (
-                <div className="mx_RoomTile2_badgeContainer">
+                <div className="mx_RoomTile2_badgeContainer" aria-hidden="true">
                     <NotificationBadge
                         notification={this.state.notificationState}
                         forceCount={false}
@@ -392,14 +477,14 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
         name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
 
         let messagePreview = null;
-        if (this.props.showMessagePreview && !this.props.isMinimized) {
+        if (this.showMessagePreview) {
             // The preview store heavily caches this info, so should be safe to hammer.
             const text = MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag);
 
             // Only show the preview if there is one to show.
             if (text) {
                 messagePreview = (
-                    <div className="mx_RoomTile2_messagePreview">
+                    <div className="mx_RoomTile2_messagePreview" id={messagePreviewId(this.props.room.roomId)}>
                         {text}
                     </div>
                 );
@@ -409,7 +494,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
         const nameClasses = classNames({
             "mx_RoomTile2_name": true,
             "mx_RoomTile2_nameWithPreview": !!messagePreview,
-            "mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.color >= NotificationColor.Bold,
+            "mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.isUnread,
         });
 
         let nameContainer = (
@@ -422,9 +507,30 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
         );
         if (this.props.isMinimized) nameContainer = null;
 
+        let ariaLabel = name;
+        // The following labels are written in such a fashion to increase screen reader efficiency (speed).
+        if (this.props.tag === DefaultTagID.Invite) {
+            // append nothing
+        } else if (this.state.notificationState.hasMentions) {
+            ariaLabel += " " + _t("%(count)s unread messages including mentions.", {
+                count: this.state.notificationState.count,
+            });
+        } else if (this.state.notificationState.hasUnreadCount) {
+            ariaLabel += " " + _t("%(count)s unread messages.", {
+                count: this.state.notificationState.count,
+            });
+        } else if (this.state.notificationState.isUnread) {
+            ariaLabel += " " + _t("Unread messages.");
+        }
+
+        let ariaDescribedBy: string;
+        if (this.showMessagePreview) {
+            ariaDescribedBy = messagePreviewId(this.props.room.roomId);
+        }
+
         return (
             <React.Fragment>
-                <RovingTabIndexWrapper>
+                <RovingTabIndexWrapper inputRef={this.roomTileRef}>
                     {({onFocus, isActive, ref}) =>
                         <AccessibleButton
                             onFocus={onFocus}
@@ -434,14 +540,17 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
                             onMouseEnter={this.onTileMouseEnter}
                             onMouseLeave={this.onTileMouseLeave}
                             onClick={this.onTileClick}
-                            role="treeitem"
                             onContextMenu={this.onContextMenu}
+                            role="treeitem"
+                            aria-label={ariaLabel}
+                            aria-selected={this.state.selected}
+                            aria-describedby={ariaDescribedBy}
                         >
                             {roomAvatar}
                             {nameContainer}
                             {badge}
-                            {this.renderNotificationsMenu(isActive)}
                             {this.renderGeneralMenu()}
+                            {this.renderNotificationsMenu(isActive)}
                         </AccessibleButton>
                     }
                 </RovingTabIndexWrapper>
diff --git a/src/components/views/rooms/TemporaryTile.tsx b/src/components/views/rooms/TemporaryTile.tsx
index b6c165ecda..a3ee7eb5bd 100644
--- a/src/components/views/rooms/TemporaryTile.tsx
+++ b/src/components/views/rooms/TemporaryTile.tsx
@@ -18,16 +18,15 @@ import React from "react";
 import classNames from "classnames";
 import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
 import AccessibleButton from "../../views/elements/AccessibleButton";
-import { INotificationState } from "../../../stores/notifications/INotificationState";
 import NotificationBadge from "./NotificationBadge";
-import { NotificationColor } from "../../../stores/notifications/NotificationColor";
+import { NotificationState } from "../../../stores/notifications/NotificationState";
 
 interface IProps {
     isMinimized: boolean;
     isSelected: boolean;
     displayName: string;
     avatar: React.ReactElement;
-    notificationState: INotificationState;
+    notificationState: NotificationState;
     onClick: () => void;
 }
 
@@ -74,7 +73,7 @@ export default class TemporaryTile extends React.Component<IProps, IState> {
 
         const nameClasses = classNames({
             "mx_RoomTile2_name": true,
-            "mx_RoomTile2_nameHasUnreadEvents": this.props.notificationState.color >= NotificationColor.Bold,
+            "mx_RoomTile2_nameHasUnreadEvents": this.props.notificationState.isUnread,
         });
 
         let nameContainer = (
diff --git a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.js b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.js
index f57d5d3798..2edf3021dc 100644
--- a/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.js
+++ b/src/components/views/settings/tabs/room/AdvancedRoomSettingsTab.js
@@ -22,6 +22,10 @@ import * as sdk from "../../../../..";
 import AccessibleButton from "../../../elements/AccessibleButton";
 import Modal from "../../../../../Modal";
 import dis from "../../../../../dispatcher/dispatcher";
+import RoomListStore from "../../../../../stores/room-list/RoomListStore2";
+import RoomListActions from "../../../../../actions/RoomListActions";
+import { DefaultTagID } from '../../../../../stores/room-list/models';
+import LabelledToggleSwitch from '../../../elements/LabelledToggleSwitch';
 
 export default class AdvancedRoomSettingsTab extends React.Component {
     static propTypes = {
@@ -29,12 +33,16 @@ export default class AdvancedRoomSettingsTab extends React.Component {
         closeSettingsFn: PropTypes.func.isRequired,
     };
 
-    constructor() {
-        super();
+    constructor(props) {
+        super(props);
+
+        const room = MatrixClientPeg.get().getRoom(props.roomId);
+        const roomTags = RoomListStore.instance.getTagsForRoom(room);
 
         this.state = {
             // This is eventually set to the value of room.getRecommendedVersion()
             upgradeRecommendation: null,
+            isLowPriorityRoom: roomTags.includes(DefaultTagID.LowPriority),
         };
     }
 
@@ -86,6 +94,25 @@ export default class AdvancedRoomSettingsTab extends React.Component {
         this.props.closeSettingsFn();
     };
 
+    _onToggleLowPriorityTag = (e) => {
+        this.setState({
+            isLowPriorityRoom: !this.state.isLowPriorityRoom,
+        });
+
+        const removeTag = this.state.isLowPriorityRoom ? DefaultTagID.LowPriority : DefaultTagID.Favourite;
+        const addTag = this.state.isLowPriorityRoom ? null : DefaultTagID.LowPriority;
+        const client = MatrixClientPeg.get();
+
+        dis.dispatch(RoomListActions.tagRoom(
+            client,
+            client.getRoom(this.props.roomId),
+            removeTag,
+            addTag,
+            undefined,
+            0,
+        ));
+    }
+
     render() {
         const client = MatrixClientPeg.get();
         const room = client.getRoom(this.props.roomId);
@@ -156,6 +183,17 @@ export default class AdvancedRoomSettingsTab extends React.Component {
                         {_t("Open Devtools")}
                     </AccessibleButton>
                 </div>
+                <div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
+                    <span className='mx_SettingsTab_subheading'>{_t('Make this room low priority')}</span>
+                    <LabelledToggleSwitch
+                        value={this.state.isLowPriorityRoom}
+                        onChange={this._onToggleLowPriorityTag}
+                        label={_t(
+                            "Low priority rooms show up at the bottom of your room list" +
+                            " in a dedicated section at the bottom of your room list",
+                        )}
+                    />
+                </div>
             </div>
         );
     }
diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx
index 325d5cede6..6826eed7b7 100644
--- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx
+++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx
@@ -402,6 +402,12 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
                     useCheckbox={true}
                     disabled={this.state.useIRCLayout}
                 />
+                <SettingsFlag
+                    name="useIRCLayout"
+                    level={SettingLevel.DEVICE}
+                    useCheckbox={true}
+                    onChange={(checked) => this.setState({useIRCLayout: checked})}
+                />
                 <SettingsFlag
                     name="useSystemFont"
                     level={SettingLevel.DEVICE}
@@ -440,7 +446,6 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
                 </div>
                 {this.renderThemeSection()}
                 {this.renderFontSection()}
-                {SettingsStore.isFeatureEnabled("feature_irc_ui") ? this.renderLayoutSection() : null}
                 {this.renderAdvancedSection()}
             </div>
         );
diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js
index 40b622cf37..abe6b48712 100644
--- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js
+++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js
@@ -32,12 +32,12 @@ export default class PreferencesUserSettingsTab extends React.Component {
         'breadcrumbs',
     ];
 
-    // TODO: Remove temp structures: https://github.com/vector-im/riot-web/issues/14231
+    // TODO: Remove temp structures: https://github.com/vector-im/riot-web/issues/14367
     static ROOM_LIST_2_SETTINGS = [
         'breadcrumbs',
     ];
 
-    // TODO: Remove temp structures: https://github.com/vector-im/riot-web/issues/14231
+    // TODO: Remove temp structures: https://github.com/vector-im/riot-web/issues/14367
     static eligibleRoomListSettings = () => {
         if (RoomListStoreTempProxy.isUsingNewStore()) {
             return PreferencesUserSettingsTab.ROOM_LIST_2_SETTINGS;
diff --git a/src/components/views/toasts/GenericToast.tsx b/src/components/views/toasts/GenericToast.tsx
index 9f8885ba47..6cd881b9eb 100644
--- a/src/components/views/toasts/GenericToast.tsx
+++ b/src/components/views/toasts/GenericToast.tsx
@@ -14,13 +14,13 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React, {ReactChild} from "react";
+import React, {ReactNode} from "react";
 
 import FormButton from "../elements/FormButton";
 import {XOR} from "../../../@types/common";
 
 export interface IProps {
-    description: ReactChild;
+    description: ReactNode;
     acceptLabel: string;
 
     onAccept();
diff --git a/src/components/views/voip/CallContainer.tsx b/src/components/views/voip/CallContainer.tsx
new file mode 100644
index 0000000000..0e901fac7d
--- /dev/null
+++ b/src/components/views/voip/CallContainer.tsx
@@ -0,0 +1,37 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+import IncomingCallBox2 from './IncomingCallBox2';
+import CallPreview from './CallPreview2';
+import * as VectorConferenceHandler from '../../../VectorConferenceHandler';
+
+interface IProps {
+
+}
+
+interface IState {
+
+}
+
+export default class CallContainer extends React.PureComponent<IProps, IState> {
+    public render() {
+        return <div className="mx_CallContainer">
+            <IncomingCallBox2 />
+            <CallPreview ConferenceHandler={VectorConferenceHandler} />
+        </div>;
+    }
+}
\ No newline at end of file
diff --git a/src/components/views/voip/CallPreview2.tsx b/src/components/views/voip/CallPreview2.tsx
new file mode 100644
index 0000000000..1f2caf5ef8
--- /dev/null
+++ b/src/components/views/voip/CallPreview2.tsx
@@ -0,0 +1,129 @@
+/*
+Copyright 2017, 2018 New Vector Ltd
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+
+import React from 'react';
+
+import CallView from "./CallView2";
+import RoomViewStore from '../../../stores/RoomViewStore';
+import CallHandler from '../../../CallHandler';
+import dis from '../../../dispatcher/dispatcher';
+import { ActionPayload } from '../../../dispatcher/payloads';
+import PersistentApp from "../elements/PersistentApp";
+import SettingsStore from "../../../settings/SettingsStore";
+
+interface IProps {
+    // A Conference Handler implementation
+    // Must have a function signature:
+    //  getConferenceCallForRoom(roomId: string): MatrixCall
+    ConferenceHandler: any;
+}
+
+interface IState {
+    roomId: string;
+    activeCall: any;
+    newRoomListActive: boolean;
+}
+
+export default class CallPreview extends React.Component<IProps, IState> {
+    private roomStoreToken: any;
+    private dispatcherRef: string;
+    private settingsWatcherRef: string;
+
+    constructor(props: IProps) {
+        super(props);
+
+        this.state = {
+            roomId: RoomViewStore.getRoomId(),
+            activeCall: CallHandler.getAnyActiveCall(),
+            newRoomListActive: SettingsStore.getValue("feature_new_room_list"),
+        };
+
+        this.settingsWatcherRef = SettingsStore.watchSetting("feature_new_room_list", null, (name, roomId, level, valAtLevel, newVal) => this.setState({
+            newRoomListActive: newVal,
+        }));
+    }
+
+    public componentDidMount() {
+        this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
+        this.dispatcherRef = dis.register(this.onAction);
+    }
+
+    public componentWillUnmount() {
+        if (this.roomStoreToken) {
+            this.roomStoreToken.remove();
+        }
+        dis.unregister(this.dispatcherRef);
+        SettingsStore.unwatchSetting(this.settingsWatcherRef);
+    }
+
+    private onRoomViewStoreUpdate = (payload) => {
+        if (RoomViewStore.getRoomId() === this.state.roomId) return;
+        this.setState({
+            roomId: RoomViewStore.getRoomId(),
+        });
+    };
+
+    private onAction = (payload: ActionPayload) => {
+        switch (payload.action) {
+            // listen for call state changes to prod the render method, which
+            // may hide the global CallView if the call it is tracking is dead
+            case 'call_state':
+                this.setState({
+                    activeCall: CallHandler.getAnyActiveCall(),
+                });
+                break;
+        }
+    };
+
+    private onCallViewClick = () => {
+        const call = CallHandler.getAnyActiveCall();
+        if (call) {
+            dis.dispatch({
+                action: 'view_room',
+                room_id: call.groupRoomId || call.roomId,
+            });
+        }
+    };
+
+    public render() {
+        if (this.state.newRoomListActive) {
+            const callForRoom = CallHandler.getCallForRoom(this.state.roomId);
+            const showCall = (
+                this.state.activeCall &&
+                this.state.activeCall.call_state === 'connected' &&
+                !callForRoom
+            );
+
+            if (showCall) {
+                return (
+                    <CallView
+                        className="mx_CallPreview" onClick={this.onCallViewClick}
+                        ConferenceHandler={this.props.ConferenceHandler}
+                        showHangup={true}
+                    />
+                );
+            }
+
+            return <PersistentApp />;
+        }
+
+        return null;
+    }
+}
+
diff --git a/src/components/views/voip/CallView2.tsx b/src/components/views/voip/CallView2.tsx
new file mode 100644
index 0000000000..c80d82d395
--- /dev/null
+++ b/src/components/views/voip/CallView2.tsx
@@ -0,0 +1,200 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+
+import React, {createRef} from 'react';
+import Room from 'matrix-js-sdk/src/models/room';
+import dis from '../../../dispatcher/dispatcher';
+import CallHandler from '../../../CallHandler';
+import {MatrixClientPeg} from '../../../MatrixClientPeg';
+import { _t } from '../../../languageHandler';
+import AccessibleButton from '../elements/AccessibleButton';
+import VideoView from "./VideoView";
+import RoomAvatar from "../avatars/RoomAvatar";
+import PulsedAvatar from '../avatars/PulsedAvatar';
+
+interface IProps {
+        // js-sdk room object. If set, we will only show calls for the given
+        // room; if not, we will show any active call.
+        room?: Room;
+
+        // A Conference Handler implementation
+        // Must have a function signature:
+        //  getConferenceCallForRoom(roomId: string): MatrixCall
+        ConferenceHandler?: any;
+
+        // maxHeight style attribute for the video panel
+        maxVideoHeight?: number;
+
+        // a callback which is called when the user clicks on the video div
+        onClick?: React.MouseEventHandler;
+
+        // a callback which is called when the content in the callview changes
+        // in a way that is likely to cause a resize.
+        onResize?: any;
+
+        // classname applied to view,
+        className?: string;
+
+        // Whether to show the hang up icon:W
+        showHangup?: boolean;
+}
+
+interface IState {
+    call: any;
+}
+
+export default class CallView extends React.Component<IProps, IState> {
+    private videoref: React.RefObject<any>;
+    private dispatcherRef: string;
+    public call: any;
+
+    constructor(props: IProps) {
+        super(props);
+
+        this.state = {
+            // the call this view is displaying (if any)
+            call: null,
+        };
+
+        this.videoref = createRef();
+    }
+
+    public componentDidMount() {
+        this.dispatcherRef = dis.register(this.onAction);
+        this.showCall();
+    }
+
+    public componentWillUnmount() {
+        dis.unregister(this.dispatcherRef);
+    }
+
+    private onAction = (payload) => {
+        // don't filter out payloads for room IDs other than props.room because
+        // we may be interested in the conf 1:1 room
+        if (payload.action !== 'call_state') {
+            return;
+        }
+        this.showCall();
+    };
+
+    private showCall() {
+        let call;
+
+        if (this.props.room) {
+            const roomId = this.props.room.roomId;
+            call = CallHandler.getCallForRoom(roomId) ||
+                (this.props.ConferenceHandler ?
+                 this.props.ConferenceHandler.getConferenceCallForRoom(roomId) :
+                 null
+                );
+
+            if (this.call) {
+                this.setState({ call: call });
+            }
+        } else {
+            call = CallHandler.getAnyActiveCall();
+            // Ignore calls if we can't get the room associated with them.
+            // I think the underlying problem is that the js-sdk sends events
+            // for calls before it has made the rooms available in the store,
+            // although this isn't confirmed.
+            if (MatrixClientPeg.get().getRoom(call.roomId) === null) {
+                call = null;
+            }
+            this.setState({ call: call });
+        }
+
+        if (call) {
+            call.setLocalVideoElement(this.getVideoView().getLocalVideoElement());
+            call.setRemoteVideoElement(this.getVideoView().getRemoteVideoElement());
+            // always use a separate element for audio stream playback.
+            // this is to let us move CallView around the DOM without interrupting remote audio
+            // during playback, by having the audio rendered by a top-level <audio/> element.
+            // rather than being rendered by the main remoteVideo <video/> element.
+            call.setRemoteAudioElement(this.getVideoView().getRemoteAudioElement());
+        }
+        if (call && call.type === "video" && call.call_state !== "ended" && call.call_state !== "ringing") {
+            // if this call is a conf call, don't display local video as the
+            // conference will have us in it
+            this.getVideoView().getLocalVideoElement().style.display = (
+                call.confUserId ? "none" : "block"
+            );
+            this.getVideoView().getRemoteVideoElement().style.display = "block";
+        } else {
+            this.getVideoView().getLocalVideoElement().style.display = "none";
+            this.getVideoView().getRemoteVideoElement().style.display = "none";
+            dis.dispatch({action: 'video_fullscreen', fullscreen: false});
+        }
+
+        if (this.props.onResize) {
+            this.props.onResize();
+        }
+    }
+
+    private getVideoView() {
+        return this.videoref.current;
+    }
+
+    public render() {
+        let view: React.ReactNode;
+        if (this.state.call && this.state.call.type === "voice") {
+            const client = MatrixClientPeg.get();
+            const callRoom = client.getRoom(this.state.call.roomId);
+
+            view = <AccessibleButton className="mx_CallView2_voice" onClick={this.props.onClick}>
+                <PulsedAvatar>
+                    <RoomAvatar
+                        room={callRoom}
+                        height={35}
+                        width={35}
+                    />
+                </PulsedAvatar>
+                <div>
+                    <h1>{callRoom.name}</h1>
+                    <p>{ _t("Active call") }</p>
+                </div>
+            </AccessibleButton>;
+        } else {
+            view = <VideoView
+                ref={this.videoref}
+                onClick={this.props.onClick}
+                onResize={this.props.onResize}
+                maxHeight={this.props.maxVideoHeight}
+            />;
+        }
+
+        let hangup: React.ReactNode;
+        if (this.props.showHangup) {
+            hangup = <div
+                className="mx_CallView2_hangup"
+                onClick={() => {
+                    dis.dispatch({
+                        action: 'hangup',
+                        room_id: this.state.call.roomId,
+                    });
+                }}
+            />;
+        }
+
+        return <div className={this.props.className}>
+            {view}
+            {hangup}
+        </div>;
+    }
+}
+
diff --git a/src/components/views/voip/IncomingCallBox2.tsx b/src/components/views/voip/IncomingCallBox2.tsx
new file mode 100644
index 0000000000..6dfcb4bcee
--- /dev/null
+++ b/src/components/views/voip/IncomingCallBox2.tsx
@@ -0,0 +1,141 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2018 New Vector Ltd
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
+
+import React from 'react';
+import {MatrixClientPeg} from '../../../MatrixClientPeg';
+import dis from '../../../dispatcher/dispatcher';
+import { _t } from '../../../languageHandler';
+import { ActionPayload } from '../../../dispatcher/payloads';
+import CallHandler from '../../../CallHandler';
+import PulsedAvatar from '../avatars/PulsedAvatar';
+import RoomAvatar from '../avatars/RoomAvatar';
+import FormButton from '../elements/FormButton';
+
+interface IProps {
+}
+
+interface IState {
+    incomingCall: any;
+}
+
+export default class IncomingCallBox2 extends React.Component<IProps, IState> {
+    private dispatcherRef: string;
+
+    constructor(props: IProps) {
+        super(props);
+
+        this.dispatcherRef = dis.register(this.onAction);
+        this.state = {
+            incomingCall: null,
+        };
+    }
+
+    public componentWillUnmount() {
+        dis.unregister(this.dispatcherRef);
+    }
+
+    private onAction = (payload: ActionPayload) => {
+        switch (payload.action) {
+            case 'call_state':
+                const call = CallHandler.getCall(payload.room_id);
+                if (call && call.call_state === 'ringing') {
+                    this.setState({
+                        incomingCall: call,
+                    });
+                } else {
+                    this.setState({
+                        incomingCall: null,
+                    });
+                }
+        }
+    };
+
+    private onAnswerClick: React.MouseEventHandler = (e) => {
+        e.stopPropagation();
+        dis.dispatch({
+            action: 'answer',
+            room_id: this.state.incomingCall.roomId,
+        });
+    };
+
+    private onRejectClick: React.MouseEventHandler = (e) => {
+        e.stopPropagation();
+        dis.dispatch({
+            action: 'hangup',
+            room_id: this.state.incomingCall.roomId,
+        });
+    };
+
+    public render() {
+        if (!this.state.incomingCall) {
+            return null;
+        }
+
+        let room = null;
+        if (this.state.incomingCall) {
+            room = MatrixClientPeg.get().getRoom(this.state.incomingCall.roomId);
+        }
+
+        const caller = room ? room.name : _t("Unknown caller");
+
+        let incomingCallText = null;
+        if (this.state.incomingCall) {
+            if (this.state.incomingCall.type === "voice") {
+                incomingCallText = _t("Incoming voice call");
+            } else if (this.state.incomingCall.type === "video") {
+                incomingCallText = _t("Incoming video call");
+            } else {
+                incomingCallText = _t("Incoming call");
+            }
+        }
+
+        return <div className="mx_IncomingCallBox2">
+            <div className="mx_IncomingCallBox2_CallerInfo">
+                <PulsedAvatar>
+                    <RoomAvatar
+                        room={room}
+                        height={32}
+                        width={32}
+                    />
+                </PulsedAvatar>
+                <div>
+                    <h1>{caller}</h1>
+                    <p>{incomingCallText}</p>
+                </div>
+            </div>
+            <div className="mx_IncomingCallBox2_buttons">
+                <FormButton
+                    className={"mx_IncomingCallBox2_decline"}
+                    onClick={this.onRejectClick}
+                    kind="danger"
+                    label={_t("Decline")}
+                />
+                <div className="mx_IncomingCallBox2_spacer" />
+                <FormButton
+                    className={"mx_IncomingCallBox2_accept"}
+                    onClick={this.onAnswerClick}
+                    kind="primary"
+                    label={_t("Accept")}
+                />
+            </div>
+        </div>;
+    }
+}
+
diff --git a/src/contexts/MatrixClientContext.js b/src/contexts/MatrixClientContext.ts
similarity index 85%
rename from src/contexts/MatrixClientContext.js
rename to src/contexts/MatrixClientContext.ts
index 54a23ca132..7e8a92064d 100644
--- a/src/contexts/MatrixClientContext.js
+++ b/src/contexts/MatrixClientContext.ts
@@ -15,7 +15,8 @@ limitations under the License.
 */
 
 import { createContext } from "react";
+import { MatrixClient } from "matrix-js-sdk/src/client";
 
-const MatrixClientContext = createContext(undefined);
+const MatrixClientContext = createContext<MatrixClient>(undefined);
 MatrixClientContext.displayName = "MatrixClientContext";
 export default MatrixClientContext;
diff --git a/src/createRoom.js b/src/createRoom.ts
similarity index 81%
rename from src/createRoom.js
rename to src/createRoom.ts
index affdf196a7..c436196c27 100644
--- a/src/createRoom.js
+++ b/src/createRoom.ts
@@ -15,6 +15,9 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+import {MatrixClient} from "matrix-js-sdk/src/client";
+import {Room} from "matrix-js-sdk/src/models/room";
+
 import {MatrixClientPeg} from './MatrixClientPeg';
 import Modal from './Modal';
 import * as sdk from './index';
@@ -26,6 +29,56 @@ import {getAddressType} from "./UserAddress";
 
 const E2EE_WK_KEY = "im.vector.riot.e2ee";
 
+// TODO move these interfaces over to js-sdk once it has been typescripted enough to accept them
+enum Visibility {
+    Public = "public",
+    Private = "private",
+}
+
+enum Preset {
+    PrivateChat = "private_chat",
+    TrustedPrivateChat = "trusted_private_chat",
+    PublicChat = "public_chat",
+}
+
+interface Invite3PID {
+    id_server: string;
+    id_access_token?: string; // this gets injected by the js-sdk
+    medium: string;
+    address: string;
+}
+
+interface IStateEvent {
+    type: string;
+    state_key?: string; // defaults to an empty string
+    content: object;
+}
+
+interface ICreateOpts {
+    visibility?: Visibility;
+    room_alias_name?: string;
+    name?: string;
+    topic?: string;
+    invite?: string[];
+    invite_3pid?: Invite3PID[];
+    room_version?: string;
+    creation_content?: object;
+    initial_state?: IStateEvent[];
+    preset?: Preset;
+    is_direct?: boolean;
+    power_level_content_override?: object;
+}
+
+interface IOpts {
+    dmUserId?: string;
+    createOpts?: ICreateOpts;
+    spinner?: boolean;
+    guestAccess?: boolean;
+    encryption?: boolean;
+    inlineErrors?: boolean;
+    andView?: boolean;
+}
+
 /**
  * Create a new room, and switch to it.
  *
@@ -40,11 +93,12 @@ const E2EE_WK_KEY = "im.vector.riot.e2ee";
  *     Default: False
  * @param {bool=} opts.inlineErrors True to raise errors off the promise instead of resolving to null.
  *     Default: False
+ * @param {bool=} opts.andView True to dispatch an action to view the room once it has been created.
  *
  * @returns {Promise} which resolves to the room id, or null if the
  * action was aborted or failed.
  */
-export default function createRoom(opts) {
+export default function createRoom(opts: IOpts): Promise<string | null> {
     opts = opts || {};
     if (opts.spinner === undefined) opts.spinner = true;
     if (opts.guestAccess === undefined) opts.guestAccess = true;
@@ -59,12 +113,12 @@ export default function createRoom(opts) {
         return Promise.resolve(null);
     }
 
-    const defaultPreset = opts.dmUserId ? 'trusted_private_chat' : 'private_chat';
+    const defaultPreset = opts.dmUserId ? Preset.TrustedPrivateChat : Preset.PrivateChat;
 
     // set some defaults for the creation
     const createOpts = opts.createOpts || {};
     createOpts.preset = createOpts.preset || defaultPreset;
-    createOpts.visibility = createOpts.visibility || 'private';
+    createOpts.visibility = createOpts.visibility || Visibility.Private;
     if (opts.dmUserId && createOpts.invite === undefined) {
         switch (getAddressType(opts.dmUserId)) {
             case 'mx-user-id':
@@ -166,7 +220,7 @@ export default function createRoom(opts) {
     });
 }
 
-export function findDMForUser(client, userId) {
+export function findDMForUser(client: MatrixClient, userId: string): Room {
     const roomIds = DMRoomMap.shared().getDMRoomsForUserId(userId);
     const rooms = roomIds.map(id => client.getRoom(id));
     const suitableDMRooms = rooms.filter(r => {
@@ -189,7 +243,7 @@ export function findDMForUser(client, userId) {
  * NOTE: this assumes you've just created the room and there's not been an opportunity
  * for other code to run, so we shouldn't miss RoomState.newMember when it comes by.
  */
-export async function _waitForMember(client, roomId, userId, opts = { timeout: 1500 }) {
+export async function _waitForMember(client: MatrixClient, roomId: string, userId: string, opts = { timeout: 1500 }) {
     const { timeout } = opts;
     let handler;
     return new Promise((resolve) => {
@@ -212,7 +266,7 @@ export async function _waitForMember(client, roomId, userId, opts = { timeout: 1
  * Ensure that for every user in a room, there is at least one device that we
  * can encrypt to.
  */
-export async function canEncryptToAllUsers(client, userIds) {
+export async function canEncryptToAllUsers(client: MatrixClient, userIds: string[]) {
     const usersDeviceMap = await client.downloadKeys(userIds);
     // { "@user:host": { "DEVICE": {...}, ... }, ... }
     return Object.values(usersDeviceMap).every((userDevices) =>
@@ -221,7 +275,7 @@ export async function canEncryptToAllUsers(client, userIds) {
     );
 }
 
-export async function ensureDMExists(client, userId) {
+export async function ensureDMExists(client: MatrixClient, userId: string): Promise<string> {
     const existingDMRoom = findDMForUser(client, userId);
     let roomId;
     if (existingDMRoom) {
diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts
index 379a0a4451..9be674b59e 100644
--- a/src/dispatcher/actions.ts
+++ b/src/dispatcher/actions.ts
@@ -79,4 +79,9 @@ export enum Action {
      * Sets a system font. Should be used with UpdateSystemFontPayload
      */
     UpdateSystemFont = "update_system_font",
+
+    /**
+     * Changes room based on room list order and payload parameters. Should be used with ViewRoomDeltaPayload.
+     */
+    ViewRoomDelta = "view_room_delta",
 }
diff --git a/src/dispatcher/payloads/ViewRoomDeltaPayload.ts b/src/dispatcher/payloads/ViewRoomDeltaPayload.ts
new file mode 100644
index 0000000000..de33a88b2e
--- /dev/null
+++ b/src/dispatcher/payloads/ViewRoomDeltaPayload.ts
@@ -0,0 +1,32 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { ActionPayload } from "../payloads";
+import { Action } from "../actions";
+
+export interface ViewRoomDeltaPayload extends ActionPayload {
+    action: Action.ViewRoomDelta;
+
+    /**
+     * The delta index of the room to view.
+     */
+    delta: number;
+
+    /**
+     * Optionally, whether or not to filter to unread (Bold/Grey/Red) rooms only. (Default: false)
+     */
+    unread?: boolean;
+}
diff --git a/src/groups.js b/src/groups.js
index 860cf71fff..e73af15c79 100644
--- a/src/groups.js
+++ b/src/groups.js
@@ -15,7 +15,8 @@ limitations under the License.
 */
 
 import PropTypes from 'prop-types';
-import { _t } from './languageHandler.js';
+
+import { _t } from './languageHandler';
 
 export const GroupMemberType = PropTypes.shape({
     userId: PropTypes.string.isRequired,
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 5a79b01003..4b1dfe2b8e 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -488,7 +488,6 @@
     "Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)",
     "Use the improved room list (will refresh to apply changes)": "Use the improved room list (will refresh to apply changes)",
     "Support adding custom themes": "Support adding custom themes",
-    "Enable IRC layout option in the appearance tab": "Enable IRC layout option in the appearance tab",
     "Show info about bridges in room settings": "Show info about bridges in room settings",
     "Font size": "Font size",
     "Use custom size": "Use custom size",
@@ -538,7 +537,7 @@
     "How fast should messages be downloaded.": "How fast should messages be downloaded.",
     "Manually verify all remote sessions": "Manually verify all remote sessions",
     "IRC display name width": "IRC display name width",
-    "Use IRC layout": "Use IRC layout",
+    "Enable experimental, compact IRC style layout": "Enable experimental, compact IRC style layout",
     "Collecting app version information": "Collecting app version information",
     "Collecting logs": "Collecting logs",
     "Uploading report": "Uploading report",
@@ -557,12 +556,17 @@
     "My Ban List": "My Ban List",
     "This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!",
     "Active call (%(roomName)s)": "Active call (%(roomName)s)",
+    "Active call": "Active call",
     "unknown caller": "unknown caller",
     "Incoming voice call from %(name)s": "Incoming voice call from %(name)s",
     "Incoming video call from %(name)s": "Incoming video call from %(name)s",
     "Incoming call from %(name)s": "Incoming call from %(name)s",
     "Decline": "Decline",
     "Accept": "Accept",
+    "Unknown caller": "Unknown caller",
+    "Incoming voice call": "Incoming voice call",
+    "Incoming video call": "Incoming video call",
+    "Incoming call": "Incoming call",
     "The other party cancelled the verification.": "The other party cancelled the verification.",
     "Verified!": "Verified!",
     "You've successfully verified this user.": "You've successfully verified this user.",
@@ -965,6 +969,8 @@
     "Room version:": "Room version:",
     "Developer options": "Developer options",
     "Open Devtools": "Open Devtools",
+    "Make this room low priority": "Make this room low priority",
+    "Low priority rooms show up at the bottom of your room list in a dedicated section at the bottom of your room list": "Low priority rooms show up at the bottom of your room list in a dedicated section at the bottom of your room list",
     "This room is bridging messages to the following platforms. <a>Learn more.</a>": "This room is bridging messages to the following platforms. <a>Learn more.</a>",
     "This room isn’t bridging messages to any platforms. <a>Learn more.</a>": "This room isn’t bridging messages to any platforms. <a>Learn more.</a>",
     "Bridges": "Bridges",
@@ -1199,14 +1205,16 @@
     "Securely back up your keys to avoid losing them. <a>Learn more.</a>": "Securely back up your keys to avoid losing them. <a>Learn more.</a>",
     "Not now": "Not now",
     "Don't ask me again": "Don't ask me again",
-    "Sort by": "Sort by",
-    "Activity": "Activity",
-    "A-Z": "A-Z",
     "Unread rooms": "Unread rooms",
     "Always show first": "Always show first",
     "Show": "Show",
     "Message preview": "Message preview",
+    "Sort by": "Sort by",
+    "Activity": "Activity",
+    "A-Z": "A-Z",
     "List options": "List options",
+    "Jump to first unread room.": "Jump to first unread room.",
+    "Jump to first invite.": "Jump to first invite.",
     "Add room": "Add room",
     "Show %(count)s more|other": "Show %(count)s more",
     "Show %(count)s more|one": "Show %(count)s more",
@@ -1221,6 +1229,7 @@
     "All messages": "All messages",
     "Mentions & Keywords": "Mentions & Keywords",
     "Notification options": "Notification options",
+    "Favourited": "Favourited",
     "Favourite": "Favourite",
     "Leave Room": "Leave Room",
     "Room options": "Room options",
@@ -2088,6 +2097,8 @@
     "Find a room…": "Find a room…",
     "Find a room… (e.g. %(exampleRoom)s)": "Find a room… (e.g. %(exampleRoom)s)",
     "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.",
+    "Clear filter": "Clear filter",
+    "Search rooms": "Search rooms",
     "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.": "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.",
     "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.",
     "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.",
@@ -2097,10 +2108,7 @@
     "%(count)s <resendText>Resend all</resendText> or <cancelText>cancel all</cancelText> now. You can also select individual messages to resend or cancel.|one": "<resendText>Resend message</resendText> or <cancelText>cancel message</cancelText> now.",
     "Connectivity to the server has been lost.": "Connectivity to the server has been lost.",
     "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.",
-    "Active call": "Active call",
     "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?",
-    "Jump to first unread room.": "Jump to first unread room.",
-    "Jump to first invite.": "Jump to first invite.",
     "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?",
     "You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?",
     "Search failed": "Search failed",
@@ -2115,7 +2123,6 @@
     "Click to mute video": "Click to mute video",
     "Click to unmute audio": "Click to unmute audio",
     "Click to mute audio": "Click to mute audio",
-    "Clear filter": "Clear filter",
     "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.": "Tried to load a specific point in this room's timeline, but you do not have permission to view the message in question.",
     "Tried to load a specific point in this room's timeline, but was unable to find it.": "Tried to load a specific point in this room's timeline, but was unable to find it.",
     "Failed to load timeline position": "Failed to load timeline position",
@@ -2128,9 +2135,8 @@
     "Switch theme": "Switch theme",
     "Security & privacy": "Security & privacy",
     "All settings": "All settings",
-    "Archived rooms": "Archived rooms",
     "Feedback": "Feedback",
-    "Account settings": "Account settings",
+    "User menu": "User menu",
     "Could not load user profile": "Could not load user profile",
     "Verify this login": "Verify this login",
     "Session verified": "Session verified",
diff --git a/src/languageHandler.js b/src/languageHandler.tsx
similarity index 87%
rename from src/languageHandler.js
rename to src/languageHandler.tsx
index 79a172015a..91d90d4e6c 100644
--- a/src/languageHandler.js
+++ b/src/languageHandler.tsx
@@ -1,7 +1,7 @@
 /*
 Copyright 2017 MTRNord and Cooperative EITA
 Copyright 2017 Vector Creations Ltd.
-Copyright 2019 The Matrix.org Foundation C.I.C.
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
 Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
 
 Licensed under the Apache License, Version 2.0 (the "License");
@@ -20,10 +20,11 @@ limitations under the License.
 import request from 'browser-request';
 import counterpart from 'counterpart';
 import React from 'react';
+
 import SettingsStore, {SettingLevel} from "./settings/SettingsStore";
 import PlatformPeg from "./PlatformPeg";
 
-// $webapp is a webpack resolve alias pointing to the output directory, see webpack config
+// @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config
 import webpackLangJsonUrl from "$webapp/i18n/languages.json";
 
 const i18nFolder = 'i18n/';
@@ -37,27 +38,31 @@ counterpart.setSeparator('|');
 // Fall back to English
 counterpart.setFallbackLocale('en');
 
+interface ITranslatableError extends Error {
+    translatedMessage: string;
+}
+
 /**
  * Helper function to create an error which has an English message
  * with a translatedMessage property for use by the consumer.
  * @param {string} message Message to translate.
  * @returns {Error} The constructed error.
  */
-export function newTranslatableError(message) {
-    const error = new Error(message);
+export function newTranslatableError(message: string) {
+    const error = new Error(message) as ITranslatableError;
     error.translatedMessage = _t(message);
     return error;
 }
 
 // Function which only purpose is to mark that a string is translatable
 // Does not actually do anything. It's helpful for automatic extraction of translatable strings
-export function _td(s) {
+export function _td(s: string): string {
     return s;
 }
 
 // Wrapper for counterpart's translation function so that it handles nulls and undefineds properly
 // Takes the same arguments as counterpart.translate()
-function safeCounterpartTranslate(text, options) {
+function safeCounterpartTranslate(text: string, options?: object) {
     // Horrible hack to avoid https://github.com/vector-im/riot-web/issues/4191
     // The interpolation library that counterpart uses does not support undefined/null
     // values and instead will throw an error. This is a problem since everywhere else
@@ -89,6 +94,13 @@ function safeCounterpartTranslate(text, options) {
     return translated;
 }
 
+interface IVariables {
+    count?: number;
+    [key: string]: number | string;
+}
+
+type Tags = Record<string, (sub: string) => React.ReactNode>;
+
 /*
  * Translates text and optionally also replaces XML-ish elements in the text with e.g. React components
  * @param {string} text The untranslated text, e.g "click <a>here</a> now to %(foo)s".
@@ -105,7 +117,9 @@ function safeCounterpartTranslate(text, options) {
  *
  * @return a React <span> component if any non-strings were used in substitutions, otherwise a string
  */
-export function _t(text, variables, tags) {
+export function _t(text: string, variables?: IVariables): string;
+export function _t(text: string, variables: IVariables, tags: Tags): React.ReactNode;
+export function _t(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode {
     // Don't do substitutions in counterpart. We handle it ourselves so we can replace with React components
     // However, still pass the variables to counterpart so that it can choose the correct plural if count is given
     // It is enough to pass the count variable, but in the future counterpart might make use of other information too
@@ -141,23 +155,25 @@ export function _t(text, variables, tags) {
  *
  * @return a React <span> component if any non-strings were used in substitutions, otherwise a string
  */
-export function substitute(text, variables, tags) {
-    let result = text;
+export function substitute(text: string, variables?: IVariables): string;
+export function substitute(text: string, variables: IVariables, tags: Tags): string;
+export function substitute(text: string, variables?: IVariables, tags?: Tags): string | React.ReactNode {
+    let result: React.ReactNode | string = text;
 
     if (variables !== undefined) {
-        const regexpMapping = {};
+        const regexpMapping: IVariables = {};
         for (const variable in variables) {
             regexpMapping[`%\\(${variable}\\)s`] = variables[variable];
         }
-        result = replaceByRegexes(result, regexpMapping);
+        result = replaceByRegexes(result as string, regexpMapping);
     }
 
     if (tags !== undefined) {
-        const regexpMapping = {};
+        const regexpMapping: Tags = {};
         for (const tag in tags) {
             regexpMapping[`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`] = tags[tag];
         }
-        result = replaceByRegexes(result, regexpMapping);
+        result = replaceByRegexes(result as string, regexpMapping);
     }
 
     return result;
@@ -172,7 +188,9 @@ export function substitute(text, variables, tags) {
  *
  * @return a React <span> component if any non-strings were used in substitutions, otherwise a string
  */
-export function replaceByRegexes(text, mapping) {
+export function replaceByRegexes(text: string, mapping: IVariables): string;
+export function replaceByRegexes(text: string, mapping: Tags): React.ReactNode;
+export function replaceByRegexes(text: string, mapping: IVariables | Tags): string | React.ReactNode {
     // We initially store our output as an array of strings and objects (e.g. React components).
     // This will then be converted to a string or a <span> at the end
     const output = [text];
@@ -189,7 +207,7 @@ export function replaceByRegexes(text, mapping) {
         // and everything after the match. Insert all three into the output. We need to do this because we can insert objects.
         // Otherwise there would be no need for the splitting and we could do simple replacement.
         let matchFoundSomewhere = false; // If we don't find a match anywhere we want to log it
-        for (const outputIndex in output) {
+        for (let outputIndex = 0; outputIndex < output.length; outputIndex++) {
             const inputText = output[outputIndex];
             if (typeof inputText !== 'string') { // We might have inserted objects earlier, don't try to replace them
                 continue;
@@ -216,7 +234,7 @@ export function replaceByRegexes(text, mapping) {
                 let replaced;
                 // If substitution is a function, call it
                 if (mapping[regexpString] instanceof Function) {
-                    replaced = mapping[regexpString].apply(null, capturedGroups);
+                    replaced = (mapping as Tags)[regexpString].apply(null, capturedGroups);
                 } else {
                     replaced = mapping[regexpString];
                 }
@@ -277,11 +295,11 @@ export function replaceByRegexes(text, mapping) {
 // Allow overriding the text displayed when no translation exists
 // Currently only used in unit tests to avoid having to load
 // the translations in riot-web
-export function setMissingEntryGenerator(f) {
+export function setMissingEntryGenerator(f: (value: string) => void) {
     counterpart.setMissingEntryGenerator(f);
 }
 
-export function setLanguage(preferredLangs) {
+export function setLanguage(preferredLangs: string | string[]) {
     if (!Array.isArray(preferredLangs)) {
         preferredLangs = [preferredLangs];
     }
@@ -358,8 +376,8 @@ export function getLanguageFromBrowser() {
  * @param {string} language The input language string
  * @return {string[]} List of normalised languages
  */
-export function getNormalizedLanguageKeys(language) {
-    const languageKeys = [];
+export function getNormalizedLanguageKeys(language: string) {
+    const languageKeys: string[] = [];
     const normalizedLanguage = normalizeLanguageKey(language);
     const languageParts = normalizedLanguage.split('-');
     if (languageParts.length === 2 && languageParts[0] === languageParts[1]) {
@@ -380,7 +398,7 @@ export function getNormalizedLanguageKeys(language) {
  * @param {string} language The language string to be normalized
  * @returns {string} The normalized language string
  */
-export function normalizeLanguageKey(language) {
+export function normalizeLanguageKey(language: string) {
     return language.toLowerCase().replace("_", "-");
 }
 
@@ -396,7 +414,7 @@ export function getCurrentLanguage() {
  * @param {string[]} langs List of language codes to pick from
  * @returns {string} The most appropriate language code from langs
  */
-export function pickBestLanguage(langs) {
+export function pickBestLanguage(langs: string[]): string {
     const currentLang = getCurrentLanguage();
     const normalisedLangs = langs.map(normalizeLanguageKey);
 
@@ -408,13 +426,13 @@ export function pickBestLanguage(langs) {
 
     {
         // Failing that, a different dialect of the same language
-        const closeLangIndex = normalisedLangs.find((l) => l.substr(0, 2) === currentLang.substr(0, 2));
+        const closeLangIndex = normalisedLangs.findIndex((l) => l.substr(0, 2) === currentLang.substr(0, 2));
         if (closeLangIndex > -1) return langs[closeLangIndex];
     }
 
     {
         // Neither of those? Try an english variant.
-        const enIndex = normalisedLangs.find((l) => l.startsWith('en'));
+        const enIndex = normalisedLangs.findIndex((l) => l.startsWith('en'));
         if (enIndex > -1) return langs[enIndex];
     }
 
@@ -422,7 +440,7 @@ export function pickBestLanguage(langs) {
     return langs[0];
 }
 
-function getLangsJson() {
+function getLangsJson(): Promise<object> {
     return new Promise(async (resolve, reject) => {
         let url;
         if (typeof(webpackLangJsonUrl) === 'string') { // in Jest this 'url' isn't a URL, so just fall through
@@ -443,7 +461,7 @@ function getLangsJson() {
     });
 }
 
-function weblateToCounterpart(inTrs) {
+function weblateToCounterpart(inTrs: object): object {
     const outTrs = {};
 
     for (const key of Object.keys(inTrs)) {
@@ -463,7 +481,7 @@ function weblateToCounterpart(inTrs) {
     return outTrs;
 }
 
-function getLanguage(langPath) {
+function getLanguage(langPath: string): object {
     return new Promise((resolve, reject) => {
         request(
             { method: "GET", url: langPath },
diff --git a/src/settings/Settings.js b/src/settings/Settings.js
index fd85f6970d..3b1218c0d3 100644
--- a/src/settings/Settings.js
+++ b/src/settings/Settings.js
@@ -141,7 +141,8 @@ export const SETTINGS = {
         default: false,
     },
     "feature_new_room_list": {
-        isFeature: true,
+        // TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14367
+        // XXX: We shouldn't have non-features appear like features.
         displayName: _td("Use the improved room list (will refresh to apply changes)"),
         supportedLevels: LEVELS_FEATURE,
         default: true,
@@ -153,12 +154,6 @@ export const SETTINGS = {
         supportedLevels: LEVELS_FEATURE,
         default: false,
     },
-    "feature_irc_ui": {
-        supportedLevels: LEVELS_ACCOUNT_SETTINGS,
-        displayName: _td('Enable IRC layout option in the appearance tab'),
-        default: false,
-        isFeature: true,
-    },
     "mjolnirRooms": {
         supportedLevels: ['account'],
         default: [],
@@ -472,13 +467,13 @@ export const SETTINGS = {
             deny: [],
         },
     },
-    // TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14231
+    // TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14373
     "RoomList.orderAlphabetically": {
         supportedLevels: LEVELS_ACCOUNT_SETTINGS,
         displayName: _td("Order rooms by name"),
         default: false,
     },
-    // TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14231
+    // TODO: Remove setting: https://github.com/vector-im/riot-web/issues/14373
     "RoomList.orderByImportance": {
         supportedLevels: LEVELS_ACCOUNT_SETTINGS,
         displayName: _td("Show rooms with unread notifications first"),
@@ -568,7 +563,7 @@ export const SETTINGS = {
     },
     "useIRCLayout": {
         supportedLevels: LEVELS_ACCOUNT_SETTINGS,
-        displayName: _td("Use IRC layout"),
+        displayName: _td("Enable experimental, compact IRC style layout"),
         default: false,
     },
 };
diff --git a/src/settings/handlers/RoomSettingsHandler.js b/src/settings/handlers/RoomSettingsHandler.js
index d8e775742c..00dd5b8bec 100644
--- a/src/settings/handlers/RoomSettingsHandler.js
+++ b/src/settings/handlers/RoomSettingsHandler.js
@@ -43,11 +43,14 @@ export default class RoomSettingsHandler extends MatrixClientBackedSettingsHandl
         const roomId = event.getRoomId();
         const room = this.client.getRoom(roomId);
 
-        // Note: the tests often fire setting updates that don't have rooms in the store, so
-        // we fail softly here. We shouldn't assume that the state being fired is current
-        // state, but we also don't need to explode just because we didn't find a room.
-        if (!room) console.warn(`Unknown room caused setting update: ${roomId}`);
-        if (room && state !== room.currentState) return; // ignore state updates which are not current
+        // Note: in tests and during the encryption setup on initial load we might not have
+        // rooms in the store, so we just quietly ignore the problem. If we log it then we'll
+        // just end up spamming the logs a few thousand times. It is perfectly fine for us
+        // to ignore the problem as the app will not have loaded enough to care yet.
+        if (!room) return;
+
+        // ignore state updates which are not current
+        if (room && state !== room.currentState) return;
 
         if (event.getType() === "org.matrix.room.preview_urls") {
             let val = event.getContent()['disable'];
diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts
index c78f15c3b4..48ef75cb59 100644
--- a/src/stores/BreadcrumbsStore.ts
+++ b/src/stores/BreadcrumbsStore.ts
@@ -57,7 +57,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
     protected async onAction(payload: ActionPayload) {
         if (!this.matrixClient) return;
 
-        // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14231
+        // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14367
         if (!RoomListStoreTempProxy.isUsingNewStore()) return;
 
         if (payload.action === 'setting_updated') {
@@ -80,7 +80,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
     }
 
     protected async onReady() {
-        // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14231
+        // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14367
         if (!RoomListStoreTempProxy.isUsingNewStore()) return;
 
         await this.updateRooms();
@@ -91,7 +91,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
     }
 
     protected async onNotReady() {
-        // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14231
+        // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14367
         if (!RoomListStoreTempProxy.isUsingNewStore()) return;
 
         this.matrixClient.removeListener("Room.myMembership", this.onMyMembership);
@@ -125,6 +125,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
     }
 
     private async appendRoom(room: Room) {
+        let updated = false;
         const rooms = (this.state.rooms || []).slice(); // cheap clone
 
         // If the room is upgraded, use that room instead. We'll also splice out
@@ -136,30 +137,42 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
             // Take out any room that isn't the most recent room
             for (let i = 0; i < history.length - 1; i++) {
                 const idx = rooms.findIndex(r => r.roomId === history[i].roomId);
-                if (idx !== -1) rooms.splice(idx, 1);
+                if (idx !== -1) {
+                    rooms.splice(idx, 1);
+                    updated = true;
+                }
             }
         }
 
         // Remove the existing room, if it is present
         const existingIdx = rooms.findIndex(r => r.roomId === room.roomId);
-        if (existingIdx !== -1) {
-            rooms.splice(existingIdx, 1);
-        }
 
-        // Splice the room to the start of the list
-        rooms.splice(0, 0, room);
+        // If we're focusing on the first room no-op
+        if (existingIdx !== 0) {
+            if (existingIdx !== -1) {
+                rooms.splice(existingIdx, 1);
+            }
+
+            // Splice the room to the start of the list
+            rooms.splice(0, 0, room);
+            updated = true;
+        }
 
         if (rooms.length > MAX_ROOMS) {
             // This looks weird, but it's saying to start at the MAX_ROOMS point in the
             // list and delete everything after it.
             rooms.splice(MAX_ROOMS, rooms.length - MAX_ROOMS);
+            updated = true;
         }
 
-        // Update the breadcrumbs
-        await this.updateState({rooms});
-        const roomIds = rooms.map(r => r.roomId);
-        if (roomIds.length > 0) {
-            await SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds);
+
+        if (updated) {
+            // Update the breadcrumbs
+            await this.updateState({rooms});
+            const roomIds = rooms.map(r => r.roomId);
+            if (roomIds.length > 0) {
+                await SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds);
+            }
         }
     }
 
diff --git a/src/stores/RoomListStore.js b/src/stores/RoomListStore.js
index c19b2f8bc2..1861085a27 100644
--- a/src/stores/RoomListStore.js
+++ b/src/stores/RoomListStore.js
@@ -99,7 +99,7 @@ class RoomListStore extends Store {
     }
 
     _checkDisabled() {
-        this.disabled = SettingsStore.isFeatureEnabled("feature_new_room_list");
+        this.disabled = SettingsStore.getValue("feature_new_room_list");
         if (this.disabled) {
             console.warn("👋 legacy room list store has been disabled");
         }
diff --git a/src/stores/notifications/ListNotificationState.ts b/src/stores/notifications/ListNotificationState.ts
index 5773693b47..6c67dbdd08 100644
--- a/src/stores/notifications/ListNotificationState.ts
+++ b/src/stores/notifications/ListNotificationState.ts
@@ -14,23 +14,20 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { EventEmitter } from "events";
-import { INotificationState, NOTIFICATION_STATE_UPDATE } from "./INotificationState";
 import { NotificationColor } from "./NotificationColor";
-import { IDestroyable } from "../../utils/IDestroyable";
 import { TagID } from "../room-list/models";
 import { Room } from "matrix-js-sdk/src/models/room";
 import { arrayDiff } from "../../utils/arrays";
 import { RoomNotificationState } from "./RoomNotificationState";
-import { TagSpecificNotificationState } from "./TagSpecificNotificationState";
+import { NOTIFICATION_STATE_UPDATE, NotificationState } from "./NotificationState";
 
-export class ListNotificationState extends EventEmitter implements IDestroyable, INotificationState {
-    private _count: number;
-    private _color: NotificationColor;
+export type FetchRoomFn = (room: Room) => RoomNotificationState;
+
+export class ListNotificationState extends NotificationState {
     private rooms: Room[] = [];
     private states: { [roomId: string]: RoomNotificationState } = {};
 
-    constructor(private byTileCount = false, private tagId: TagID) {
+    constructor(private byTileCount = false, private tagId: TagID, private getRoomFn: FetchRoomFn) {
         super();
     }
 
@@ -38,14 +35,6 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
         return null; // This notification state doesn't support symbols
     }
 
-    public get count(): number {
-        return this._count;
-    }
-
-    public get color(): NotificationColor {
-        return this._color;
-    }
-
     public setRooms(rooms: Room[]) {
         // If we're only concerned about the tile count, don't bother setting up listeners.
         if (this.byTileCount) {
@@ -62,16 +51,10 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
             if (!state) continue; // We likely just didn't have a badge (race condition)
             delete this.states[oldRoom.roomId];
             state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
-            state.destroy();
         }
         for (const newRoom of diff.added) {
-            const state = new TagSpecificNotificationState(newRoom, this.tagId);
+            const state = this.getRoomFn(newRoom);
             state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
-            if (this.states[newRoom.roomId]) {
-                // "Should never happen" disclaimer.
-                console.warn("Overwriting notification state for room:", newRoom.roomId);
-                this.states[newRoom.roomId].destroy();
-            }
             this.states[newRoom.roomId] = state;
         }
 
@@ -85,8 +68,9 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
     }
 
     public destroy() {
+        super.destroy();
         for (const state of Object.values(this.states)) {
-            state.destroy();
+            state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
         }
         this.states = {};
     }
@@ -96,7 +80,7 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
     };
 
     private calculateTotalState() {
-        const before = {count: this.count, symbol: this.symbol, color: this.color};
+        const snapshot = this.snapshot();
 
         if (this.byTileCount) {
             this._color = NotificationColor.Red;
@@ -111,10 +95,7 @@ export class ListNotificationState extends EventEmitter implements IDestroyable,
         }
 
         // finally, publish an update if needed
-        const after = {count: this.count, symbol: this.symbol, color: this.color};
-        if (JSON.stringify(before) !== JSON.stringify(after)) {
-            this.emit(NOTIFICATION_STATE_UPDATE);
-        }
+        this.emitIfUpdated(snapshot);
     }
 }
 
diff --git a/src/stores/notifications/NotificationState.ts b/src/stores/notifications/NotificationState.ts
new file mode 100644
index 0000000000..c8ef0ba859
--- /dev/null
+++ b/src/stores/notifications/NotificationState.ts
@@ -0,0 +1,87 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { EventEmitter } from "events";
+import { NotificationColor } from "./NotificationColor";
+import { IDestroyable } from "../../utils/IDestroyable";
+
+export const NOTIFICATION_STATE_UPDATE = "update";
+
+export abstract class NotificationState extends EventEmitter implements IDestroyable {
+    protected _symbol: string;
+    protected _count: number;
+    protected _color: NotificationColor;
+
+    public get symbol(): string {
+        return this._symbol;
+    }
+
+    public get count(): number {
+        return this._count;
+    }
+
+    public get color(): NotificationColor {
+        return this._color;
+    }
+
+    public get isIdle(): boolean {
+        return this.color <= NotificationColor.None;
+    }
+
+    public get isUnread(): boolean {
+        return this.color >= NotificationColor.Bold;
+    }
+
+    public get hasUnreadCount(): boolean {
+        return this.color >= NotificationColor.Grey && (!!this.count || !!this.symbol);
+    }
+
+    public get hasMentions(): boolean {
+        return this.color >= NotificationColor.Red;
+    }
+
+    protected emitIfUpdated(snapshot: NotificationStateSnapshot) {
+        if (snapshot.isDifferentFrom(this)) {
+            this.emit(NOTIFICATION_STATE_UPDATE);
+        }
+    }
+
+    protected snapshot(): NotificationStateSnapshot {
+        return new NotificationStateSnapshot(this);
+    }
+
+    public destroy(): void {
+        this.removeAllListeners(NOTIFICATION_STATE_UPDATE);
+    }
+}
+
+export class NotificationStateSnapshot {
+    private readonly symbol: string;
+    private readonly count: number;
+    private readonly color: NotificationColor;
+
+    constructor(state: NotificationState) {
+        this.symbol = state.symbol;
+        this.count = state.count;
+        this.color = state.color;
+    }
+
+    public isDifferentFrom(other: NotificationState): boolean {
+        const before = {count: this.count, symbol: this.symbol, color: this.color};
+        const after = {count: other.count, symbol: other.symbol, color: other.color};
+        return JSON.stringify(before) !== JSON.stringify(after);
+    }
+}
diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts
index f9b19fcbcb..ab354c0e93 100644
--- a/src/stores/notifications/RoomNotificationState.ts
+++ b/src/stores/notifications/RoomNotificationState.ts
@@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { EventEmitter } from "events";
-import { INotificationState, NOTIFICATION_STATE_UPDATE } from "./INotificationState";
 import { NotificationColor } from "./NotificationColor";
 import { IDestroyable } from "../../utils/IDestroyable";
 import { MatrixClientPeg } from "../../MatrixClientPeg";
@@ -25,13 +23,10 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import { Room } from "matrix-js-sdk/src/models/room";
 import * as RoomNotifs from '../../RoomNotifs';
 import * as Unread from '../../Unread';
+import { NotificationState } from "./NotificationState";
 
-export class RoomNotificationState extends EventEmitter implements IDestroyable, INotificationState {
-    private _symbol: string;
-    private _count: number;
-    private _color: NotificationColor;
-
-    constructor(private room: Room) {
+export class RoomNotificationState extends NotificationState implements IDestroyable {
+    constructor(public readonly room: Room) {
         super();
         this.room.on("Room.receipt", this.handleReadReceipt);
         this.room.on("Room.timeline", this.handleRoomEventUpdate);
@@ -41,23 +36,12 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable,
         this.updateNotificationState();
     }
 
-    public get symbol(): string {
-        return this._symbol;
-    }
-
-    public get count(): number {
-        return this._count;
-    }
-
-    public get color(): NotificationColor {
-        return this._color;
-    }
-
     private get roomIsInvite(): boolean {
         return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite;
     }
 
     public destroy(): void {
+        super.destroy();
         this.room.removeListener("Room.receipt", this.handleReadReceipt);
         this.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
         this.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
@@ -87,7 +71,7 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable,
     };
 
     private updateNotificationState() {
-        const before = {count: this.count, symbol: this.symbol, color: this.color};
+        const snapshot = this.snapshot();
 
         if (RoomNotifs.getRoomNotifsState(this.room.roomId) === RoomNotifs.MUTE) {
             // When muted we suppress all notification states, even if we have context on them.
@@ -136,9 +120,6 @@ export class RoomNotificationState extends EventEmitter implements IDestroyable,
         }
 
         // finally, publish an update if needed
-        const after = {count: this.count, symbol: this.symbol, color: this.color};
-        if (JSON.stringify(before) !== JSON.stringify(after)) {
-            this.emit(NOTIFICATION_STATE_UPDATE);
-        }
+        this.emitIfUpdated(snapshot);
     }
 }
diff --git a/src/stores/notifications/RoomNotificationStateStore.ts b/src/stores/notifications/RoomNotificationStateStore.ts
new file mode 100644
index 0000000000..311dcdf2d6
--- /dev/null
+++ b/src/stores/notifications/RoomNotificationStateStore.ts
@@ -0,0 +1,101 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { ActionPayload } from "../../dispatcher/payloads";
+import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
+import defaultDispatcher from "../../dispatcher/dispatcher";
+import { DefaultTagID, TagID } from "../room-list/models";
+import { FetchRoomFn, ListNotificationState } from "./ListNotificationState";
+import { Room } from "matrix-js-sdk/src/models/room";
+import { RoomNotificationState } from "./RoomNotificationState";
+import { TagSpecificNotificationState } from "./TagSpecificNotificationState";
+
+const INSPECIFIC_TAG = "INSPECIFIC_TAG";
+type INSPECIFIC_TAG = "INSPECIFIC_TAG";
+
+interface IState {}
+
+export class RoomNotificationStateStore extends AsyncStoreWithClient<IState> {
+    private static internalInstance = new RoomNotificationStateStore();
+
+    private roomMap = new Map<Room, Map<TagID | INSPECIFIC_TAG, RoomNotificationState>>();
+
+    private constructor() {
+        super(defaultDispatcher, {});
+    }
+
+    /**
+     * Creates a new list notification state. The consumer is expected to set the rooms
+     * on the notification state, and destroy the state when it no longer needs it.
+     * @param tagId The tag to create the notification state for.
+     * @returns The notification state for the tag.
+     */
+    public getListState(tagId: TagID): ListNotificationState {
+        // Note: we don't cache these notification states as the consumer is expected to call
+        // .setRooms() on the returned object, which could confuse other consumers.
+
+        // TODO: Update if/when invites move out of the room list.
+        const useTileCount = tagId === DefaultTagID.Invite;
+        const getRoomFn: FetchRoomFn = (room: Room) => {
+            return this.getRoomState(room, tagId);
+        };
+        return new ListNotificationState(useTileCount, tagId, getRoomFn);
+    }
+
+    /**
+     * Gets a copy of the notification state for a room. The consumer should not
+     * attempt to destroy the returned state as it may be shared with other
+     * consumers.
+     * @param room The room to get the notification state for.
+     * @param inTagId Optional tag ID to scope the notification state to.
+     * @returns The room's notification state.
+     */
+    public getRoomState(room: Room, inTagId?: TagID): RoomNotificationState {
+        if (!this.roomMap.has(room)) {
+            this.roomMap.set(room, new Map<TagID | INSPECIFIC_TAG, RoomNotificationState>());
+        }
+
+        const targetTag = inTagId ? inTagId : INSPECIFIC_TAG;
+
+        const forRoomMap = this.roomMap.get(room);
+        if (!forRoomMap.has(targetTag)) {
+            if (inTagId) {
+                forRoomMap.set(inTagId, new TagSpecificNotificationState(room, inTagId));
+            } else {
+                forRoomMap.set(INSPECIFIC_TAG, new RoomNotificationState(room));
+            }
+        }
+
+        return forRoomMap.get(targetTag);
+    }
+
+    public static get instance(): RoomNotificationStateStore {
+        return RoomNotificationStateStore.internalInstance;
+    }
+
+    protected async onNotReady(): Promise<any> {
+        for (const roomMap of this.roomMap.values()) {
+            for (const roomState of roomMap.values()) {
+                roomState.destroy();
+            }
+        }
+    }
+
+    // We don't need this, but our contract says we do.
+    protected async onAction(payload: ActionPayload) {
+        return Promise.resolve();
+    }
+}
diff --git a/src/stores/notifications/StaticNotificationState.ts b/src/stores/notifications/StaticNotificationState.ts
index 51902688fe..0392ed3716 100644
--- a/src/stores/notifications/StaticNotificationState.ts
+++ b/src/stores/notifications/StaticNotificationState.ts
@@ -14,13 +14,15 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { EventEmitter } from "events";
-import { INotificationState } from "./INotificationState";
 import { NotificationColor } from "./NotificationColor";
+import { NotificationState } from "./NotificationState";
 
-export class StaticNotificationState extends EventEmitter implements INotificationState {
-    constructor(public symbol: string, public count: number, public color: NotificationColor) {
+export class StaticNotificationState extends NotificationState {
+    constructor(symbol: string, count: number, color: NotificationColor) {
         super();
+        this._symbol = symbol;
+        this._count = count;
+        this._color = color;
     }
 
     public static forCount(count: number, color: NotificationColor): StaticNotificationState {
diff --git a/src/stores/room-list/ListLayout.ts b/src/stores/room-list/ListLayout.ts
index efb0c4bdfb..caf2e92bd1 100644
--- a/src/stores/room-list/ListLayout.ts
+++ b/src/stores/room-list/ListLayout.ts
@@ -18,10 +18,6 @@ import { TagID } from "./models";
 
 const TILE_HEIGHT_PX = 44;
 
-// this comes from the CSS where the show more button is
-// mathematically this percent of a tile when floating.
-const RESIZER_BOX_FACTOR = 0.78;
-
 interface ISerializedListLayout {
     numTiles: number;
     showPreviews: boolean;
@@ -81,35 +77,12 @@ export class ListLayout {
     }
 
     public get minVisibleTiles(): number {
-        return 1 + RESIZER_BOX_FACTOR;
+        return 1;
     }
 
     public get defaultVisibleTiles(): number {
-        // 10 is what "feels right", and mostly subject to design's opinion.
-        return 10 + RESIZER_BOX_FACTOR;
-    }
-
-    public setVisibleTilesWithin(diff: number, maxPossible: number) {
-        if (this.visibleTiles > maxPossible) {
-            this.visibleTiles = maxPossible + diff;
-        } else {
-            this.visibleTiles += diff;
-        }
-    }
-
-    public calculateTilesToPixelsMin(maxTiles: number, n: number, possiblePadding: number): number {
-        // Only apply the padding if we're about to use maxTiles as we need to
-        // plan for the padding. If we're using n, the padding is already accounted
-        // for by the resizing stuff.
-        let padding = 0;
-        if (maxTiles < n) {
-            padding = possiblePadding;
-        }
-        return this.tilesToPixels(Math.min(maxTiles, n)) + padding;
-    }
-
-    public tilesWithResizerBoxFactor(n: number): number {
-        return n + RESIZER_BOX_FACTOR;
+        // This number is what "feels right", and mostly subject to design's opinion.
+        return 5;
     }
 
     public tilesWithPadding(n: number, paddingPx: number): number {
diff --git a/src/stores/room-list/MessagePreviewStore.ts b/src/stores/room-list/MessagePreviewStore.ts
index 01ddde2e17..ea7fa830cd 100644
--- a/src/stores/room-list/MessagePreviewStore.ts
+++ b/src/stores/room-list/MessagePreviewStore.ts
@@ -192,7 +192,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
     protected async onAction(payload: ActionPayload) {
         if (!this.matrixClient) return;
 
-        // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14231
+        // TODO: Remove when new room list is made the default: https://github.com/vector-im/riot-web/issues/14367
         if (!RoomListStoreTempProxy.isUsingNewStore()) return;
 
         if (payload.action === 'MatrixActions.Room.timeline' || payload.action === 'MatrixActions.Event.decrypted') {
diff --git a/src/stores/room-list/RoomListLayoutStore.ts b/src/stores/room-list/RoomListLayoutStore.ts
new file mode 100644
index 0000000000..fbc7d7719d
--- /dev/null
+++ b/src/stores/room-list/RoomListLayoutStore.ts
@@ -0,0 +1,73 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { TagID } from "./models";
+import { ListLayout } from "./ListLayout";
+import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
+import defaultDispatcher from "../../dispatcher/dispatcher";
+import { ActionPayload } from "../../dispatcher/payloads";
+
+interface IState {}
+
+export default class RoomListLayoutStore extends AsyncStoreWithClient<IState> {
+    private static internalInstance: RoomListLayoutStore;
+
+    private readonly layoutMap = new Map<TagID, ListLayout>();
+
+    constructor() {
+        super(defaultDispatcher);
+    }
+
+    public static get instance(): RoomListLayoutStore {
+        if (!RoomListLayoutStore.internalInstance) {
+            RoomListLayoutStore.internalInstance = new RoomListLayoutStore();
+        }
+        return RoomListLayoutStore.internalInstance;
+    }
+
+    public ensureLayoutExists(tagId: TagID) {
+        if (!this.layoutMap.has(tagId)) {
+            this.layoutMap.set(tagId, new ListLayout(tagId));
+        }
+    }
+
+    public getLayoutFor(tagId: TagID): ListLayout {
+        if (!this.layoutMap.has(tagId)) {
+            this.layoutMap.set(tagId, new ListLayout(tagId));
+        }
+        return this.layoutMap.get(tagId);
+    }
+
+    // Note: this primarily exists for debugging, and isn't really intended to be used by anything.
+    public async resetLayouts() {
+        console.warn("Resetting layouts for room list");
+        for (const layout of this.layoutMap.values()) {
+            layout.reset();
+        }
+    }
+
+    protected async onNotReady(): Promise<any> {
+        // On logout, clear the map.
+        this.layoutMap.clear();
+    }
+
+    // We don't need this function, but our contract says we do
+    protected async onAction(payload: ActionPayload): Promise<any> {
+        return Promise.resolve();
+    }
+}
+
+window.mx_RoomListLayoutStore = RoomListLayoutStore.instance;
diff --git a/src/stores/room-list/RoomListStore2.ts b/src/stores/room-list/RoomListStore2.ts
index e5205f6051..8686a3a054 100644
--- a/src/stores/room-list/RoomListStore2.ts
+++ b/src/stores/room-list/RoomListStore2.ts
@@ -25,12 +25,15 @@ import { IListOrderingMap, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm
 import { ActionPayload } from "../../dispatcher/payloads";
 import defaultDispatcher from "../../dispatcher/dispatcher";
 import { readReceiptChangeIsFor } from "../../utils/read-receipts";
-import { IFilterCondition } from "./filters/IFilterCondition";
+import { FILTER_CHANGED, IFilterCondition } from "./filters/IFilterCondition";
 import { TagWatcher } from "./TagWatcher";
 import RoomViewStore from "../RoomViewStore";
 import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm";
 import { EffectiveMembership, getEffectiveMembership } from "./membership";
 import { ListLayout } from "./ListLayout";
+import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
+import RoomListLayoutStore from "./RoomListLayoutStore";
+import { MarkedExecution } from "../../utils/MarkedExecution";
 
 interface IState {
     tagsEnabled?: boolean;
@@ -43,12 +46,19 @@ interface IState {
 export const LISTS_UPDATE_EVENT = "lists_update";
 
 export class RoomListStore2 extends AsyncStore<ActionPayload> {
+    /**
+     * Set to true if you're running tests on the store. Should not be touched in
+     * any other environment.
+     */
+    public static TEST_MODE = false;
+
     private _matrixClient: MatrixClient;
     private initialListsGenerated = false;
     private enabled = false;
     private algorithm = new Algorithm();
     private filterConditions: IFilterCondition[] = [];
     private tagWatcher = new TagWatcher(this);
+    private updateFn = new MarkedExecution(() => this.emit(LISTS_UPDATE_EVENT));
 
     private readonly watchedSettings = [
         'feature_custom_tags',
@@ -59,8 +69,9 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
 
         this.checkEnabled();
         for (const settingName of this.watchedSettings) SettingsStore.monitorSetting(settingName, null);
-        RoomViewStore.addListener(this.onRVSUpdate);
+        RoomViewStore.addListener(() => this.handleRVSUpdate({}));
         this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
+        this.algorithm.on(FILTER_CHANGED, this.onAlgorithmFilterUpdated);
     }
 
     public get orderedLists(): ITagMap {
@@ -72,9 +83,43 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
         return this._matrixClient;
     }
 
-    // TODO: Remove enabled flag with the old RoomListStore: https://github.com/vector-im/riot-web/issues/14231
+    // Intended for test usage
+    public async resetStore() {
+        await this.reset();
+        this.tagWatcher = new TagWatcher(this);
+        this.filterConditions = [];
+        this.initialListsGenerated = false;
+        this._matrixClient = null;
+
+        this.algorithm.off(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
+        this.algorithm.off(FILTER_CHANGED, this.onAlgorithmListUpdated);
+        this.algorithm = new Algorithm();
+        this.algorithm.on(LIST_UPDATED_EVENT, this.onAlgorithmListUpdated);
+        this.algorithm.on(FILTER_CHANGED, this.onAlgorithmListUpdated);
+    }
+
+    // Public for test usage. Do not call this.
+    public async makeReady(client: MatrixClient) {
+        // TODO: Remove with https://github.com/vector-im/riot-web/issues/14367
+        this.checkEnabled();
+        if (!this.enabled) return;
+
+        this._matrixClient = client;
+
+        // Update any settings here, as some may have happened before we were logically ready.
+        // Update any settings here, as some may have happened before we were logically ready.
+        console.log("Regenerating room lists: Startup");
+        await this.readAndCacheSettingsFromStore();
+        await this.regenerateAllLists({trigger: false});
+        await this.handleRVSUpdate({trigger: false}); // fake an RVS update to adjust sticky room, if needed
+
+        this.updateFn.mark(); // we almost certainly want to trigger an update.
+        this.updateFn.trigger();
+    }
+
+    // TODO: Remove enabled flag with the old RoomListStore: https://github.com/vector-im/riot-web/issues/14367
     private checkEnabled() {
-        this.enabled = SettingsStore.isFeatureEnabled("feature_new_room_list");
+        this.enabled = SettingsStore.getValue("feature_new_room_list");
         if (this.enabled) {
             console.log("âš¡ new room list store engaged");
         }
@@ -88,44 +133,58 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
         await this.updateAlgorithmInstances();
     }
 
-    private onRVSUpdate = () => {
-        if (!this.enabled) return; // TODO: Remove with https://github.com/vector-im/riot-web/issues/14231
+    /**
+     * Handles suspected RoomViewStore changes.
+     * @param trigger Set to false to prevent a list update from being sent. Should only
+     * be used if the calling code will manually trigger the update.
+     */
+    private async handleRVSUpdate({trigger = true}) {
+        if (!this.enabled) return; // TODO: Remove with https://github.com/vector-im/riot-web/issues/14367
         if (!this.matrixClient) return; // We assume there won't be RVS updates without a client
 
         const activeRoomId = RoomViewStore.getRoomId();
         if (!activeRoomId && this.algorithm.stickyRoom) {
-            this.algorithm.stickyRoom = null;
+            await this.algorithm.setStickyRoom(null);
         } else if (activeRoomId) {
             const activeRoom = this.matrixClient.getRoom(activeRoomId);
             if (!activeRoom) {
                 console.warn(`${activeRoomId} is current in RVS but missing from client - clearing sticky room`);
-                this.algorithm.stickyRoom = null;
+                await this.algorithm.setStickyRoom(null);
             } else if (activeRoom !== this.algorithm.stickyRoom) {
-                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-                console.log(`Changing sticky room to ${activeRoomId}`);
-                this.algorithm.stickyRoom = activeRoom;
+                if (!window.mx_QuietRoomListLogging) {
+                    // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                    console.log(`Changing sticky room to ${activeRoomId}`);
+                }
+                await this.algorithm.setStickyRoom(activeRoom);
             }
         }
-    };
+
+        if (trigger) this.updateFn.trigger();
+    }
 
     protected async onDispatch(payload: ActionPayload) {
+        // When we're running tests we can't reliably use setImmediate out of timing concerns.
+        // As such, we use a more synchronous model.
+        if (RoomListStore2.TEST_MODE) {
+            await this.onDispatchAsync(payload);
+            return;
+        }
+
+        // We do this to intentionally break out of the current event loop task, allowing
+        // us to instead wait for a more convenient time to run our updates.
+        setImmediate(() => this.onDispatchAsync(payload));
+    }
+
+    protected async onDispatchAsync(payload: ActionPayload) {
         if (payload.action === 'MatrixActions.sync') {
             // Filter out anything that isn't the first PREPARED sync.
             if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) {
                 return;
             }
 
-            // TODO: Remove with https://github.com/vector-im/riot-web/issues/14231
-            this.checkEnabled();
-            if (!this.enabled) return;
+            await this.makeReady(payload.matrixClient);
 
-            this._matrixClient = payload.matrixClient;
-
-            // Update any settings here, as some may have happened before we were logically ready.
-            console.log("Regenerating room lists: Startup");
-            await this.readAndCacheSettingsFromStore();
-            await this.regenerateAllLists();
-            this.onRVSUpdate(); // fake an RVS update to adjust sticky room, if needed
+            return; // no point in running the next conditions - they won't match
         }
 
         // TODO: Remove this once the RoomListStore becomes default
@@ -134,7 +193,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
         if (payload.action === 'on_client_not_viable' || payload.action === 'on_logged_out') {
             // Reset state without causing updates as the client will have been destroyed
             // and downstream code will throw NPE errors.
-            this.reset(null, true);
+            await this.reset(null, true);
             this._matrixClient = null;
             this.initialListsGenerated = false; // we'll want to regenerate them
         }
@@ -148,7 +207,8 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
                 console.log("Regenerating room lists: Settings changed");
                 await this.readAndCacheSettingsFromStore();
 
-                await this.regenerateAllLists(); // regenerate the lists now
+                await this.regenerateAllLists({trigger: false}); // regenerate the lists now
+                this.updateFn.trigger();
             }
         }
 
@@ -166,16 +226,22 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
                     console.warn(`Own read receipt was in unknown room ${room.roomId}`);
                     return;
                 }
-                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-                console.log(`[RoomListDebug] Got own read receipt in ${room.roomId}`);
+                if (!window.mx_QuietRoomListLogging) {
+                    // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                    console.log(`[RoomListDebug] Got own read receipt in ${room.roomId}`);
+                }
                 await this.handleRoomUpdate(room, RoomUpdateCause.ReadReceipt);
+                this.updateFn.trigger();
                 return;
             }
         } else if (payload.action === 'MatrixActions.Room.tags') {
             const roomPayload = (<any>payload); // TODO: Type out the dispatcher types
-            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-            console.log(`[RoomListDebug] Got tag change in ${roomPayload.room.roomId}`);
+            if (!window.mx_QuietRoomListLogging) {
+                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                console.log(`[RoomListDebug] Got tag change in ${roomPayload.room.roomId}`);
+            }
             await this.handleRoomUpdate(roomPayload.room, RoomUpdateCause.PossibleTagChange);
+            this.updateFn.trigger();
         } else if (payload.action === 'MatrixActions.Room.timeline') {
             const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
 
@@ -185,12 +251,16 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
             const roomId = eventPayload.event.getRoomId();
             const room = this.matrixClient.getRoom(roomId);
             const tryUpdate = async (updatedRoom: Room) => {
-                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-                console.log(`[RoomListDebug] Live timeline event ${eventPayload.event.getId()}` +
-                    ` in ${updatedRoom.roomId}`);
-                if (eventPayload.event.getType() === 'm.room.tombstone' && eventPayload.event.getStateKey() === '') {
+                if (!window.mx_QuietRoomListLogging) {
                     // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-                    console.log(`[RoomListDebug] Got tombstone event - trying to remove now-dead room`);
+                    console.log(`[RoomListDebug] Live timeline event ${eventPayload.event.getId()}` +
+                        ` in ${updatedRoom.roomId}`);
+                }
+                if (eventPayload.event.getType() === 'm.room.tombstone' && eventPayload.event.getStateKey() === '') {
+                    if (!window.mx_QuietRoomListLogging) {
+                        // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                        console.log(`[RoomListDebug] Got tombstone event - trying to remove now-dead room`);
+                    }
                     const newRoom = this.matrixClient.getRoom(eventPayload.event.getContent()['replacement_room']);
                     if (newRoom) {
                         // If we have the new room, then the new room check will have seen the predecessor
@@ -199,6 +269,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
                     }
                 }
                 await this.handleRoomUpdate(updatedRoom, RoomUpdateCause.Timeline);
+                this.updateFn.trigger();
             };
             if (!room) {
                 console.warn(`Live timeline event ${eventPayload.event.getId()} received without associated room`);
@@ -219,16 +290,18 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
                 console.warn(`Event ${eventPayload.event.getId()} was decrypted in an unknown room ${roomId}`);
                 return;
             }
-            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-            console.log(`[RoomListDebug] Decrypted timeline event ${eventPayload.event.getId()} in ${roomId}`);
-            // TODO: Verify that e2e rooms are handled on init: https://github.com/vector-im/riot-web/issues/14238
-            // It seems like when viewing the room the timeline is decrypted, rather than at startup. This could
-            // cause inaccuracies with the list ordering. We may have to decrypt the last N messages of every room :(
+            if (!window.mx_QuietRoomListLogging) {
+                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                console.log(`[RoomListDebug] Decrypted timeline event ${eventPayload.event.getId()} in ${roomId}`);
+            }
             await this.handleRoomUpdate(room, RoomUpdateCause.Timeline);
+            this.updateFn.trigger();
         } else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') {
             const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
-            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-            console.log(`[RoomListDebug] Received updated DM map`);
+            if (!window.mx_QuietRoomListLogging) {
+                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                console.log(`[RoomListDebug] Received updated DM map`);
+            }
             const dmMap = eventPayload.event.getContent();
             for (const userId of Object.keys(dmMap)) {
                 const roomIds = dmMap[userId];
@@ -246,51 +319,73 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
                     await this.handleRoomUpdate(room, RoomUpdateCause.PossibleTagChange);
                 }
             }
+            this.updateFn.trigger();
         } else if (payload.action === 'MatrixActions.Room.myMembership') {
             const membershipPayload = (<any>payload); // TODO: Type out the dispatcher types
             const oldMembership = getEffectiveMembership(membershipPayload.oldMembership);
             const newMembership = getEffectiveMembership(membershipPayload.membership);
             if (oldMembership !== EffectiveMembership.Join && newMembership === EffectiveMembership.Join) {
-                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-                console.log(`[RoomListDebug] Handling new room ${membershipPayload.room.roomId}`);
+                if (!window.mx_QuietRoomListLogging) {
+                    // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                    console.log(`[RoomListDebug] Handling new room ${membershipPayload.room.roomId}`);
+                }
 
                 // If we're joining an upgraded room, we'll want to make sure we don't proliferate
                 // the dead room in the list.
                 const createEvent = membershipPayload.room.currentState.getStateEvents("m.room.create", "");
                 if (createEvent && createEvent.getContent()['predecessor']) {
-                    console.log(`[RoomListDebug] Room has a predecessor`);
+                    if (!window.mx_QuietRoomListLogging) {
+                        // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                        console.log(`[RoomListDebug] Room has a predecessor`);
+                    }
                     const prevRoom = this.matrixClient.getRoom(createEvent.getContent()['predecessor']['room_id']);
                     if (prevRoom) {
                         const isSticky = this.algorithm.stickyRoom === prevRoom;
                         if (isSticky) {
-                            console.log(`[RoomListDebug] Clearing sticky room due to room upgrade`);
-                            await this.algorithm.setStickyRoomAsync(null);
+                            if (!window.mx_QuietRoomListLogging) {
+                                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                                console.log(`[RoomListDebug] Clearing sticky room due to room upgrade`);
+                            }
+                            await this.algorithm.setStickyRoom(null);
                         }
 
                         // Note: we hit the algorithm instead of our handleRoomUpdate() function to
                         // avoid redundant updates.
-                        console.log(`[RoomListDebug] Removing previous room from room list`);
+                        if (!window.mx_QuietRoomListLogging) {
+                            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                            console.log(`[RoomListDebug] Removing previous room from room list`);
+                        }
                         await this.algorithm.handleRoomUpdate(prevRoom, RoomUpdateCause.RoomRemoved);
                     }
                 }
 
-                console.log(`[RoomListDebug] Adding new room to room list`);
+                if (!window.mx_QuietRoomListLogging) {
+                    // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                    console.log(`[RoomListDebug] Adding new room to room list`);
+                }
                 await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
+                this.updateFn.trigger();
                 return;
             }
 
             if (oldMembership !== EffectiveMembership.Invite && newMembership === EffectiveMembership.Invite) {
-                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-                console.log(`[RoomListDebug] Handling invite to ${membershipPayload.room.roomId}`);
+                if (!window.mx_QuietRoomListLogging) {
+                    // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                    console.log(`[RoomListDebug] Handling invite to ${membershipPayload.room.roomId}`);
+                }
                 await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
+                this.updateFn.trigger();
                 return;
             }
 
             // If it's not a join, it's transitioning into a different list (possibly historical)
             if (oldMembership !== newMembership) {
-                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-                console.log(`[RoomListDebug] Handling membership change in ${membershipPayload.room.roomId}`);
+                if (!window.mx_QuietRoomListLogging) {
+                    // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                    console.log(`[RoomListDebug] Handling membership change in ${membershipPayload.room.roomId}`);
+                }
                 await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.PossibleTagChange);
+                this.updateFn.trigger();
                 return;
             }
         }
@@ -299,13 +394,20 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
     private async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<any> {
         const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause);
         if (shouldUpdate) {
-            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-            console.log(`[DEBUG] Room "${room.name}" (${room.roomId}) triggered by ${cause} requires list update`);
-            this.emit(LISTS_UPDATE_EVENT, this);
+            if (!window.mx_QuietRoomListLogging) {
+                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                console.log(`[DEBUG] Room "${room.name}" (${room.roomId}) triggered by ${cause} requires list update`);
+            }
+            this.updateFn.mark();
         }
     }
 
     public async setTagSorting(tagId: TagID, sort: SortAlgorithm) {
+        await this.setAndPersistTagSorting(tagId, sort);
+        this.updateFn.trigger();
+    }
+
+    private async setAndPersistTagSorting(tagId: TagID, sort: SortAlgorithm) {
         await this.algorithm.setTagSorting(tagId, sort);
         // TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114
         localStorage.setItem(`mx_tagSort_${tagId}`, sort);
@@ -321,7 +423,34 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
         return <SortAlgorithm>localStorage.getItem(`mx_tagSort_${tagId}`);
     }
 
+    // logic must match calculateListOrder
+    private calculateTagSorting(tagId: TagID): SortAlgorithm {
+        const defaultSort = SortAlgorithm.Alphabetic;
+        const settingAlphabetical = SettingsStore.getValue("RoomList.orderAlphabetically", null, true);
+        const definedSort = this.getTagSorting(tagId);
+        const storedSort = this.getStoredTagSorting(tagId);
+
+        // We use the following order to determine which of the 4 flags to use:
+        // Stored > Settings > Defined > Default
+
+        let tagSort = defaultSort;
+        if (storedSort) {
+            tagSort = storedSort;
+        } else if (!isNullOrUndefined(settingAlphabetical)) {
+            tagSort = settingAlphabetical ? SortAlgorithm.Alphabetic : SortAlgorithm.Recent;
+        } else if (definedSort) {
+            tagSort = definedSort;
+        } // else default (already set)
+
+        return tagSort;
+    }
+
     public async setListOrder(tagId: TagID, order: ListAlgorithm) {
+        await this.setAndPersistListOrder(tagId, order);
+        this.updateFn.trigger();
+    }
+
+    private async setAndPersistListOrder(tagId: TagID, order: ListAlgorithm) {
         await this.algorithm.setListOrdering(tagId, order);
         // TODO: Per-account? https://github.com/vector-im/riot-web/issues/14114
         localStorage.setItem(`mx_listOrder_${tagId}`, order);
@@ -337,25 +466,45 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
         return <ListAlgorithm>localStorage.getItem(`mx_listOrder_${tagId}`);
     }
 
-    private async updateAlgorithmInstances() {
-        const defaultSort = SortAlgorithm.Alphabetic;
+    // logic must match calculateTagSorting
+    private calculateListOrder(tagId: TagID): ListAlgorithm {
         const defaultOrder = ListAlgorithm.Natural;
+        const settingImportance = SettingsStore.getValue("RoomList.orderByImportance", null, true);
+        const definedOrder = this.getListOrder(tagId);
+        const storedOrder = this.getStoredListOrder(tagId);
+
+        // We use the following order to determine which of the 4 flags to use:
+        // Stored > Settings > Defined > Default
+
+        let listOrder = defaultOrder;
+        if (storedOrder) {
+            listOrder = storedOrder;
+        } else if (!isNullOrUndefined(settingImportance)) {
+            listOrder = settingImportance ? ListAlgorithm.Importance : ListAlgorithm.Natural;
+        } else if (definedOrder) {
+            listOrder = definedOrder;
+        } // else default (already set)
+
+        return listOrder;
+    }
+
+    private async updateAlgorithmInstances() {
+        // We'll require an update, so mark for one. Marking now also prevents the calls
+        // to setTagSorting and setListOrder from causing triggers.
+        this.updateFn.mark();
 
         for (const tag of Object.keys(this.orderedLists)) {
             const definedSort = this.getTagSorting(tag);
             const definedOrder = this.getListOrder(tag);
 
-            const storedSort = this.getStoredTagSorting(tag);
-            const storedOrder = this.getStoredListOrder(tag);
-
-            const tagSort = storedSort ? storedSort : (definedSort ? definedSort : defaultSort);
-            const listOrder = storedOrder ? storedOrder : (definedOrder ? definedOrder : defaultOrder);
+            const tagSort = this.calculateTagSorting(tag);
+            const listOrder = this.calculateListOrder(tag);
 
             if (tagSort !== definedSort) {
-                await this.setTagSorting(tag, tagSort);
+                await this.setAndPersistTagSorting(tag, tagSort);
             }
             if (listOrder !== definedOrder) {
-                await this.setListOrder(tag, listOrder);
+                await this.setAndPersistListOrder(tag, listOrder);
             }
         }
     }
@@ -367,19 +516,37 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
     }
 
     private onAlgorithmListUpdated = () => {
-        // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-        console.log("Underlying algorithm has triggered a list update - refiring");
-        this.emit(LISTS_UPDATE_EVENT, this);
+        if (!window.mx_QuietRoomListLogging) {
+            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+            console.log("Underlying algorithm has triggered a list update - marking");
+        }
+        this.updateFn.mark();
     };
 
-    private async regenerateAllLists() {
+    private onAlgorithmFilterUpdated = () => {
+        // The filter can happen off-cycle, so trigger an update. The filter will have
+        // already caused a mark.
+        this.updateFn.trigger();
+    };
+
+    /**
+     * Regenerates the room whole room list, discarding any previous results.
+     *
+     * Note: This is only exposed externally for the tests. Do not call this from within
+     * the app.
+     * @param trigger Set to false to prevent a list update from being sent. Should only
+     * be used if the calling code will manually trigger the update.
+     */
+    public async regenerateAllLists({trigger = true}) {
         console.warn("Regenerating all room lists");
 
         const sorts: ITagSortingMap = {};
         const orders: IListOrderingMap = {};
         for (const tagId of OrderedDefaultTagIDs) {
-            sorts[tagId] = this.getStoredTagSorting(tagId) || SortAlgorithm.Alphabetic;
-            orders[tagId] = this.getStoredListOrder(tagId) || ListAlgorithm.Natural;
+            sorts[tagId] = this.calculateTagSorting(tagId);
+            orders[tagId] = this.calculateListOrder(tagId);
+
+            RoomListLayoutStore.instance.ensureLayoutExists(tagId);
         }
 
         if (this.state.tagsEnabled) {
@@ -395,30 +562,26 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
 
         this.initialListsGenerated = true;
 
-        this.emit(LISTS_UPDATE_EVENT, this);
-    }
-
-    // Note: this primarily exists for debugging, and isn't really intended to be used by anything.
-    public async resetLayouts() {
-        console.warn("Resetting layouts for room list");
-        for (const tagId of Object.keys(this.orderedLists)) {
-            new ListLayout(tagId).reset();
-        }
-        await this.regenerateAllLists();
+        if (trigger) this.updateFn.trigger();
     }
 
     public addFilter(filter: IFilterCondition): void {
-        // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-        console.log("Adding filter condition:", filter);
+        if (!window.mx_QuietRoomListLogging) {
+            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+            console.log("Adding filter condition:", filter);
+        }
         this.filterConditions.push(filter);
         if (this.algorithm) {
             this.algorithm.addFilterCondition(filter);
         }
+        this.updateFn.trigger();
     }
 
     public removeFilter(filter: IFilterCondition): void {
-        // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-        console.log("Removing filter condition:", filter);
+        if (!window.mx_QuietRoomListLogging) {
+            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+            console.log("Removing filter condition:", filter);
+        }
         const idx = this.filterConditions.indexOf(filter);
         if (idx >= 0) {
             this.filterConditions.splice(idx, 1);
@@ -427,6 +590,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
                 this.algorithm.removeFilterCondition(filter);
             }
         }
+        this.updateFn.trigger();
     }
 
     /**
diff --git a/src/stores/room-list/RoomListStoreTempProxy.ts b/src/stores/room-list/RoomListStoreTempProxy.ts
index 86aff178ee..2a5348ab6e 100644
--- a/src/stores/room-list/RoomListStoreTempProxy.ts
+++ b/src/stores/room-list/RoomListStoreTempProxy.ts
@@ -24,11 +24,11 @@ import { ITagMap } from "./algorithms/models";
  * Temporary RoomListStore proxy. Should be replaced with RoomListStore2 when
  * it is available to everyone.
  *
- * TODO: Delete this: https://github.com/vector-im/riot-web/issues/14231
+ * TODO: Delete this: https://github.com/vector-im/riot-web/issues/14367
  */
 export class RoomListStoreTempProxy {
     public static isUsingNewStore(): boolean {
-        return SettingsStore.isFeatureEnabled("feature_new_room_list");
+        return SettingsStore.getValue("feature_new_room_list");
     }
 
     public static addListener(handler: () => void): RoomListStoreTempToken {
diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts
index 36abf86975..17e8283c74 100644
--- a/src/stores/room-list/algorithms/Algorithm.ts
+++ b/src/stores/room-list/algorithms/Algorithm.ts
@@ -18,7 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
 import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
 import DMRoomMap from "../../../utils/DMRoomMap";
 import { EventEmitter } from "events";
-import { arrayHasDiff, ArrayUtil } from "../../../utils/arrays";
+import { arrayDiff, arrayHasDiff, ArrayUtil } from "../../../utils/arrays";
 import { getEnumValues } from "../../../utils/enums";
 import { DefaultTagID, RoomUpdateCause, TagID } from "../models";
 import {
@@ -41,6 +41,17 @@ import { getListAlgorithmInstance } from "./list-ordering";
  */
 export const LIST_UPDATED_EVENT = "list_updated_event";
 
+// These are the causes which require a room to be known in order for us to handle them. If
+// a cause in this list is raised and we don't know about the room, we don't handle the update.
+//
+// Note: these typically happen when a new room is coming in, such as the user creating or
+// joining the room. For these cases, we need to know about the room prior to handling it otherwise
+// we'll make bad assumptions.
+const CAUSES_REQUIRING_ROOM = [
+    RoomUpdateCause.Timeline,
+    RoomUpdateCause.ReadReceipt,
+];
+
 interface IStickyRoom {
     room: Room;
     position: number;
@@ -57,6 +68,7 @@ export class Algorithm extends EventEmitter {
     private _cachedStickyRooms: ITagMap = {}; // a clone of the _cachedRooms, with the sticky room
     private filteredRooms: ITagMap = {};
     private _stickyRoom: IStickyRoom = null;
+    private _lastStickyRoom: IStickyRoom = null; // only not-null when changing the sticky room
     private sortAlgorithms: ITagSortingMap;
     private listAlgorithms: IListOrderingMap;
     private algorithms: IOrderingAlgorithmMap;
@@ -75,12 +87,6 @@ export class Algorithm extends EventEmitter {
         return this._stickyRoom ? this._stickyRoom.room : null;
     }
 
-    public set stickyRoom(val: Room) {
-        // setters can't be async, so we call a private function to do the work
-        // noinspection JSIgnoredPromiseFromCall
-        this.updateStickyRoom(val);
-    }
-
     protected get hasFilters(): boolean {
         return this.allowedByFilter.size > 0;
     }
@@ -103,11 +109,12 @@ export class Algorithm extends EventEmitter {
      * Awaitable version of the sticky room setter.
      * @param val The new room to sticky.
      */
-    public async setStickyRoomAsync(val: Room) {
+    public async setStickyRoom(val: Room) {
         await this.updateStickyRoom(val);
     }
 
     public getTagSorting(tagId: TagID): SortAlgorithm {
+        if (!this.sortAlgorithms) return null;
         return this.sortAlgorithms[tagId];
     }
 
@@ -124,6 +131,7 @@ export class Algorithm extends EventEmitter {
     }
 
     public getListOrdering(tagId: TagID): ListAlgorithm {
+        if (!this.listAlgorithms) return null;
         return this.listAlgorithms[tagId];
     }
 
@@ -145,11 +153,11 @@ export class Algorithm extends EventEmitter {
         // Populate the cache of the new filter
         this.allowedByFilter.set(filterCondition, this.rooms.filter(r => filterCondition.isVisible(r)));
         this.recalculateFilteredRooms();
-        filterCondition.on(FILTER_CHANGED, this.recalculateFilteredRooms.bind(this));
+        filterCondition.on(FILTER_CHANGED, this.handleFilterChange.bind(this));
     }
 
     public removeFilterCondition(filterCondition: IFilterCondition): void {
-        filterCondition.off(FILTER_CHANGED, this.recalculateFilteredRooms.bind(this));
+        filterCondition.off(FILTER_CHANGED, this.handleFilterChange.bind(this));
         if (this.allowedByFilter.has(filterCondition)) {
             this.allowedByFilter.delete(filterCondition);
 
@@ -161,10 +169,29 @@ export class Algorithm extends EventEmitter {
         }
     }
 
+    private async handleFilterChange() {
+        await this.recalculateFilteredRooms();
+
+        // re-emit the update so the list store can fire an off-cycle update if needed
+        this.emit(FILTER_CHANGED);
+    }
+
     private async updateStickyRoom(val: Room) {
+        try {
+            return await this.doUpdateStickyRoom(val);
+        } finally {
+            this._lastStickyRoom = null; // clear to indicate we're done changing
+        }
+    }
+
+    private async doUpdateStickyRoom(val: Room) {
         // Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing,
         // otherwise we risk duplicating rooms.
 
+        // Set the last sticky room to indicate that we're in a change. The code throughout the
+        // class can safely handle a null room, so this should be safe to do as a backup.
+        this._lastStickyRoom = this._stickyRoom || <IStickyRoom>{};
+
         // It's possible to have no selected room. In that case, clear the sticky room
         if (!val) {
             if (this._stickyRoom) {
@@ -179,7 +206,7 @@ export class Algorithm extends EventEmitter {
         }
 
         // When we do have a room though, we expect to be able to find it
-        const tag = this.roomIdsToTags[val.roomId][0];
+        let tag = this.roomIdsToTags[val.roomId][0];
         if (!tag) throw new Error(`${val.roomId} does not belong to a tag and cannot be sticky`);
 
         // We specifically do NOT use the ordered rooms set as it contains the sticky room, which
@@ -196,19 +223,41 @@ export class Algorithm extends EventEmitter {
         // the same thing it no-ops. After we're done calling the algorithm, we'll issue
         // a new update for ourselves.
         const lastStickyRoom = this._stickyRoom;
-        this._stickyRoom = null;
+        this._stickyRoom = null; // clear before we update the algorithm
         this.recalculateStickyRoom();
 
         // When we do have the room, re-add the old room (if needed) to the algorithm
         // and remove the sticky room from the algorithm. This is so the underlying
         // algorithm doesn't try and confuse itself with the sticky room concept.
-        if (lastStickyRoom) {
+        // We don't add the new room if the sticky room isn't changing because that's
+        // an easy way to cause duplication. We have to do room ID checks instead of
+        // referential checks as the references can differ through the lifecycle.
+        if (lastStickyRoom && lastStickyRoom.room && lastStickyRoom.room.roomId !== val.roomId) {
             // Lie to the algorithm and re-add the room to the algorithm
             await this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom);
         }
         // Lie to the algorithm and remove the room from it's field of view
         await this.handleRoomUpdate(val, RoomUpdateCause.RoomRemoved);
 
+        // Check for tag & position changes while we're here. We also check the room to ensure
+        // it is still the same room.
+        if (this._stickyRoom) {
+            if (this._stickyRoom.room !== val) {
+                // Check the room IDs just in case
+                if (this._stickyRoom.room.roomId === val.roomId) {
+                    console.warn("Sticky room changed references");
+                } else {
+                    throw new Error("Sticky room changed while the sticky room was changing");
+                }
+            }
+
+            console.warn(`Sticky room changed tag & position from ${tag} / ${position} `
+                + `to ${this._stickyRoom.tag} / ${this._stickyRoom.position}`);
+
+            tag = this._stickyRoom.tag;
+            position = this._stickyRoom.position;
+        }
+
         // Now that we're done lying to the algorithm, we need to update our position
         // marker only if the user is moving further down the same list. If they're switching
         // lists, or moving upwards, the position marker will splice in just fine but if
@@ -273,8 +322,10 @@ export class Algorithm extends EventEmitter {
             }
             newMap[tagId] = allowedRoomsInThisTag;
 
-            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-            console.log(`[DEBUG] ${newMap[tagId].length}/${rooms.length} rooms filtered into ${tagId}`);
+            if (!window.mx_QuietRoomListLogging) {
+                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                console.log(`[DEBUG] ${newMap[tagId].length}/${rooms.length} rooms filtered into ${tagId}`);
+            }
         }
 
         const allowedRooms = Object.values(newMap).reduce((rv, v) => { rv.push(...v); return rv; }, <Room[]>[]);
@@ -283,26 +334,13 @@ export class Algorithm extends EventEmitter {
         this.emit(LIST_UPDATED_EVENT);
     }
 
-    // TODO: Remove or use.
-    protected addPossiblyFilteredRoomsToTag(tagId: TagID, added: Room[]): void {
-        const filters = this.allowedByFilter.keys();
-        for (const room of added) {
-            for (const filter of filters) {
-                if (filter.isVisible(room)) {
-                    this.allowedRoomsByFilters.add(room);
-                    break;
-                }
-            }
-        }
-
-        // Now that we've updated the allowed rooms, recalculate the tag
-        this.recalculateFilteredRoomsForTag(tagId);
-    }
-
     protected recalculateFilteredRoomsForTag(tagId: TagID): void {
         if (!this.hasFilters) return; // don't bother doing work if there's nothing to do
 
-        console.log(`Recalculating filtered rooms for ${tagId}`);
+        if (!window.mx_QuietRoomListLogging) {
+            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+            console.log(`Recalculating filtered rooms for ${tagId}`);
+        }
         delete this.filteredRooms[tagId];
         const rooms = this.cachedRooms[tagId].map(r => r); // cheap clone
         this.tryInsertStickyRoomToFilterSet(rooms, tagId);
@@ -311,8 +349,10 @@ export class Algorithm extends EventEmitter {
             this.filteredRooms[tagId] = filteredRooms;
         }
 
-        // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-        console.log(`[DEBUG] ${filteredRooms.length}/${rooms.length} rooms filtered into ${tagId}`);
+        if (!window.mx_QuietRoomListLogging) {
+            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+            console.log(`[DEBUG] ${filteredRooms.length}/${rooms.length} rooms filtered into ${tagId}`);
+        }
     }
 
     protected tryInsertStickyRoomToFilterSet(rooms: Room[], tagId: TagID) {
@@ -351,8 +391,10 @@ export class Algorithm extends EventEmitter {
         }
 
         if (!this._cachedStickyRooms || !updatedTag) {
-            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-            console.log(`Generating clone of cached rooms for sticky room handling`);
+            if (!window.mx_QuietRoomListLogging) {
+                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                console.log(`Generating clone of cached rooms for sticky room handling`);
+            }
             const stickiedTagMap: ITagMap = {};
             for (const tagId of Object.keys(this.cachedRooms)) {
                 stickiedTagMap[tagId] = this.cachedRooms[tagId].map(r => r); // shallow clone
@@ -363,8 +405,10 @@ export class Algorithm extends EventEmitter {
         if (updatedTag) {
             // Update the tag indicated by the caller, if possible. This is mostly to ensure
             // our cache is up to date.
-            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-            console.log(`Replacing cached sticky rooms for ${updatedTag}`);
+            if (!window.mx_QuietRoomListLogging) {
+                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                console.log(`Replacing cached sticky rooms for ${updatedTag}`);
+            }
             this._cachedStickyRooms[updatedTag] = this.cachedRooms[updatedTag].map(r => r); // shallow clone
         }
 
@@ -373,8 +417,10 @@ export class Algorithm extends EventEmitter {
         // we might have updated from the cache is also our sticky room.
         const sticky = this._stickyRoom;
         if (!updatedTag || updatedTag === sticky.tag) {
-            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-            console.log(`Inserting sticky room ${sticky.room.roomId} at position ${sticky.position} in ${sticky.tag}`);
+            if (!window.mx_QuietRoomListLogging) {
+                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                console.log(`Inserting sticky room ${sticky.room.roomId} at position ${sticky.position} in ${sticky.tag}`);
+            }
             this._cachedStickyRooms[sticky.tag].splice(sticky.position, 0, sticky.room);
         }
 
@@ -466,13 +512,9 @@ export class Algorithm extends EventEmitter {
         // Split out the easy rooms first (leave and invite)
         const memberships = splitRoomsByMembership(rooms);
         for (const room of memberships[EffectiveMembership.Invite]) {
-            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-            console.log(`[DEBUG] "${room.name}" (${room.roomId}) is an Invite`);
             newTags[DefaultTagID.Invite].push(room);
         }
         for (const room of memberships[EffectiveMembership.Leave]) {
-            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-            console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Historical`);
             newTags[DefaultTagID.Archived].push(room);
         }
 
@@ -483,11 +525,7 @@ export class Algorithm extends EventEmitter {
             let inTag = false;
             if (tags.length > 0) {
                 for (const tag of tags) {
-                    // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-                    console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged as ${tag}`);
                     if (!isNullOrUndefined(newTags[tag])) {
-                        // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-                        console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged with VALID tag ${tag}`);
                         newTags[tag].push(room);
                         inTag = true;
                     }
@@ -495,11 +533,11 @@ export class Algorithm extends EventEmitter {
             }
 
             if (!inTag) {
-                // TODO: Determine if DM and push there instead: https://github.com/vector-im/riot-web/issues/14236
-                newTags[DefaultTagID.Untagged].push(room);
-
-                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-                console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Untagged`);
+                if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
+                    newTags[DefaultTagID.DM].push(room);
+                } else {
+                    newTags[DefaultTagID.Untagged].push(room);
+                }
             }
         }
 
@@ -560,7 +598,7 @@ export class Algorithm extends EventEmitter {
     /**
      * Updates the roomsToTags map
      */
-    protected updateTagsFromCache() {
+    private updateTagsFromCache() {
         const newMap = {};
 
         const tags = Object.keys(this.cachedRooms);
@@ -607,21 +645,118 @@ export class Algorithm extends EventEmitter {
      * processing.
      */
     public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
+        if (!window.mx_QuietRoomListLogging) {
+            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+            console.log(`Handle room update for ${room.roomId} called with cause ${cause}`);
+        }
         if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
 
+        // Note: check the isSticky against the room ID just in case the reference is wrong
+        const isSticky = this._stickyRoom && this._stickyRoom.room && this._stickyRoom.room.roomId === room.roomId;
         if (cause === RoomUpdateCause.NewRoom) {
+            const isForLastSticky = this._lastStickyRoom && this._lastStickyRoom.room === room;
             const roomTags = this.roomIdsToTags[room.roomId];
-            if (roomTags && roomTags.length > 0) {
+            const hasTags = roomTags && roomTags.length > 0;
+
+            // Don't change the cause if the last sticky room is being re-added. If we fail to
+            // pass the cause through as NewRoom, we'll fail to lie to the algorithm and thus
+            // lose the room.
+            if (hasTags && !isForLastSticky) {
                 console.warn(`${room.roomId} is reportedly new but is already known - assuming TagChange instead`);
                 cause = RoomUpdateCause.PossibleTagChange;
             }
+
+            // Check to see if the room is known first
+            let knownRoomRef = this.rooms.includes(room);
+            if (hasTags && !knownRoomRef) {
+                console.warn(`${room.roomId} might be a reference change - attempting to update reference`);
+                this.rooms = this.rooms.map(r => r.roomId === room.roomId ? room : r);
+                knownRoomRef = this.rooms.includes(room);
+                if (!knownRoomRef) {
+                    console.warn(`${room.roomId} is still not referenced. It may be sticky.`);
+                }
+            }
+
+            // If we have tags for a room and don't have the room referenced, something went horribly
+            // wrong - the reference should have been updated above.
+            if (hasTags && !knownRoomRef && !isSticky) {
+                throw new Error(`${room.roomId} is missing from room array but is known - trying to find duplicate`);
+            }
+
+            // Like above, update the reference to the sticky room if we need to
+            if (hasTags && isSticky) {
+                // Go directly in and set the sticky room's new reference, being careful not
+                // to trigger a sticky room update ourselves.
+                this._stickyRoom.room = room;
+            }
+
+            // If after all that we're still a NewRoom update, add the room if applicable.
+            // We don't do this for the sticky room (because it causes duplication issues)
+            // or if we know about the reference (as it should be replaced).
+            if (cause === RoomUpdateCause.NewRoom && !isSticky && !knownRoomRef) {
+                this.rooms.push(room);
+            }
         }
 
         if (cause === RoomUpdateCause.PossibleTagChange) {
-            // TODO: Be smarter and splice rather than regen the planet. https://github.com/vector-im/riot-web/issues/14035
-            // TODO: No-op if no change. https://github.com/vector-im/riot-web/issues/14035
-            await this.setKnownRooms(this.rooms);
-            return true;
+            let didTagChange = false;
+            const oldTags = this.roomIdsToTags[room.roomId] || [];
+            const newTags = this.getTagsForRoom(room);
+            const diff = arrayDiff(oldTags, newTags);
+            if (diff.removed.length > 0 || diff.added.length > 0) {
+                for (const rmTag of diff.removed) {
+                    if (!window.mx_QuietRoomListLogging) {
+                        // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                        console.log(`Removing ${room.roomId} from ${rmTag}`);
+                    }
+                    const algorithm: OrderingAlgorithm = this.algorithms[rmTag];
+                    if (!algorithm) throw new Error(`No algorithm for ${rmTag}`);
+                    await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
+                    this.cachedRooms[rmTag] = algorithm.orderedRooms;
+                }
+                for (const addTag of diff.added) {
+                    if (!window.mx_QuietRoomListLogging) {
+                        // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                        console.log(`Adding ${room.roomId} to ${addTag}`);
+                    }
+                    const algorithm: OrderingAlgorithm = this.algorithms[addTag];
+                    if (!algorithm) throw new Error(`No algorithm for ${addTag}`);
+                    await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom);
+                    this.cachedRooms[addTag] = algorithm.orderedRooms;
+                }
+
+                // Update the tag map so we don't regen it in a moment
+                this.roomIdsToTags[room.roomId] = newTags;
+
+                if (!window.mx_QuietRoomListLogging) {
+                    // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                    console.log(`Changing update cause for ${room.roomId} to Timeline to sort rooms`);
+                }
+                cause = RoomUpdateCause.Timeline;
+                didTagChange = true;
+            } else {
+                if (!window.mx_QuietRoomListLogging) {
+                    // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                    console.log(`Received no-op update for ${room.roomId} - changing to Timeline update`);
+                }
+                cause = RoomUpdateCause.Timeline;
+            }
+
+            if (didTagChange && isSticky) {
+                // Manually update the tag for the sticky room without triggering a sticky room
+                // update. The update will be handled implicitly by the sticky room handling and
+                // requires no changes on our part, if we're in the middle of a sticky room change.
+                if (this._lastStickyRoom) {
+                    this._stickyRoom = {
+                        room,
+                        tag: this.roomIdsToTags[room.roomId][0],
+                        position: 0, // right at the top as it changed tags
+                    };
+                } else {
+                    // We have to clear the lock as the sticky room change will trigger updates.
+                    await this.setStickyRoom(room);
+                }
+            }
         }
 
         // If the update is for a room change which might be the sticky room, prevent it. We
@@ -629,14 +764,27 @@ export class Algorithm extends EventEmitter {
         // as the sticky room relies on this.
         if (cause !== RoomUpdateCause.NewRoom && cause !== RoomUpdateCause.RoomRemoved) {
             if (this.stickyRoom === room) {
-                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-                console.warn(`[RoomListDebug] Received ${cause} update for sticky room ${room.roomId} - ignoring`);
+                if (!window.mx_QuietRoomListLogging) {
+                    // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                    console.warn(`[RoomListDebug] Received ${cause} update for sticky room ${room.roomId} - ignoring`);
+                }
                 return false;
             }
         }
 
-        if (cause === RoomUpdateCause.NewRoom && !this.roomIdsToTags[room.roomId]) {
-            console.log(`[RoomListDebug] Updating tags for new room ${room.roomId} (${room.name})`);
+        if (!this.roomIdsToTags[room.roomId]) {
+            if (CAUSES_REQUIRING_ROOM.includes(cause)) {
+                if (!window.mx_QuietRoomListLogging) {
+                    // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                    console.warn(`Skipping tag update for ${room.roomId} because we don't know about the room`);
+                }
+                return false;
+            }
+
+            if (!window.mx_QuietRoomListLogging) {
+                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                console.log(`[RoomListDebug] Updating tags for room ${room.roomId} (${room.name})`);
+            }
 
             // Get the tags for the room and populate the cache
             const roomTags = this.getTagsForRoom(room).filter(t => !isNullOrUndefined(this.cachedRooms[t]));
@@ -646,9 +794,19 @@ export class Algorithm extends EventEmitter {
             if (!roomTags.length) throw new Error(`Tags cannot be determined for ${room.roomId}`);
 
             this.roomIdsToTags[room.roomId] = roomTags;
+
+            if (!window.mx_QuietRoomListLogging) {
+                // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+                console.log(`[RoomListDebug] Updated tags for ${room.roomId}:`, roomTags);
+            }
         }
 
-        let tags = this.roomIdsToTags[room.roomId];
+        if (!window.mx_QuietRoomListLogging) {
+            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+            console.log(`[RoomListDebug] Reached algorithmic handling for ${room.roomId} and cause ${cause}`);
+        }
+
+        const tags = this.roomIdsToTags[room.roomId];
         if (!tags) {
             console.warn(`No tags known for "${room.name}" (${room.roomId})`);
             return false;
@@ -668,6 +826,10 @@ export class Algorithm extends EventEmitter {
             changed = true;
         }
 
-        return true;
+        if (!window.mx_QuietRoomListLogging) {
+            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
+            console.log(`[RoomListDebug] Finished handling ${room.roomId} with cause ${cause} (changed=${changed})`);
+        }
+        return changed;
     }
 }
diff --git a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts
index e95f92f985..b3f1c2b146 100644
--- a/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts
+++ b/src/stores/room-list/algorithms/list-ordering/ImportanceAlgorithm.ts
@@ -19,47 +19,29 @@ import { Room } from "matrix-js-sdk/src/models/room";
 import { RoomUpdateCause, TagID } from "../../models";
 import { SortAlgorithm } from "../models";
 import { sortRoomsWithAlgorithm } from "../tag-sorting";
-import * as Unread from '../../../../Unread';
 import { OrderingAlgorithm } from "./OrderingAlgorithm";
-
-/**
- * The determined category of a room.
- */
-export enum Category {
-    /**
-     * The room has unread mentions within.
-     */
-    Red = "RED",
-    /**
-     * The room has unread notifications within. Note that these are not unread
-     * mentions - they are simply messages which the user has asked to cause a
-     * badge count update or push notification.
-     */
-    Grey = "GREY",
-    /**
-     * The room has unread messages within (grey without the badge).
-     */
-    Bold = "BOLD",
-    /**
-     * The room has no relevant unread messages within.
-     */
-    Idle = "IDLE",
-}
+import { NotificationColor } from "../../../notifications/NotificationColor";
+import { RoomNotificationStateStore } from "../../../notifications/RoomNotificationStateStore";
 
 interface ICategorizedRoomMap {
     // @ts-ignore - TS wants this to be a string, but we know better
-    [category: Category]: Room[];
+    [category: NotificationColor]: Room[];
 }
 
 interface ICategoryIndex {
     // @ts-ignore - TS wants this to be a string, but we know better
-    [category: Category]: number; // integer
+    [category: NotificationColor]: number; // integer
 }
 
 // Caution: changing this means you'll need to update a bunch of assumptions and
 // comments! Check the usage of Category carefully to figure out what needs changing
 // if you're going to change this array's order.
-const CATEGORY_ORDER = [Category.Red, Category.Grey, Category.Bold, Category.Idle];
+const CATEGORY_ORDER = [
+    NotificationColor.Red,
+    NotificationColor.Grey,
+    NotificationColor.Bold,
+    NotificationColor.None, // idle
+];
 
 /**
  * An implementation of the "importance" algorithm for room list sorting. Where
@@ -87,18 +69,15 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
 
     public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
         super(tagId, initialSortingAlgorithm);
-
-        // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-        console.log(`[RoomListDebug] Constructed an ImportanceAlgorithm for ${tagId}`);
     }
 
     // noinspection JSMethodCanBeStatic
     private categorizeRooms(rooms: Room[]): ICategorizedRoomMap {
         const map: ICategorizedRoomMap = {
-            [Category.Red]: [],
-            [Category.Grey]: [],
-            [Category.Bold]: [],
-            [Category.Idle]: [],
+            [NotificationColor.Red]: [],
+            [NotificationColor.Grey]: [],
+            [NotificationColor.Bold]: [],
+            [NotificationColor.None]: [],
         };
         for (const room of rooms) {
             const category = this.getRoomCategory(room);
@@ -108,25 +87,11 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
     }
 
     // noinspection JSMethodCanBeStatic
-    private getRoomCategory(room: Room): Category {
-        // Function implementation borrowed from old RoomListStore
-
-        const mentions = room.getUnreadNotificationCount('highlight') > 0;
-        if (mentions) {
-            return Category.Red;
-        }
-
-        let unread = room.getUnreadNotificationCount() > 0;
-        if (unread) {
-            return Category.Grey;
-        }
-
-        unread = Unread.doesRoomHaveUnreadMessages(room);
-        if (unread) {
-            return Category.Bold;
-        }
-
-        return Category.Idle;
+    private getRoomCategory(room: Room): NotificationColor {
+        // It's fine for us to call this a lot because it's cached, and we shouldn't be
+        // wasting anything by doing so as the store holds single references
+        const state = RoomNotificationStateStore.instance.getRoomState(room, this.tagId);
+        return state.color;
     }
 
     public async setRooms(rooms: Room[]): Promise<any> {
@@ -160,7 +125,10 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
             this.cachedOrderedRooms.splice(this.indices[category], 0, room); // splice in the new room (pre-adjusted)
         } else if (cause === RoomUpdateCause.RoomRemoved) {
             const roomIdx = this.getRoomIndex(room);
-            if (roomIdx === -1) return false; // no change
+            if (roomIdx === -1) {
+                console.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`);
+                return false; // no change
+            }
             const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices);
             this.alterCategoryPositionBy(oldCategory, -1, this.indices);
             this.cachedOrderedRooms.splice(roomIdx, 1); // remove the room
@@ -169,15 +137,6 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
         }
     }
 
-    private getRoomIndex(room: Room): number {
-        let roomIdx = this.cachedOrderedRooms.indexOf(room);
-        if (roomIdx === -1) { // can only happen if the js-sdk's store goes sideways.
-            console.warn(`Degrading performance to find missing room in "${this.tagId}": ${room.roomId}`);
-            roomIdx = this.cachedOrderedRooms.findIndex(r => r.roomId === room.roomId);
-        }
-        return roomIdx;
-    }
-
     public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
         try {
             await this.updateLock.acquireAsync();
@@ -226,7 +185,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
         }
     }
 
-    private async sortCategory(category: Category) {
+    private async sortCategory(category: NotificationColor) {
         // This should be relatively quick because the room is usually inserted at the top of the
         // category, and most popular sorting algorithms will deal with trying to keep the active
         // room at the top/start of the category. For the few algorithms that will have to move the
@@ -243,7 +202,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
     }
 
     // noinspection JSMethodCanBeStatic
-    private getCategoryFromIndices(index: number, indices: ICategoryIndex): Category {
+    private getCategoryFromIndices(index: number, indices: ICategoryIndex): NotificationColor {
         for (let i = 0; i < CATEGORY_ORDER.length; i++) {
             const category = CATEGORY_ORDER[i];
             const isLast = i === (CATEGORY_ORDER.length - 1);
@@ -259,7 +218,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
     }
 
     // noinspection JSMethodCanBeStatic
-    private moveRoomIndexes(nRooms: number, fromCategory: Category, toCategory: Category, indices: ICategoryIndex) {
+    private moveRoomIndexes(nRooms: number, fromCategory: NotificationColor, toCategory: NotificationColor, indices: ICategoryIndex) {
         // We have to update the index of the category *after* the from/toCategory variables
         // in order to update the indices correctly. Because the room is moving from/to those
         // categories, the next category's index will change - not the category we're modifying.
@@ -270,7 +229,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
         this.alterCategoryPositionBy(toCategory, +nRooms, indices);
     }
 
-    private alterCategoryPositionBy(category: Category, n: number, indices: ICategoryIndex) {
+    private alterCategoryPositionBy(category: NotificationColor, n: number, indices: ICategoryIndex) {
         // Note: when we alter a category's index, we actually have to modify the ones following
         // the target and not the target itself.
 
diff --git a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts
index f74329cb4d..ae1a2c98f6 100644
--- a/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts
+++ b/src/stores/room-list/algorithms/list-ordering/NaturalAlgorithm.ts
@@ -28,9 +28,6 @@ export class NaturalAlgorithm extends OrderingAlgorithm {
 
     public constructor(tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
         super(tagId, initialSortingAlgorithm);
-
-        // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-        console.log(`[RoomListDebug] Constructed a NaturalAlgorithm for ${tagId}`);
     }
 
     public async setRooms(rooms: Room[]): Promise<any> {
@@ -50,8 +47,12 @@ export class NaturalAlgorithm extends OrderingAlgorithm {
             if (cause === RoomUpdateCause.NewRoom) {
                 this.cachedOrderedRooms.push(room);
             } else if (cause === RoomUpdateCause.RoomRemoved) {
-                const idx = this.cachedOrderedRooms.indexOf(room);
-                if (idx >= 0) this.cachedOrderedRooms.splice(idx, 1);
+                const idx = this.getRoomIndex(room);
+                if (idx >= 0) {
+                    this.cachedOrderedRooms.splice(idx, 1);
+                } else {
+                    console.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`);
+                }
             }
 
             // TODO: Optimize this to avoid useless operations: https://github.com/vector-im/riot-web/issues/14035
diff --git a/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts b/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts
index 4ab7650367..c47a35523c 100644
--- a/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts
+++ b/src/stores/room-list/algorithms/list-ordering/OrderingAlgorithm.ts
@@ -70,4 +70,13 @@ export abstract class OrderingAlgorithm {
      * @returns True if the update requires the Algorithm to update the presentation layers.
      */
     public abstract handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean>;
+
+    protected getRoomIndex(room: Room): number {
+        let roomIdx = this.cachedOrderedRooms.indexOf(room);
+        if (roomIdx === -1) { // can only happen if the js-sdk's store goes sideways.
+            console.warn(`Degrading performance to find missing room in "${this.tagId}": ${room.roomId}`);
+            roomIdx = this.cachedOrderedRooms.findIndex(r => r.roomId === room.roomId);
+        }
+        return roomIdx;
+    }
 }
diff --git a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts
index a122ee3ae6..e7ca94ed95 100644
--- a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts
+++ b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts
@@ -19,6 +19,7 @@ import { TagID } from "../../models";
 import { IAlgorithm } from "./IAlgorithm";
 import { MatrixClientPeg } from "../../../../MatrixClientPeg";
 import * as Unread from "../../../../Unread";
+import { EffectiveMembership, getEffectiveMembership } from "../../membership";
 
 /**
  * Sorts rooms according to the last event's timestamp in each room that seems
@@ -37,6 +38,8 @@ export class RecentAlgorithm implements IAlgorithm {
         // actually changed (probably needs to be done higher up?) then we could do an
         // insertion sort or similar on the limited set of changes.
 
+        const myUserId = MatrixClientPeg.get().getUserId();
+
         const tsCache: { [roomId: string]: number } = {};
         const getLastTs = (r: Room) => {
             if (tsCache[r.roomId]) {
@@ -50,13 +53,23 @@ export class RecentAlgorithm implements IAlgorithm {
                     return Number.MAX_SAFE_INTEGER;
                 }
 
+                // If the room hasn't been joined yet, it probably won't have a timeline to
+                // parse. We'll still fall back to the timeline if this fails, but chances
+                // are we'll at least have our own membership event to go off of.
+                const effectiveMembership = getEffectiveMembership(r.getMyMembership());
+                if (effectiveMembership !== EffectiveMembership.Join) {
+                    const membershipEvent = r.currentState.getStateEvents("m.room.member", myUserId);
+                    if (membershipEvent && !Array.isArray(membershipEvent)) {
+                        return membershipEvent.getTs();
+                    }
+                }
+
                 for (let i = r.timeline.length - 1; i >= 0; --i) {
                     const ev = r.timeline[i];
                     if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?)
 
                     // TODO: Don't assume we're using the same client as the peg
-                    if (ev.getSender() === MatrixClientPeg.get().getUserId()
-                        || Unread.eventTriggersUnreadCount(ev)) {
+                    if (ev.getSender() === myUserId || Unread.eventTriggersUnreadCount(ev)) {
                         return ev.getTs();
                     }
                 }
diff --git a/src/stores/room-list/filters/CommunityFilterCondition.ts b/src/stores/room-list/filters/CommunityFilterCondition.ts
index 9f7d8daaa3..45e65fb4f4 100644
--- a/src/stores/room-list/filters/CommunityFilterCondition.ts
+++ b/src/stores/room-list/filters/CommunityFilterCondition.ts
@@ -52,8 +52,6 @@ export class CommunityFilterCondition extends EventEmitter implements IFilterCon
         const beforeRoomIds = this.roomIds;
         this.roomIds = (await GroupStore.getGroupRooms(this.community.groupId)).map(r => r.roomId);
         if (arrayHasDiff(beforeRoomIds, this.roomIds)) {
-            // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-            console.log("Updating filter for group: ", this.community.groupId);
             this.emit(FILTER_CHANGED);
         }
     };
diff --git a/src/stores/room-list/filters/NameFilterCondition.ts b/src/stores/room-list/filters/NameFilterCondition.ts
index 12f147990d..6014a122f8 100644
--- a/src/stores/room-list/filters/NameFilterCondition.ts
+++ b/src/stores/room-list/filters/NameFilterCondition.ts
@@ -41,8 +41,6 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio
 
     public set search(val: string) {
         this._search = val;
-        // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
-        console.log("Updating filter for room name search:", this._search);
         this.emit(FILTER_CHANGED);
     }
 
diff --git a/src/stores/room-list/previews/MessageEventPreview.ts b/src/stores/room-list/previews/MessageEventPreview.ts
index 86ec4c539b..86cb51ef15 100644
--- a/src/stores/room-list/previews/MessageEventPreview.ts
+++ b/src/stores/room-list/previews/MessageEventPreview.ts
@@ -20,6 +20,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import { _t } from "../../../languageHandler";
 import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
 import ReplyThread from "../../../components/views/elements/ReplyThread";
+import { sanitizedHtmlNodeInnerText } from "../../../HtmlUtils";
 
 export class MessageEventPreview implements IPreview {
     public getTextFor(event: MatrixEvent, tagId?: TagID): string {
@@ -36,14 +37,27 @@ export class MessageEventPreview implements IPreview {
         const msgtype = eventContent['msgtype'];
         if (!body || !msgtype) return null; // invalid event, no preview
 
+        const hasHtml = eventContent.format === "org.matrix.custom.html" && eventContent.formatted_body;
+        if (hasHtml) {
+            body = eventContent.formatted_body;
+        }
+
         // XXX: Newer relations have a getRelation() function which is not compatible with replies.
         const mRelatesTo = event.getWireContent()['m.relates_to'];
         if (mRelatesTo && mRelatesTo['m.in_reply_to']) {
             // If this is a reply, get the real reply and use that
-            body = (ReplyThread.stripPlainReply(body) || '').trim();
+            if (hasHtml) {
+                body = (ReplyThread.stripHTMLReply(body) || '').trim();
+            } else {
+                body = (ReplyThread.stripPlainReply(body) || '').trim();
+            }
             if (!body) return null; // invalid event, no preview
         }
 
+        if (hasHtml) {
+            body = sanitizedHtmlNodeInnerText(body);
+        }
+
         if (msgtype === 'm.emote') {
             return _t("%(senderName)s %(emote)s", {senderName: getSenderName(event), emote: body});
         }
diff --git a/src/utils/MarkedExecution.ts b/src/utils/MarkedExecution.ts
new file mode 100644
index 0000000000..b0b8fdf63d
--- /dev/null
+++ b/src/utils/MarkedExecution.ts
@@ -0,0 +1,56 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/**
+ * A utility to ensure that a function is only called once triggered with
+ * a mark applied. Multiple marks can be applied to the function, however
+ * the function will only be called once upon trigger().
+ *
+ * The function starts unmarked.
+ */
+export class MarkedExecution {
+    private marked = false;
+
+    /**
+     * Creates a MarkedExecution for the provided function.
+     * @param fn The function to be called upon trigger if marked.
+     */
+    constructor(private fn: () => void) {
+    }
+
+    /**
+     * Resets the mark without calling the function.
+     */
+    public reset() {
+        this.marked = false;
+    }
+
+    /**
+     * Marks the function to be called upon trigger().
+     */
+    public mark() {
+        this.marked = true;
+    }
+
+    /**
+     * If marked, the function will be called, otherwise this does nothing.
+     */
+    public trigger() {
+        if (!this.marked) return;
+        this.reset(); // reset first just in case the fn() causes a trigger()
+        this.fn();
+    }
+}
diff --git a/src/utils/pillify.js b/src/utils/pillify.js
index f708ab7770..cb140c61a4 100644
--- a/src/utils/pillify.js
+++ b/src/utils/pillify.js
@@ -111,7 +111,7 @@ export function pillifyLinks(nodes, mxEvent, pills) {
                             type={Pill.TYPE_AT_ROOM_MENTION}
                             inMessage={true}
                             room={room}
-                            shouldShowPillAvatar={true}
+                            shouldShowPillAvatar={shouldShowPillAvatar}
                         />;
 
                         ReactDOM.render(pill, pillContainer);
diff --git a/src/utils/promise.ts b/src/utils/promise.ts
index c5c1cb9a56..d3ae2c3d1b 100644
--- a/src/utils/promise.ts
+++ b/src/utils/promise.ts
@@ -15,7 +15,7 @@ limitations under the License.
 */
 
 // Returns a promise which resolves with a given value after the given number of ms
-export function sleep<T>(ms: number, value: T): Promise<T> {
+export function sleep<T>(ms: number, value?: T): Promise<T> {
     return new Promise((resolve => { setTimeout(resolve, ms, value); }));
 }
 
diff --git a/test/components/views/messages/TextualBody-test.js b/test/components/views/messages/TextualBody-test.js
index 07cd51edbd..1f0749aff5 100644
--- a/test/components/views/messages/TextualBody-test.js
+++ b/test/components/views/messages/TextualBody-test.js
@@ -205,8 +205,9 @@ describe("<TextualBody />", () => {
             expect(content.html()).toBe('<span class="mx_EventTile_body markdown-body" dir="auto">' +
                 'Hey <span>' +
                 '<a class="mx_Pill mx_UserPill" title="@user:server">' +
-                '<img class="mx_BaseAvatar mx_BaseAvatar_image" src="mxc://avatar.url/image.png" ' +
-                'style="width: 16px; height: 16px;" title="@member:domain.bla" alt="" aria-hidden="true">Member</a>' +
+                '<img src="mxc://avatar.url/image.png" style="width: 16px; height: 16px;" ' +
+                'title="@member:domain.bla" alt="" aria-hidden="true" role="button" tabindex="0" ' +
+                'class="mx_AccessibleButton mx_BaseAvatar mx_BaseAvatar_image">Member</a>' +
                 '</span></span>');
         });
     });
diff --git a/test/components/views/rooms/RoomList-test.js b/test/components/views/rooms/RoomList-test.js
index d0694a8437..e84f943708 100644
--- a/test/components/views/rooms/RoomList-test.js
+++ b/test/components/views/rooms/RoomList-test.js
@@ -1,7 +1,6 @@
 import React from 'react';
 import ReactTestUtils from 'react-dom/test-utils';
 import ReactDOM from 'react-dom';
-import lolex from 'lolex';
 
 import * as TestUtils from '../../../test-utils';
 
@@ -15,11 +14,18 @@ import GroupStore from '../../../../src/stores/GroupStore.js';
 
 import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
 import {DefaultTagID} from "../../../../src/stores/room-list/models";
+import RoomListStore, {LISTS_UPDATE_EVENT, RoomListStore2} from "../../../../src/stores/room-list/RoomListStore2";
+import RoomListLayoutStore from "../../../../src/stores/room-list/RoomListLayoutStore";
 
 function generateRoomId() {
     return '!' + Math.random().toString().slice(2, 10) + ':domain';
 }
 
+function waitForRoomListStoreUpdate() {
+    return new Promise((resolve) => {
+        RoomListStore.instance.once(LISTS_UPDATE_EVENT, () => resolve());
+    });
+}
 
 describe('RoomList', () => {
     function createRoom(opts) {
@@ -34,7 +40,6 @@ describe('RoomList', () => {
     let client = null;
     let root = null;
     const myUserId = '@me:domain';
-    let clock = null;
 
     const movingRoomId = '!someroomid';
     let movingRoom;
@@ -43,25 +48,25 @@ describe('RoomList', () => {
     let myMember;
     let myOtherMember;
 
-    beforeEach(function() {
+    beforeEach(async function(done) {
+        RoomListStore2.TEST_MODE = true;
+
         TestUtils.stubClient();
         client = MatrixClientPeg.get();
         client.credentials = {userId: myUserId};
         //revert this to prototype method as the test-utils monkey-patches this to return a hardcoded value
         client.getUserId = MatrixClient.prototype.getUserId;
 
-        clock = lolex.install();
-
         DMRoomMap.makeShared();
 
         parentDiv = document.createElement('div');
         document.body.appendChild(parentDiv);
 
-        const RoomList = sdk.getComponent('views.rooms.RoomList');
+        const RoomList = sdk.getComponent('views.rooms.RoomList2');
         const WrappedRoomList = TestUtils.wrapInMatrixClientContext(RoomList);
         root = ReactDOM.render(
             <DragDropContext>
-                <WrappedRoomList searchFilter="" />
+                <WrappedRoomList searchFilter="" onResize={() => {}} />
             </DragDropContext>
         , parentDiv);
         ReactTestUtils.findRenderedComponentWithType(root, RoomList);
@@ -102,23 +107,29 @@ describe('RoomList', () => {
         });
 
         client.getRoom = (roomId) => roomMap[roomId];
+
+        // Now that everything has been set up, prepare and update the store
+        await RoomListStore.instance.makeReady(client);
+
+        done();
     });
 
-    afterEach((done) => {
+    afterEach(async (done) => {
         if (parentDiv) {
             ReactDOM.unmountComponentAtNode(parentDiv);
             parentDiv.remove();
             parentDiv = null;
         }
 
-        clock.uninstall();
+        await RoomListLayoutStore.instance.resetLayouts();
+        await RoomListStore.instance.resetStore();
 
         done();
     });
 
     function expectRoomInSubList(room, subListTest) {
-        const RoomSubList = sdk.getComponent('structures.RoomSubList');
-        const RoomTile = sdk.getComponent('views.rooms.RoomTile');
+        const RoomSubList = sdk.getComponent('views.rooms.RoomSublist2');
+        const RoomTile = sdk.getComponent('views.rooms.RoomTile2');
 
         const subLists = ReactTestUtils.scryRenderedComponentsWithType(root, RoomSubList);
         const containingSubList = subLists.find(subListTest);
@@ -140,20 +151,20 @@ describe('RoomList', () => {
         expect(expectedRoomTile.props.room).toBe(room);
     }
 
-    function expectCorrectMove(oldTag, newTag) {
-        const getTagSubListTest = (tag) => {
-            if (tag === undefined) return (s) => s.props.label.endsWith('Rooms');
-            return (s) => s.props.tagName === tag;
+    function expectCorrectMove(oldTagId, newTagId) {
+        const getTagSubListTest = (tagId) => {
+            return (s) => s.props.tagId === tagId;
         };
 
         // Default to finding the destination sublist with newTag
-        const destSubListTest = getTagSubListTest(newTag);
-        const srcSubListTest = getTagSubListTest(oldTag);
+        const destSubListTest = getTagSubListTest(newTagId);
+        const srcSubListTest = getTagSubListTest(oldTagId);
 
         // Set up the room that will be moved such that it has the correct state for a room in
-        // the section for oldTag
-        if (['m.favourite', 'm.lowpriority'].includes(oldTag)) movingRoom.tags = {[oldTag]: {}};
-        if (oldTag === DefaultTagID.DM) {
+        // the section for oldTagId
+        if (oldTagId === DefaultTagID.Favourite || oldTagId === DefaultTagID.LowPriority) {
+            movingRoom.tags = {[oldTagId]: {}};
+        } else if (oldTagId === DefaultTagID.DM) {
             // Mock inverse m.direct
             DMRoomMap.shared().roomToUser = {
                 [movingRoom.roomId]: '@someotheruser:domain',
@@ -162,17 +173,12 @@ describe('RoomList', () => {
 
         dis.dispatch({action: 'MatrixActions.sync', prevState: null, state: 'PREPARED', matrixClient: client});
 
-        clock.runAll();
-
         expectRoomInSubList(movingRoom, srcSubListTest);
 
         dis.dispatch({action: 'RoomListActions.tagRoom.pending', request: {
-            oldTag, newTag, room: movingRoom,
+            oldTagId, newTagId, room: movingRoom,
         }});
 
-        // Run all setTimeouts for dispatches and room list rate limiting
-        clock.runAll();
-
         expectRoomInSubList(movingRoom, destSubListTest);
     }
 
@@ -269,6 +275,12 @@ describe('RoomList', () => {
             };
             GroupStore._notifyListeners();
 
+            // We also have to mock the client's getGroup function for the room list to filter it.
+            // It's not smart enough to tell the difference between a real group and a template though.
+            client.getGroup = (groupId) => {
+                return {groupId};
+            };
+
             // Select tag
             dis.dispatch({action: 'select_tag', tag: '+group:domain'}, true);
         }
@@ -277,17 +289,14 @@ describe('RoomList', () => {
             setupSelectedTag();
         });
 
-        it('displays the correct rooms when the groups rooms are changed', () => {
+        it('displays the correct rooms when the groups rooms are changed', async () => {
             GroupStore.getGroupRooms = (groupId) => {
                 return [movingRoom, otherRoom];
             };
             GroupStore._notifyListeners();
 
-            // Run through RoomList debouncing
-            clock.runAll();
-
-            // By default, the test will
-            expectRoomInSubList(otherRoom, (s) => s.props.label.endsWith('Rooms'));
+            await waitForRoomListStoreUpdate();
+            expectRoomInSubList(otherRoom, (s) => s.props.tagId === DefaultTagID.Untagged);
         });
 
         itDoesCorrectOptimisticUpdatesForDraggedRoomTiles();
diff --git a/test/end-to-end-tests/src/usecases/accept-invite.js b/test/end-to-end-tests/src/usecases/accept-invite.js
index 3f208cc1fc..d38fdcd0db 100644
--- a/test/end-to-end-tests/src/usecases/accept-invite.js
+++ b/test/end-to-end-tests/src/usecases/accept-invite.js
@@ -15,10 +15,12 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+const {findSublist} = require("./create-room");
+
 module.exports = async function acceptInvite(session, name) {
     session.log.step(`accepts "${name}" invite`);
-    //TODO: brittle selector
-    const invitesHandles = await session.queryAll('.mx_RoomTile_name.mx_RoomTile_invite');
+    const inviteSublist = await findSublist(session, "invites");
+    const invitesHandles = await inviteSublist.$$(".mx_RoomTile2_name");
     const invitesWithText = await Promise.all(invitesHandles.map(async (inviteHandle) => {
         const text = await session.innerText(inviteHandle);
         return {inviteHandle, text};
diff --git a/test/end-to-end-tests/src/usecases/create-room.js b/test/end-to-end-tests/src/usecases/create-room.js
index 7e219fd159..24e42b92dd 100644
--- a/test/end-to-end-tests/src/usecases/create-room.js
+++ b/test/end-to-end-tests/src/usecases/create-room.js
@@ -16,21 +16,27 @@ limitations under the License.
 */
 
 async function openRoomDirectory(session) {
-    const roomDirectoryButton = await session.query('.mx_LeftPanel_explore .mx_AccessibleButton');
+    const roomDirectoryButton = await session.query('.mx_LeftPanel2_exploreButton');
     await roomDirectoryButton.click();
 }
 
+async function findSublist(session, name) {
+    const sublists = await session.queryAll('.mx_RoomSublist2');
+    for (const sublist of sublists) {
+        const header = await sublist.$('.mx_RoomSublist2_headerText');
+        const headerText = await session.innerText(header);
+        if (headerText.toLowerCase().includes(name.toLowerCase())) {
+            return sublist;
+        }
+    }
+    throw new Error(`could not find room list section that contains '${name}' in header`);
+}
+
 async function createRoom(session, roomName, encrypted=false) {
     session.log.step(`creates room "${roomName}"`);
 
-    const roomListHeaders = await session.queryAll('.mx_RoomSubList_labelContainer');
-    const roomListHeaderLabels = await Promise.all(roomListHeaders.map(h => session.innerText(h)));
-    const roomsIndex = roomListHeaderLabels.findIndex(l => l.toLowerCase().includes("rooms"));
-    if (roomsIndex === -1) {
-        throw new Error("could not find room list section that contains 'rooms' in header");
-    }
-    const roomsHeader = roomListHeaders[roomsIndex];
-    const addRoomButton = await roomsHeader.$(".mx_RoomSubList_addRoom");
+    const roomsSublist = await findSublist(session, "rooms");
+    const addRoomButton = await roomsSublist.$(".mx_RoomSublist2_auxButton");
     await addRoomButton.click();
 
     const roomNameInput = await session.query('.mx_CreateRoomDialog_name input');
@@ -51,14 +57,8 @@ async function createRoom(session, roomName, encrypted=false) {
 async function createDm(session, invitees) {
     session.log.step(`creates DM with ${JSON.stringify(invitees)}`);
 
-    const roomListHeaders = await session.queryAll('.mx_RoomSubList_labelContainer');
-    const roomListHeaderLabels = await Promise.all(roomListHeaders.map(h => session.innerText(h)));
-    const dmsIndex = roomListHeaderLabels.findIndex(l => l.toLowerCase().includes('direct messages'));
-    if (dmsIndex === -1) {
-        throw new Error("could not find room list section that contains 'direct messages' in header");
-    }
-    const dmsHeader = roomListHeaders[dmsIndex];
-    const startChatButton = await dmsHeader.$(".mx_RoomSubList_addRoom");
+    const dmsSublist = await findSublist(session, "people");
+    const startChatButton = await dmsSublist.$(".mx_RoomSublist2_auxButton");
     await startChatButton.click();
 
     const inviteesEditor = await session.query('.mx_InviteDialog_editor textarea');
@@ -83,4 +83,4 @@ async function createDm(session, invitees) {
     session.log.done();
 }
 
-module.exports = {openRoomDirectory, createRoom, createDm};
+module.exports = {openRoomDirectory, findSublist, createRoom, createDm};
diff --git a/yarn.lock b/yarn.lock
index 98b42a0b29..f3dc163b00 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1257,6 +1257,11 @@
   resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.10.tgz#cc658ca319b6355399efc1f5b9e818f1a24bf999"
   integrity sha512-1UzDldn9GfYYEsWWnn/P4wkTlkZDH7lDb0wBMGbtIQc9zXEQq7FlKBdZUn6OBqD8sKZZ2RQO2mAjGpXiDGoRmQ==
 
+"@types/counterpart@^0.18.1":
+  version "0.18.1"
+  resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.1.tgz#b1b784d9e54d9879f0a8cb12f2caedab65430fe8"
+  integrity sha512-PRuFlBBkvdDOtxlIASzTmkEFar+S66Ek48NVVTWMUjtJAdn5vyMSN8y6IZIoIymGpR36q2nZbIYazBWyFxL+IQ==
+
 "@types/fbemitter@*":
   version "2.0.32"
   resolved "https://registry.yarnpkg.com/@types/fbemitter/-/fbemitter-2.0.32.tgz#8ed204da0f54e9c8eaec31b1eec91e25132d082c"
@@ -1303,6 +1308,13 @@
   resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339"
   integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==
 
+"@types/linkifyjs@^2.1.3":
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/@types/linkifyjs/-/linkifyjs-2.1.3.tgz#80195c3c88c5e75d9f660e3046ce4a42be2c2fa4"
+  integrity sha512-V3Xt9wgaOvDPXcpOy3dC8qXCxy3cs0Lr/Hqgd9Bi6m3sf/vpbpTtfmVR0LJklrqYEjaAmc7e3Xh/INT2rCAKjQ==
+  dependencies:
+    "@types/react" "*"
+
 "@types/lodash@^4.14.152":
   version "4.14.155"
   resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.155.tgz#e2b4514f46a261fd11542e47519c20ebce7bc23a"
@@ -1367,6 +1379,13 @@
     "@types/prop-types" "*"
     csstype "^2.2.0"
 
+"@types/sanitize-html@^1.23.3":
+  version "1.23.3"
+  resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-1.23.3.tgz#26527783aba3bf195ad8a3c3e51bd3713526fc0d"
+  integrity sha512-Isg8N0ifKdDq6/kaNlIcWfapDXxxquMSk2XC5THsOICRyOIhQGds95XH75/PL/g9mExi4bL8otIqJM/Wo96WxA==
+  dependencies:
+    htmlparser2 "^4.1.0"
+
 "@types/stack-utils@^1.0.1":
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e"
@@ -2494,7 +2513,7 @@ class-utils@^0.3.5:
     isobject "^3.0.0"
     static-extend "^0.1.1"
 
-classnames@^2.1.2, classnames@^2.2.5:
+classnames@^2.1.2:
   version "2.2.6"
   resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
   integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==
@@ -3774,6 +3793,11 @@ fast-levenshtein@~2.0.6:
   resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
   integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
 
+fast-memoize@^2.5.1:
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/fast-memoize/-/fast-memoize-2.5.2.tgz#79e3bb6a4ec867ea40ba0e7146816f6cdce9b57e"
+  integrity sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==
+
 fb-watchman@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.1.tgz#fc84fb39d2709cf3ff6d743706157bb5708a8a85"
@@ -6877,7 +6901,7 @@ prop-types-exact@^1.2.0:
     object.assign "^4.1.0"
     reflect.ownkeys "^0.2.0"
 
-prop-types@15.x, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
+prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
   version "15.7.2"
   resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
   integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
@@ -7048,6 +7072,13 @@ rc@1.2.8, rc@^1.2.8:
     minimist "^1.2.0"
     strip-json-comments "~2.0.1"
 
+re-resizable@^6.5.2:
+  version "6.5.2"
+  resolved "https://registry.yarnpkg.com/re-resizable/-/re-resizable-6.5.2.tgz#7eb1928c673285d4dcf654211e47acb9a3801c3e"
+  integrity sha512-Pjo3ydkr/meTr6j3YZqyv+9fRS5UNOj5SaAI06gHFQ35BnpsZKmwNvupCnbo11gjQ1I62Uy+UzlHLO9xPQEuWQ==
+  dependencies:
+    fast-memoize "^2.5.1"
+
 react-beautiful-dnd@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-4.0.1.tgz#3b0a49bf6be75af351176c904f012611dd292b81"
@@ -7081,14 +7112,6 @@ react-dom@^16.9.0:
     prop-types "^15.6.2"
     scheduler "^0.19.1"
 
-react-draggable@^4.0.3:
-  version "4.4.2"
-  resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.4.2.tgz#f3cefecee25f467f865144cda0d066e5f05f94a0"
-  integrity sha512-zLQs4R4bnBCGnCVTZiD8hPsHtkiJxgMpGDlRESM+EHQo8ysXhKJ2GKdJ8UxxLJdRVceX1j19jy+hQS2wHislPQ==
-  dependencies:
-    classnames "^2.2.5"
-    prop-types "^15.6.0"
-
 react-focus-lock@^2.2.1:
   version "2.3.1"
   resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.3.1.tgz#9d5d85899773609c7eefa4fc54fff6a0f5f2fc47"
@@ -7133,14 +7156,6 @@ react-redux@^5.0.6:
     react-is "^16.6.0"
     react-lifecycles-compat "^3.0.0"
 
-react-resizable@^1.10.1:
-  version "1.10.1"
-  resolved "https://registry.yarnpkg.com/react-resizable/-/react-resizable-1.10.1.tgz#f0c2cf1d83b3470b87676ce6d6b02bbe3f4d8cd4"
-  integrity sha512-Jd/bKOKx6+19NwC4/aMLRu/J9/krfxlDnElP41Oc+oLiUWs/zwV1S9yBfBZRnqAwQb6vQ/HRSk3bsSWGSgVbpw==
-  dependencies:
-    prop-types "15.x"
-    react-draggable "^4.0.3"
-
 react-test-renderer@^16.0.0-0, react-test-renderer@^16.9.0:
   version "16.13.1"
   resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.13.1.tgz#de25ea358d9012606de51e012d9742e7f0deabc1"