diff --git a/.github/workflows/layered-build.yaml b/.github/workflows/layered-build.yaml
new file mode 100644
index 0000000000..7235b4020f
--- /dev/null
+++ b/.github/workflows/layered-build.yaml
@@ -0,0 +1,19 @@
+name: Layered Preview Build
+on:
+    pull_request:
+        branches: [develop]
+jobs:
+    build:
+        runs-on: ubuntu-latest
+        steps:
+            - uses: actions/checkout@v2
+            - name: Build
+              run: scripts/ci/layered.sh && cd element-web && cp element.io/develop/config.json config.json && CI_PACKAGE=true yarn build
+            - name: Upload Artifact
+              uses: actions/upload-artifact@v2
+              with:
+                  name: previewbuild
+                  path: element-web/webapp
+                  # We'll only use this in a triggered job, then we're done with it
+                  retention-days: 1
+
diff --git a/.github/workflows/netflify.yaml b/.github/workflows/netflify.yaml
new file mode 100644
index 0000000000..9d65dd5926
--- /dev/null
+++ b/.github/workflows/netflify.yaml
@@ -0,0 +1,50 @@
+name: Upload Preview Build to Netlify
+on:
+    workflow_run:
+        workflows: ["Layered Preview Build"]
+        types:
+            - completed
+jobs:
+    build:
+        runs-on: ubuntu-latest
+        if: >
+            ${{ github.event.workflow_run.conclusion == 'success' }}
+        steps:
+            # There's a 'download artifact' action but it hasn't been updated for the
+            # workflow_run action (https://github.com/actions/download-artifact/issues/60)
+            # so instead we get this mess:
+            - name: 'Download artifact'
+              uses: actions/github-script@v3.1.0
+              with:
+                script: |
+                  var artifacts = await github.actions.listWorkflowRunArtifacts({
+                     owner: context.repo.owner,
+                     repo: context.repo.repo,
+                     run_id: ${{github.event.workflow_run.id }},
+                  });
+                  var matchArtifact = artifacts.data.artifacts.filter((artifact) => {
+                    return artifact.name == "previewbuild"
+                  })[0];
+                  var download = await github.actions.downloadArtifact({
+                     owner: context.repo.owner,
+                     repo: context.repo.repo,
+                     artifact_id: matchArtifact.id,
+                     archive_format: 'zip',
+                  });
+                  var fs = require('fs');
+                  fs.writeFileSync('${{github.workspace}}/previewbuild.zip', Buffer.from(download.data));
+            - run: unzip previewbuild.zip && rm previewbuild.zip
+            - name: Deploy to Netlify
+              uses: nwtgck/actions-netlify@v1.2
+              with:
+                  publish-dir: .
+                  github-token: ${{ secrets.GITHUB_TOKEN }}
+                  deploy-message: "Deploy from GitHub Actions"
+                  enable-pull-request-comment: true
+                  enable-commit-comment: false
+                  overwrites-pull-request-comment: true
+              env:
+                  NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
+                  NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
+              timeout-minutes: 1
+
diff --git a/.github/workflows/preview_changelog.yaml b/.github/workflows/preview_changelog.yaml
new file mode 100644
index 0000000000..d68d19361d
--- /dev/null
+++ b/.github/workflows/preview_changelog.yaml
@@ -0,0 +1,12 @@
+name: Preview Changelog
+on:
+  pull_request_target:
+    types: [ opened, edited, labeled ]
+jobs:
+    changelog:
+        runs-on: ubuntu-latest
+        steps:
+            - name: Preview Changelog
+              uses: matrix-org/allchange@main
+              with:
+                  ghToken: ${{ secrets.GITHUB_TOKEN }}
diff --git a/res/css/_components.scss b/res/css/_components.scss
index 652b317655..cb7ec5ba0a 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -241,6 +241,7 @@
 @import "./views/settings/_E2eAdvancedPanel.scss";
 @import "./views/settings/_EmailAddresses.scss";
 @import "./views/settings/_IntegrationManager.scss";
+@import "./views/settings/_LayoutSwitcher.scss";
 @import "./views/settings/_Notifications.scss";
 @import "./views/settings/_PhoneNumbers.scss";
 @import "./views/settings/_ProfileSettings.scss";
@@ -274,6 +275,7 @@
 @import "./views/voip/_CallPreview.scss";
 @import "./views/voip/_CallView.scss";
 @import "./views/voip/_CallViewForRoom.scss";
+@import "./views/voip/_CallViewHeader.scss";
 @import "./views/voip/_CallViewSidebar.scss";
 @import "./views/voip/_DialPad.scss";
 @import "./views/voip/_DialPadContextMenu.scss";
diff --git a/res/css/views/elements/_DesktopCapturerSourcePicker.scss b/res/css/views/elements/_DesktopCapturerSourcePicker.scss
index bd81aafef3..b4a2c69b86 100644
--- a/res/css/views/elements/_DesktopCapturerSourcePicker.scss
+++ b/res/css/views/elements/_DesktopCapturerSourcePicker.scss
@@ -24,33 +24,33 @@ limitations under the License.
         align-items: flex-start;
         height: 500px;
         overflow: overlay;
-    }
 
-    .mx_desktopCapturerSourcePicker_source {
-        display: flex;
-        flex-direction: column;
-        margin: 8px;
-    }
+        .mx_desktopCapturerSourcePicker_source {
+            width: 50%;
+            display: flex;
+            flex-direction: column;
 
-    .mx_desktopCapturerSourcePicker_source_thumbnail {
-        margin: 4px;
-        padding: 4px;
-        border-width: 2px;
-        border-radius: 8px;
-        border-style: solid;
-        border-color: transparent;
+            .mx_desktopCapturerSourcePicker_source_thumbnail {
+                margin: 4px;
+                padding: 4px;
+                border-width: 2px;
+                border-radius: 8px;
+                border-style: solid;
+                border-color: transparent;
 
-        &.mx_desktopCapturerSourcePicker_source_thumbnail_selected,
-        &:hover,
-        &:focus {
-            border-color: $accent-color;
+                &.mx_desktopCapturerSourcePicker_source_thumbnail_selected,
+                &:hover,
+                &:focus {
+                    border-color: $accent-color;
+                }
+            }
+
+            .mx_desktopCapturerSourcePicker_source_name {
+                margin: 0 4px;
+                white-space: nowrap;
+                text-overflow: ellipsis;
+                overflow: hidden;
+            }
         }
     }
-
-    .mx_desktopCapturerSourcePicker_source_name {
-        margin: 0 4px;
-        white-space: nowrap;
-        text-overflow: ellipsis;
-        overflow: hidden;
-    }
 }
diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss
index 5d7e733213..8196d5c67a 100644
--- a/res/css/views/rooms/_VoiceRecordComposerTile.scss
+++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss
@@ -20,7 +20,7 @@ limitations under the License.
     height: 28px;
     border: 2px solid $voice-record-stop-border-color;
     border-radius: 32px;
-    margin-right: 16px; // between us and the send button
+    margin-right: 8px; // between us and the waveform component
     position: relative;
 
     &::after {
@@ -64,6 +64,10 @@ limitations under the License.
 .mx_MessageComposer_row .mx_VoiceMessagePrimaryContainer {
     // Note: remaining class properties are in the PlayerContainer CSS.
 
+    // fixed height to reduce layout jumps with the play button appearing
+    // https://github.com/vector-im/element-web/issues/18431
+    height: 32px;
+
     margin: 6px; // force the composer area to put a gutter around us
     margin-right: 12px; // isolate from stop/send button
 
@@ -83,7 +87,7 @@ limitations under the License.
             height: 10px;
             position: absolute;
             left: 12px; // 12px from the left edge for container padding
-            top: 16px; // vertically center (middle align with clock)
+            top: 17px; // vertically center (middle align with clock)
             border-radius: 10px;
         }
     }
diff --git a/res/css/views/settings/_LayoutSwitcher.scss b/res/css/views/settings/_LayoutSwitcher.scss
new file mode 100644
index 0000000000..924fe5ae1b
--- /dev/null
+++ b/res/css/views/settings/_LayoutSwitcher.scss
@@ -0,0 +1,91 @@
+/*
+Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
+Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
+
+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_LayoutSwitcher {
+    .mx_LayoutSwitcher_RadioButtons {
+        display: flex;
+        flex-direction: row;
+        gap: 24px;
+
+        color: $primary-fg-color;
+
+        > .mx_LayoutSwitcher_RadioButton {
+            flex-grow: 0;
+            flex-shrink: 1;
+            display: flex;
+            flex-direction: column;
+
+            width: 300px;
+
+            border: 1px solid $appearance-tab-border-color;
+            border-radius: 10px;
+
+            .mx_EventTile_msgOption,
+            .mx_MessageActionBar {
+                display: none;
+            }
+
+            .mx_LayoutSwitcher_RadioButton_preview {
+                flex-grow: 1;
+                display: flex;
+                align-items: center;
+                padding: 10px;
+                pointer-events: none;
+            }
+
+            .mx_RadioButton {
+                flex-grow: 0;
+                padding: 10px;
+            }
+
+            .mx_EventTile_content {
+                margin-right: 0;
+            }
+
+            &.mx_LayoutSwitcher_RadioButton_selected {
+                border-color: $accent-color;
+            }
+        }
+
+        .mx_RadioButton {
+            border-top: 1px solid $appearance-tab-border-color;
+
+            > input + div {
+                border-color: rgba($muted-fg-color, 0.2);
+            }
+        }
+
+        .mx_RadioButton_checked {
+            background-color: rgba($accent-color, 0.08);
+        }
+
+        .mx_EventTile {
+            margin: 0;
+            &[data-layout=bubble] {
+                margin-right: 40px;
+            }
+            &[data-layout=irc] {
+                > a {
+                    display: none;
+                }
+            }
+            .mx_EventTile_line {
+                max-width: 90%;
+            }
+        }
+    }
+}
diff --git a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss
index ca5a6f0a66..d8e617a40d 100644
--- a/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss
+++ b/res/css/views/settings/tabs/user/_AppearanceUserSettingsTab.scss
@@ -1,5 +1,5 @@
 /*
-Copyright 2020 The Matrix.org Foundation C.I.C.
+Copyright 2020 - 2021 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -155,79 +155,6 @@ limitations under the License.
     margin-left: calc($font-16px + 10px);
 }
 
-.mx_AppearanceUserSettingsTab_Layout_RadioButtons {
-    display: flex;
-    flex-direction: row;
-    gap: 24px;
-
-    color: $primary-fg-color;
-
-    > .mx_AppearanceUserSettingsTab_Layout_RadioButton {
-        flex-grow: 0;
-        flex-shrink: 1;
-        display: flex;
-        flex-direction: column;
-
-        width: 300px;
-
-        border: 1px solid $appearance-tab-border-color;
-        border-radius: 10px;
-
-        .mx_EventTile_msgOption,
-        .mx_MessageActionBar {
-            display: none;
-        }
-
-        .mx_AppearanceUserSettingsTab_Layout_RadioButton_preview {
-            flex-grow: 1;
-            display: flex;
-            align-items: center;
-            padding: 10px;
-            pointer-events: none;
-        }
-
-        .mx_RadioButton {
-            flex-grow: 0;
-            padding: 10px;
-        }
-
-        .mx_EventTile_content {
-            margin-right: 0;
-        }
-
-        &.mx_AppearanceUserSettingsTab_Layout_RadioButton_selected {
-            border-color: $accent-color;
-        }
-    }
-
-    .mx_RadioButton {
-        border-top: 1px solid $appearance-tab-border-color;
-
-        > input + div {
-            border-color: rgba($muted-fg-color, 0.2);
-        }
-    }
-
-    .mx_RadioButton_checked {
-        background-color: rgba($accent-color, 0.08);
-    }
-
-    .mx_EventTile {
-        margin: 0;
-        &[data-layout=bubble] {
-            margin-right: 40px;
-        }
-        &[data-layout=irc] {
-            > a {
-                display: none;
-            }
-        }
-        .mx_EventTile_line {
-            max-width: 90%;
-        }
-    }
-}
-
 .mx_AppearanceUserSettingsTab_Advanced {
     color: $primary-fg-color;
 
diff --git a/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss b/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss
index 0f879d209e..fbbe9909e7 100644
--- a/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss
+++ b/res/css/views/settings/tabs/user/_HelpUserSettingsTab.scss
@@ -28,28 +28,32 @@ limitations under the License.
     user-select: all;
 }
 
-.mx_HelpUserSettingsTab_accessToken {
+.mx_HelpUserSettingsTab_copy {
     display: flex;
-    justify-content: space-between;
     border-radius: 5px;
     border: solid 1px $light-fg-color;
     margin-bottom: 10px;
     margin-top: 10px;
     padding: 10px;
-}
+    width: max-content;
 
-.mx_HelpUserSettingsTab_accessToken_copy {
-    flex-shrink: 0;
-    cursor: pointer;
-    margin-left: 20px;
-    display: inherit;
-}
+    .mx_HelpUserSettingsTab_copyButton {
+        flex-shrink: 0;
+        width: 20px;
+        height: 20px;
+        cursor: pointer;
+        margin-left: 20px;
+        display: block;
 
-.mx_HelpUserSettingsTab_accessToken_copy > div {
-    mask-image: url($copy-button-url);
-    background-color: $message-action-bar-fg-color;
-    margin-left: 5px;
-    width: 20px;
-    height: 20px;
-    background-repeat: no-repeat;
+        &::before {
+            content: "";
+
+            mask-image: url($copy-button-url);
+            background-color: $message-action-bar-fg-color;
+            width: 20px;
+            height: 20px;
+            display: block;
+            background-repeat: no-repeat;
+        }
+    }
 }
diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss
index 8d8b68efd0..7752edddfa 100644
--- a/res/css/views/voip/_CallView.scss
+++ b/res/css/views/voip/_CallView.scss
@@ -199,120 +199,6 @@ limitations under the License.
     }
 }
 
-.mx_CallView_header {
-    height: 44px;
-    display: flex;
-    flex-direction: row;
-    align-items: center;
-    justify-content: left;
-    flex-shrink: 0;
-    cursor: pointer;
-}
-
-.mx_CallView_header_callType {
-    font-size: 1.2rem;
-    font-weight: bold;
-    vertical-align: middle;
-}
-
-.mx_CallView_header_secondaryCallInfo {
-    &::before {
-        content: '·';
-        margin-left: 6px;
-        margin-right: 6px;
-    }
-}
-
-.mx_CallView_header_controls {
-    margin-left: auto;
-}
-
-.mx_CallView_header_button {
-    display: inline-block;
-    vertical-align: middle;
-    cursor: pointer;
-
-    &::before {
-        content: '';
-        display: inline-block;
-        height: 20px;
-        width: 20px;
-        vertical-align: middle;
-        background-color: $secondary-fg-color;
-        mask-repeat: no-repeat;
-        mask-size: contain;
-        mask-position: center;
-    }
-}
-
-.mx_CallView_header_button_fullscreen {
-    &::before {
-        mask-image: url('$(res)/img/element-icons/call/fullscreen.svg');
-    }
-}
-
-.mx_CallView_header_button_expand {
-    &::before {
-        mask-image: url('$(res)/img/element-icons/call/expand.svg');
-    }
-}
-
-.mx_CallView_header_callInfo {
-    margin-left: 12px;
-    margin-right: 16px;
-}
-
-.mx_CallView_header_roomName {
-    font-weight: bold;
-    font-size: 12px;
-    line-height: initial;
-    height: 15px;
-}
-
-.mx_CallView_secondaryCall_roomName {
-    margin-left: 4px;
-}
-
-.mx_CallView_header_callTypeSmall {
-    font-size: 12px;
-    color: $secondary-fg-color;
-    line-height: initial;
-    height: 15px;
-    overflow: hidden;
-    white-space: nowrap;
-    text-overflow: ellipsis;
-    max-width: 240px;
-}
-
-.mx_CallView_header_callTypeIcon {
-    display: inline-block;
-    margin-right: 6px;
-    height: 16px;
-    width: 16px;
-    vertical-align: middle;
-
-    &::before {
-        content: '';
-        display: inline-block;
-        vertical-align: top;
-
-        height: 16px;
-        width: 16px;
-        background-color: $secondary-fg-color;
-        mask-repeat: no-repeat;
-        mask-size: contain;
-        mask-position: center;
-    }
-
-    &.mx_CallView_header_callTypeIcon_voice::before {
-        mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
-    }
-
-    &.mx_CallView_header_callTypeIcon_video::before {
-        mask-image: url('$(res)/img/element-icons/call/video-call.svg');
-    }
-}
-
 .mx_CallView_callControls {
     position: absolute;
     display: flex;
diff --git a/res/css/views/voip/_CallViewHeader.scss b/res/css/views/voip/_CallViewHeader.scss
new file mode 100644
index 0000000000..014cfce478
--- /dev/null
+++ b/res/css/views/voip/_CallViewHeader.scss
@@ -0,0 +1,129 @@
+/*
+Copyright 2021 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_CallViewHeader {
+    height: 44px;
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: left;
+    flex-shrink: 0;
+    cursor: pointer;
+}
+
+.mx_CallViewHeader_callType {
+    font-size: 1.2rem;
+    font-weight: bold;
+    vertical-align: middle;
+}
+
+.mx_CallViewHeader_secondaryCallInfo {
+    &::before {
+        content: '·';
+        margin-left: 6px;
+        margin-right: 6px;
+    }
+}
+
+.mx_CallViewHeader_controls {
+    margin-left: auto;
+}
+
+.mx_CallViewHeader_button {
+    display: inline-block;
+    vertical-align: middle;
+    cursor: pointer;
+
+    &::before {
+        content: '';
+        display: inline-block;
+        height: 20px;
+        width: 20px;
+        vertical-align: middle;
+        background-color: $secondary-fg-color;
+        mask-repeat: no-repeat;
+        mask-size: contain;
+        mask-position: center;
+    }
+}
+
+.mx_CallViewHeader_button_fullscreen {
+    &::before {
+        mask-image: url('$(res)/img/element-icons/call/fullscreen.svg');
+    }
+}
+
+.mx_CallViewHeader_button_expand {
+    &::before {
+        mask-image: url('$(res)/img/element-icons/call/expand.svg');
+    }
+}
+
+.mx_CallViewHeader_callInfo {
+    margin-left: 12px;
+    margin-right: 16px;
+}
+
+.mx_CallViewHeader_roomName {
+    font-weight: bold;
+    font-size: 12px;
+    line-height: initial;
+    height: 15px;
+}
+
+.mx_CallView_secondaryCall_roomName {
+    margin-left: 4px;
+}
+
+.mx_CallViewHeader_callTypeSmall {
+    font-size: 12px;
+    color: $secondary-fg-color;
+    line-height: initial;
+    height: 15px;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    max-width: 240px;
+}
+
+.mx_CallViewHeader_callTypeIcon {
+    display: inline-block;
+    margin-right: 6px;
+    height: 16px;
+    width: 16px;
+    vertical-align: middle;
+
+    &::before {
+        content: '';
+        display: inline-block;
+        vertical-align: top;
+
+        height: 16px;
+        width: 16px;
+        background-color: $secondary-fg-color;
+        mask-repeat: no-repeat;
+        mask-size: contain;
+        mask-position: center;
+    }
+
+    &.mx_CallViewHeader_callTypeIcon_voice::before {
+        mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
+    }
+
+    &.mx_CallViewHeader_callTypeIcon_video::before {
+        mask-image: url('$(res)/img/element-icons/call/video-call.svg');
+    }
+}
diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss
index 064b532bb0..b9429318ac 100644
--- a/res/themes/legacy-dark/css/_legacy-dark.scss
+++ b/res/themes/legacy-dark/css/_legacy-dark.scss
@@ -222,6 +222,13 @@ $appearance-tab-border-color: $room-highlight-color;
 
 $composer-shadow-color: tranparent;
 
+// Bubble tiles
+$eventbubble-self-bg: #14322E;
+$eventbubble-others-bg: $event-selected-color;
+$eventbubble-bg-hover: #1C2026;
+$eventbubble-avatar-outline: $bg-color;
+$eventbubble-reply-color: #C1C6CD;
+
 // ***** Mixins! *****
 
 @define-mixin mx_DialogButton {
diff --git a/res/themes/light-custom/css/_custom.scss b/res/themes/light-custom/css/_custom.scss
index 1b9254d100..6c37351414 100644
--- a/res/themes/light-custom/css/_custom.scss
+++ b/res/themes/light-custom/css/_custom.scss
@@ -140,3 +140,10 @@ $event-highlight-bg-color: var(--timeline-highlights-color);
 //
 // redirect some variables away from their hardcoded values in the light theme
 $settings-grey-fg-color: $primary-fg-color;
+
+// --eventbubble colors
+$eventbubble-self-bg: var(--eventbubble-self-bg, $eventbubble-self-bg);
+$eventbubble-others-bg: var(--eventbubble-others-bg, $eventbubble-others-bg);
+$eventbubble-bg-hover: var(--eventbubble-bg-hover, $eventbubble-bg-hover);
+$eventbubble-avatar-outline: var(--eventbubble-avatar-outline, $eventbubble-avatar-outline);
+$eventbubble-reply-color: var(--eventbubble-reply-color, $eventbubble-reply-color);
diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx
index 77569711df..41571666c3 100644
--- a/src/CallHandler.tsx
+++ b/src/CallHandler.tsx
@@ -509,13 +509,17 @@ export default class CallHandler extends EventEmitter {
                     this.removeCallForRoom(mappedRoomId);
                     if (oldState === CallState.InviteSent && call.hangupParty === CallParty.Remote) {
                         this.play(AudioID.Busy);
+
+                        // Don't show a modal when we got rejected/the call was hung up
+                        if (!hangupReason || [CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) break;
+
                         let title;
                         let description;
                         // TODO: We should either do away with these or figure out a copy for each code (expect user_hangup...)
                         if (call.hangupReason === CallErrorCode.UserBusy) {
                             title = _t("User Busy");
                             description = _t("The user you called is busy.");
-                        } else if (hangupReason && ![CallErrorCode.UserHangup, "user hangup"].includes(hangupReason)) {
+                        } else {
                             title = _t("Call Failed");
                             description = _t("The call could not be established");
                         }
diff --git a/src/DateUtils.ts b/src/DateUtils.ts
index e4a1175d88..e8b81ca315 100644
--- a/src/DateUtils.ts
+++ b/src/DateUtils.ts
@@ -123,6 +123,19 @@ export function formatTime(date: Date, showTwelveHour = false): string {
     return pad(date.getHours()) + ':' + pad(date.getMinutes());
 }
 
+export function formatCallTime(delta: Date): string {
+    const hours = delta.getUTCHours();
+    const minutes = delta.getUTCMinutes();
+    const seconds = delta.getUTCSeconds();
+
+    let output = "";
+    if (hours) output += `${hours}h `;
+    if (minutes || output) output += `${minutes}m `;
+    if (seconds || output) output += `${seconds}s`;
+
+    return output;
+}
+
 const MILLIS_IN_DAY = 86400000;
 export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean {
     if (!nextEventDate || !prevEventDate) {
diff --git a/src/components/structures/CallEventGrouper.ts b/src/components/structures/CallEventGrouper.ts
index ce3b530858..b48bb32efe 100644
--- a/src/components/structures/CallEventGrouper.ts
+++ b/src/components/structures/CallEventGrouper.ts
@@ -27,9 +27,15 @@ export enum CallEventGrouperEvent {
     SilencedChanged = "silenced_changed",
 }
 
+const CONNECTING_STATES = [
+    CallState.Connecting,
+    CallState.WaitLocalMedia,
+    CallState.CreateOffer,
+    CallState.CreateAnswer,
+];
+
 const SUPPORTED_STATES = [
     CallState.Connected,
-    CallState.Connecting,
     CallState.Ringing,
 ];
 
@@ -61,6 +67,10 @@ export default class CallEventGrouper extends EventEmitter {
         return [...this.events].find((event) => event.getType() === EventType.CallReject);
     }
 
+    private get selectAnswer(): MatrixEvent {
+        return [...this.events].find((event) => event.getType() === EventType.CallSelectAnswer);
+    }
+
     public get isVoice(): boolean {
         const invite = this.invite;
         if (!invite) return;
@@ -82,6 +92,11 @@ export default class CallEventGrouper extends EventEmitter {
         return Boolean(this.reject);
     }
 
+    public get duration(): Date {
+        if (!this.hangup || !this.selectAnswer) return;
+        return new Date(this.hangup.getDate().getTime() - this.selectAnswer.getDate().getTime());
+    }
+
     /**
      * Returns true if there are only events from the other side - we missed the call
      */
@@ -127,7 +142,9 @@ export default class CallEventGrouper extends EventEmitter {
     }
 
     private setState = () => {
-        if (SUPPORTED_STATES.includes(this.call?.state)) {
+        if (CONNECTING_STATES.includes(this.call?.state)) {
+            this.state = CallState.Connecting;
+        } else if (SUPPORTED_STATES.includes(this.call?.state)) {
             this.state = this.call.state;
         } else {
             if (this.callWasMissed) this.state = CustomCallState.Missed;
diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx
index 698a36127b..1691d90651 100644
--- a/src/components/structures/MessagePanel.tsx
+++ b/src/components/structures/MessagePanel.tsx
@@ -51,7 +51,12 @@ import EditorStateTransfer from "../../utils/EditorStateTransfer";
 
 const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
 const continuedTypes = [EventType.Sticker, EventType.RoomMessage];
-const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, EventType.RoomServerAcl];
+const groupedEvents = [
+    EventType.RoomMember,
+    EventType.RoomThirdPartyInvite,
+    EventType.RoomServerAcl,
+    EventType.RoomPinnedEvents,
+];
 
 // check if there is a previous event and it has the same sender as this event
 // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
@@ -1234,7 +1239,7 @@ class RedactionGrouper extends BaseGrouper {
 // Wrap consecutive member events in a ListSummary, ignore if redacted
 class MemberGrouper extends BaseGrouper {
     static canStartGroup = function(panel: MessagePanel, ev: MatrixEvent): boolean {
-        return panel.shouldShowEvent(ev) && membershipTypes.includes(ev.getType() as EventType);
+        return panel.shouldShowEvent(ev) && groupedEvents.includes(ev.getType() as EventType);
     };
 
     constructor(
@@ -1252,7 +1257,7 @@ class MemberGrouper extends BaseGrouper {
         if (this.panel.wantsDateSeparator(this.events[0], ev.getDate())) {
             return false;
         }
-        return membershipTypes.includes(ev.getType() as EventType);
+        return groupedEvents.includes(ev.getType() as EventType);
     }
 
     public add(ev: MatrixEvent, showHiddenEvents?: boolean): void {
diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx
index 0899b1c72a..f62676a4fc 100644
--- a/src/components/structures/TimelinePanel.tsx
+++ b/src/components/structures/TimelinePanel.tsx
@@ -757,16 +757,20 @@ class TimelinePanel extends React.Component<IProps, IState> {
             }
             this.lastRMSentEventId = this.state.readMarkerEventId;
 
+            const roomId = this.props.timelineSet.room.roomId;
+            const hiddenRR = SettingsStore.getValue("feature_hidden_read_receipts", roomId);
+
             debuglog('TimelinePanel: Sending Read Markers for ',
                 this.props.timelineSet.room.roomId,
                 'rm', this.state.readMarkerEventId,
                 lastReadEvent ? 'rr ' + lastReadEvent.getId() : '',
+                ' hidden:' + hiddenRR,
             );
             MatrixClientPeg.get().setRoomReadMarkers(
-                this.props.timelineSet.room.roomId,
+                roomId,
                 this.state.readMarkerEventId,
                 lastReadEvent, // Could be null, in which case no RR is sent
-                {},
+                { hidden: hiddenRR },
             ).catch((e) => {
                 // /read_markers API is not implemented on this HS, fallback to just RR
                 if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) {
diff --git a/src/components/views/elements/AppPermission.tsx b/src/components/views/elements/AppPermission.tsx
index 8dc874381a..c0543eb363 100644
--- a/src/components/views/elements/AppPermission.tsx
+++ b/src/components/views/elements/AppPermission.tsx
@@ -62,10 +62,10 @@ export default class AppPermission extends React.Component<IProps, IState> {
 
         // Set all this into the initial state
         this.state = {
-            ...urlInfo,
-            roomMember,
-            isWrapped: null,
             widgetDomain: null,
+            isWrapped: null,
+            roomMember,
+            ...urlInfo,
         };
     }
 
diff --git a/src/components/views/elements/EventListSummary.tsx b/src/components/views/elements/EventListSummary.tsx
index d66319ca73..cbb0e17b42 100644
--- a/src/components/views/elements/EventListSummary.tsx
+++ b/src/components/views/elements/EventListSummary.tsx
@@ -34,7 +34,7 @@ interface IProps {
     // The list of room members for which to show avatars next to the summary
     summaryMembers?: RoomMember[];
     // The text to show as the summary of this event list
-    summaryText?: string;
+    summaryText?: string | JSX.Element;
     // An array of EventTiles to render when expanded
     children: ReactNode[];
     // Called when the event list expansion is toggled
diff --git a/src/components/views/elements/MemberEventListSummary.tsx b/src/components/views/elements/MemberEventListSummary.tsx
index 604e6c63b0..0722cb872a 100644
--- a/src/components/views/elements/MemberEventListSummary.tsx
+++ b/src/components/views/elements/MemberEventListSummary.tsx
@@ -25,8 +25,24 @@ import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
 import { isValid3pidInvite } from "../../../RoomInvite";
 import EventListSummary from "./EventListSummary";
 import { replaceableComponent } from "../../../utils/replaceableComponent";
+import defaultDispatcher from '../../../dispatcher/dispatcher';
+import { RightPanelPhases } from '../../../stores/RightPanelStorePhases';
+import { Action } from '../../../dispatcher/actions';
+import { SetRightPanelPhasePayload } from '../../../dispatcher/payloads/SetRightPanelPhasePayload';
+import { jsxJoin } from '../../../utils/ReactUtils';
+import { EventType } from 'matrix-js-sdk/src/@types/event';
 import { Layout } from '../../../settings/Layout';
 
+const onPinnedMessagesClick = (): void => {
+    defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
+        action: Action.SetRightPanelPhase,
+        phase: RightPanelPhases.PinnedMessages,
+        allowClose: false,
+    });
+};
+
+const SENDER_AS_DISPLAY_NAME_EVENTS = [EventType.RoomServerAcl, EventType.RoomPinnedEvents];
+
 interface IProps extends Omit<ComponentProps<typeof EventListSummary>, "summaryText" | "summaryMembers"> {
     // The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
     summaryLength?: number;
@@ -60,6 +76,7 @@ enum TransitionType {
     ChangedAvatar = "changed_avatar",
     NoChange = "no_change",
     ServerAcl = "server_acl",
+    ChangedPins = "pinned_messages"
 }
 
 const SEP = ",";
@@ -93,7 +110,10 @@ export default class MemberEventListSummary extends React.Component<IProps> {
      * `Object.keys(eventAggregates)`.
      * @returns {string} the textual summary of the aggregated events that occurred.
      */
-    private generateSummary(eventAggregates: Record<string, string[]>, orderedTransitionSequences: string[]) {
+    private generateSummary(
+        eventAggregates: Record<string, string[]>,
+        orderedTransitionSequences: string[],
+    ): string | JSX.Element {
         const summaries = orderedTransitionSequences.map((transitions) => {
             const userNames = eventAggregates[transitions];
             const nameList = this.renderNameList(userNames);
@@ -122,7 +142,7 @@ export default class MemberEventListSummary extends React.Component<IProps> {
             return null;
         }
 
-        return summaries.join(", ");
+        return jsxJoin(summaries, ", ");
     }
 
     /**
@@ -216,7 +236,11 @@ export default class MemberEventListSummary extends React.Component<IProps> {
      * @param {number} repeats the number of times the transition was repeated in a row.
      * @returns {string} the written Human Readable equivalent of the transition.
      */
-    private static getDescriptionForTransition(t: TransitionType, userCount: number, repeats: number) {
+    private static getDescriptionForTransition(
+        t: TransitionType,
+        userCount: number,
+        repeats: number,
+    ): string | JSX.Element {
         // The empty interpolations 'severalUsers' and 'oneUser'
         // are there only to show translators to non-English languages
         // that the verb is conjugated to plural or singular Subject.
@@ -299,6 +323,15 @@ export default class MemberEventListSummary extends React.Component<IProps> {
                         { severalUsers: "", count: repeats })
                     : _t("%(oneUser)schanged the server ACLs %(count)s times", { oneUser: "", count: repeats });
                 break;
+            case "pinned_messages":
+                res = (userCount > 1)
+                    ? _t("%(severalUsers)schanged the <a>pinned messages</a> for the room %(count)s times.",
+                        { severalUsers: "", count: repeats },
+                        { "a": (sub) => <a onClick={onPinnedMessagesClick}> { sub } </a> })
+                    : _t("%(oneUser)schanged the <a>pinned messages</a> for the room %(count)s times.",
+                        { oneUser: "", count: repeats },
+                        { "a": (sub) => <a onClick={onPinnedMessagesClick}> { sub } </a> });
+                break;
         }
 
         return res;
@@ -317,16 +350,18 @@ export default class MemberEventListSummary extends React.Component<IProps> {
      * if a transition is not recognised.
      */
     private static getTransition(e: IUserEvents): TransitionType {
-        if (e.mxEvent.getType() === 'm.room.third_party_invite') {
+        const type = e.mxEvent.getType();
+
+        if (type === EventType.RoomThirdPartyInvite) {
             // Handle 3pid invites the same as invites so they get bundled together
             if (!isValid3pidInvite(e.mxEvent)) {
                 return TransitionType.InviteWithdrawal;
             }
             return TransitionType.Invited;
-        }
-
-        if (e.mxEvent.getType() === 'm.room.server_acl') {
+        } else if (type === EventType.RoomServerAcl) {
             return TransitionType.ServerAcl;
+        } else if (type === EventType.RoomPinnedEvents) {
+            return TransitionType.ChangedPins;
         }
 
         switch (e.mxEvent.getContent().membership) {
@@ -415,22 +450,23 @@ export default class MemberEventListSummary extends React.Component<IProps> {
         // Object mapping user IDs to an array of IUserEvents
         const userEvents: Record<string, IUserEvents[]> = {};
         eventsToRender.forEach((e, index) => {
-            const userId = e.getType() === 'm.room.server_acl' ? e.getSender() : e.getStateKey();
+            const type = e.getType();
+            const userId = type === EventType.RoomServerAcl ? e.getSender() : e.getStateKey();
             // Initialise a user's events
             if (!userEvents[userId]) {
                 userEvents[userId] = [];
             }
 
-            if (e.getType() === 'm.room.server_acl') {
+            if (SENDER_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) {
                 latestUserAvatarMember.set(userId, e.sender);
             } else if (e.target) {
                 latestUserAvatarMember.set(userId, e.target);
             }
 
             let displayName = userId;
-            if (e.getType() === 'm.room.third_party_invite') {
+            if (type === EventType.RoomThirdPartyInvite) {
                 displayName = e.getContent().display_name;
-            } else if (e.getType() === 'm.room.server_acl') {
+            } else if (SENDER_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) {
                 displayName = e.sender.name;
             } else if (e.target) {
                 displayName = e.target.name;
diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx
index a204907caa..bc868c35b3 100644
--- a/src/components/views/messages/CallEvent.tsx
+++ b/src/components/views/messages/CallEvent.tsx
@@ -25,6 +25,7 @@ import { CallErrorCode, CallState } from 'matrix-js-sdk/src/webrtc/call';
 import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip';
 import classNames from 'classnames';
 import AccessibleTooltipButton from '../elements/AccessibleTooltipButton';
+import { formatCallTime } from "../../../DateUtils";
 
 interface IProps {
     mxEvent: MatrixEvent;
@@ -131,9 +132,14 @@ export default class CallEvent extends React.Component<IProps, IState> {
                 // https://github.com/vector-im/riot-android/issues/2623
                 // Also the correct hangup code as of VoIP v1 (with underscore)
                 // Also, if we don't have a reason
+                const duration = this.props.callEventGrouper.duration;
+                let text = _t("Call ended");
+                if (duration) {
+                    text += " • " + formatCallTime(duration);
+                }
                 return (
                     <div className="mx_CallEvent_content">
-                        { _t("Call ended") }
+                        { text }
                     </div>
                 );
             } else if (hangupReason === CallErrorCode.InviteTimeout) {
diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx
index 969caccaee..83fe7f5a3d 100644
--- a/src/components/views/messages/TextualBody.tsx
+++ b/src/components/views/messages/TextualBody.tsx
@@ -136,7 +136,8 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
     private addCodeExpansionButton(div: HTMLDivElement, pre: HTMLPreElement): void {
         // Calculate how many percent does the pre element take up.
         // If it's less than 30% we don't add the expansion button.
-        const percentageOfViewport = pre.offsetHeight / UIStore.instance.windowHeight * 100;
+        // We also round the number as it sometimes can be 29.99...
+        const percentageOfViewport = Math.round(pre.offsetHeight / UIStore.instance.windowHeight * 100);
         if (percentageOfViewport < 30) return;
 
         const button = document.createElement("span");
diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx
index 55baf9cb73..db3d3eee5e 100644
--- a/src/components/views/rooms/BasicMessageComposer.tsx
+++ b/src/components/views/rooms/BasicMessageComposer.tsx
@@ -1,6 +1,5 @@
 /*
-Copyright 2019 New Vector Ltd
-Copyright 2019 The Matrix.org Foundation C.I.C.
+Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -725,7 +724,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
             <MessageComposerFormatBar ref={this.formatBarRef} onAction={this.onFormatAction} shortcuts={shortcuts} />
             <div
                 className={classes}
-                contentEditable="true"
+                contentEditable={this.props.disabled ? null : true}
                 tabIndex={0}
                 onBlur={this.onBlur}
                 onFocus={this.onFocus}
diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx
index 0bc3779f80..8455e9aa11 100644
--- a/src/components/views/rooms/MessageComposer.tsx
+++ b/src/components/views/rooms/MessageComposer.tsx
@@ -58,6 +58,7 @@ function ComposerAvatar(props: IComposerAvatarProps) {
 
 interface ISendButtonProps {
     onClick: () => void;
+    title?: string; // defaults to something generic
 }
 
 function SendButton(props: ISendButtonProps) {
@@ -65,7 +66,7 @@ function SendButton(props: ISendButtonProps) {
         <AccessibleTooltipButton
             className="mx_MessageComposer_sendMessage"
             onClick={props.onClick}
-            title={_t('Send message')}
+            title={props.title ?? _t('Send message')}
         />
     );
 }
@@ -401,7 +402,11 @@ export default class MessageComposer extends React.Component<IProps, IState> {
 
             if (!this.state.isComposerEmpty || this.state.haveRecording) {
                 controls.push(
-                    <SendButton key="controls_send" onClick={this.sendMessage} />,
+                    <SendButton
+                        key="controls_send"
+                        onClick={this.sendMessage}
+                        title={this.state.haveRecording ? _t("Send voice message") : undefined}
+                    />,
                 );
             }
         } else if (this.state.tombstone) {
diff --git a/src/components/views/rooms/ReplyTile.tsx b/src/components/views/rooms/ReplyTile.tsx
index 16a7141bd7..8c0e09c76c 100644
--- a/src/components/views/rooms/ReplyTile.tsx
+++ b/src/components/views/rooms/ReplyTile.tsx
@@ -14,7 +14,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 classNames from 'classnames';
 import { _t } from '../../../languageHandler';
 import dis from '../../../dispatcher/dispatcher';
@@ -38,6 +38,8 @@ interface IProps {
 
 @replaceableComponent("views.rooms.ReplyTile")
 export default class ReplyTile extends React.PureComponent<IProps> {
+    private anchorElement = createRef<HTMLAnchorElement>();
+
     static defaultProps = {
         onHeightChanged: () => {},
     };
@@ -71,7 +73,11 @@ export default class ReplyTile extends React.PureComponent<IProps> {
         // Following a link within a reply should not dispatch the `view_room` action
         // so that the browser can direct the user to the correct location
         // The exception being the link wrapping the reply
-        if (clickTarget.tagName.toLowerCase() !== "a" || clickTarget.closest("a") === null) {
+        if (
+            clickTarget.tagName.toLowerCase() !== "a" ||
+            clickTarget.closest("a") === null ||
+            clickTarget === this.anchorElement.current
+        ) {
             // This allows the permalink to be opened in a new tab/window or copied as
             // matrix.to, but also for it to enable routing within Riot when clicked.
             e.preventDefault();
@@ -141,7 +147,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
 
         return (
             <div className={classes}>
-                <a href={permalink} onClick={this.onClick}>
+                <a href={permalink} onClick={this.onClick} ref={this.anchorElement}>
                     { sender }
                     <EventTileType
                         ref="tile"
diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx
index 1b583444a3..e8befb90fa 100644
--- a/src/components/views/rooms/VoiceRecordComposerTile.tsx
+++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx
@@ -215,7 +215,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
     }
 
     public render(): ReactNode {
-        let recordingInfo;
+        let stopOrRecordBtn;
         let deleteButton;
         if (!this.state.recordingPhase || this.state.recordingPhase === RecordingState.Started) {
             const classes = classNames({
@@ -229,7 +229,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
                 tooltip = _t("Stop recording");
             }
 
-            let stopOrRecordBtn = <AccessibleTooltipButton
+            stopOrRecordBtn = <AccessibleTooltipButton
                 className={classes}
                 onClick={this.onRecordStartEndClick}
                 title={tooltip}
@@ -237,8 +237,6 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
             if (this.state.recorder && !this.state.recorder?.isRecording) {
                 stopOrRecordBtn = null;
             }
-
-            recordingInfo = stopOrRecordBtn;
         }
 
         if (this.state.recorder && this.state.recordingPhase !== RecordingState.Uploading) {
@@ -266,11 +264,14 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
             </span>;
         }
 
+        // The record button (mic icon) is meant to be on the right edge, but we also want the
+        // stop button to be left of the waveform area. Luckily, none of the surrounding UI is
+        // rendered when we're not recording, so the record button ends up in the correct spot.
         return (<>
             { uploadIndicator }
             { deleteButton }
+            { stopOrRecordBtn }
             { this.renderWaveformArea() }
-            { recordingInfo }
         </>);
     }
 }
diff --git a/src/components/views/settings/LayoutSwitcher.tsx b/src/components/views/settings/LayoutSwitcher.tsx
new file mode 100644
index 0000000000..dd7accf9a8
--- /dev/null
+++ b/src/components/views/settings/LayoutSwitcher.tsx
@@ -0,0 +1,133 @@
+/*
+Copyright 2019 New Vector Ltd
+Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
+Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+import classNames from "classnames";
+import SettingsStore from "../../../settings/SettingsStore";
+import EventTilePreview from "../elements/EventTilePreview";
+import StyledRadioButton from "../elements/StyledRadioButton";
+import { _t } from "../../../languageHandler";
+import { Layout } from "../../../settings/Layout";
+import { SettingLevel } from "../../../settings/SettingLevel";
+
+interface IProps {
+    userId: string;
+    displayName: string;
+    avatarUrl: string;
+    messagePreviewText: string;
+    onLayoutChanged?: (layout: Layout) => void;
+}
+
+interface IState {
+    layout: Layout;
+}
+
+export default class LayoutSwitcher extends React.Component<IProps, IState> {
+    constructor(props: IProps) {
+        super(props);
+
+        this.state = {
+            layout: SettingsStore.getValue("layout"),
+        };
+    }
+
+    private onLayoutChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
+        const layout = e.target.value as Layout;
+
+        this.setState({ layout: layout });
+        SettingsStore.setValue("layout", null, SettingLevel.DEVICE, layout);
+        this.props.onLayoutChanged(layout);
+    };
+
+    public render(): JSX.Element {
+        const ircClasses = classNames("mx_LayoutSwitcher_RadioButton", {
+            mx_LayoutSwitcher_RadioButton_selected: this.state.layout == Layout.IRC,
+        });
+        const groupClasses = classNames("mx_LayoutSwitcher_RadioButton", {
+            mx_LayoutSwitcher_RadioButton_selected: this.state.layout == Layout.Group,
+        });
+        const bubbleClasses = classNames("mx_LayoutSwitcher_RadioButton", {
+            mx_LayoutSwitcher_RadioButton_selected: this.state.layout === Layout.Bubble,
+        });
+
+        return (
+            <div className="mx_SettingsTab_section mx_LayoutSwitcher">
+                <span className="mx_SettingsTab_subheading">
+                    { _t("Message layout") }
+                </span>
+
+                <div className="mx_LayoutSwitcher_RadioButtons">
+                    <label className={ircClasses}>
+                        <EventTilePreview
+                            className="mx_LayoutSwitcher_RadioButton_preview"
+                            message={this.props.messagePreviewText}
+                            layout={Layout.IRC}
+                            userId={this.props.userId}
+                            displayName={this.props.displayName}
+                            avatarUrl={this.props.avatarUrl}
+                        />
+                        <StyledRadioButton
+                            name="layout"
+                            value={Layout.IRC}
+                            checked={this.state.layout === Layout.IRC}
+                            onChange={this.onLayoutChange}
+                        >
+                            { _t("IRC") }
+                        </StyledRadioButton>
+                    </label>
+                    <label className={groupClasses}>
+                        <EventTilePreview
+                            className="mx_LayoutSwitcher_RadioButton_preview"
+                            message={this.props.messagePreviewText}
+                            layout={Layout.Group}
+                            userId={this.props.userId}
+                            displayName={this.props.displayName}
+                            avatarUrl={this.props.avatarUrl}
+                        />
+                        <StyledRadioButton
+                            name="layout"
+                            value={Layout.Group}
+                            checked={this.state.layout == Layout.Group}
+                            onChange={this.onLayoutChange}
+                        >
+                            { _t("Modern") }
+                        </StyledRadioButton>
+                    </label>
+                    <label className={bubbleClasses}>
+                        <EventTilePreview
+                            className="mx_LayoutSwitcher_RadioButton_preview"
+                            message={this.props.messagePreviewText}
+                            layout={Layout.Bubble}
+                            userId={this.props.userId}
+                            displayName={this.props.displayName}
+                            avatarUrl={this.props.avatarUrl}
+                        />
+                        <StyledRadioButton
+                            name="layout"
+                            value={Layout.Bubble}
+                            checked={this.state.layout == Layout.Bubble}
+                            onChange={this.onLayoutChange}
+                        >
+                            { _t("Message bubbles") }
+                        </StyledRadioButton>
+                    </label>
+                </div>
+            </div>
+        );
+    }
+}
diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx
index 44873816dc..cbf0b7916c 100644
--- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx
+++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx
@@ -1,6 +1,6 @@
 /*
 Copyright 2019 New Vector Ltd
-Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -37,10 +37,9 @@ import StyledRadioGroup from "../../../elements/StyledRadioGroup";
 import { SettingLevel } from "../../../../../settings/SettingLevel";
 import { UIFeature } from "../../../../../settings/UIFeature";
 import { Layout } from "../../../../../settings/Layout";
-import classNames from 'classnames';
-import StyledRadioButton from '../../../elements/StyledRadioButton';
 import { replaceableComponent } from "../../../../../utils/replaceableComponent";
 import { compare } from "../../../../../utils/strings";
+import LayoutSwitcher from "../../LayoutSwitcher";
 
 interface IProps {
 }
@@ -243,17 +242,8 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
         this.setState({ customThemeUrl: e.target.value });
     };
 
-    private onLayoutChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
-        let layout;
-        switch (e.target.value) {
-            case "irc": layout = Layout.IRC; break;
-            case "group": layout = Layout.Group; break;
-            case "bubble": layout = Layout.Bubble; break;
-        }
-
+    private onLayoutChanged = (layout: Layout): void => {
         this.setState({ layout: layout });
-
-        SettingsStore.setValue("layout", null, SettingLevel.DEVICE, layout);
     };
 
     private onIRCLayoutChange = (enabled: boolean) => {
@@ -391,75 +381,6 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
         </div>;
     }
 
-    private renderLayoutSection = () => {
-        return <div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_Layout">
-            <span className="mx_SettingsTab_subheading">{ _t("Message layout") }</span>
-
-            <div className="mx_AppearanceUserSettingsTab_Layout_RadioButtons">
-                <label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
-                    mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.IRC,
-                })}>
-                    <EventTilePreview
-                        className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
-                        message={this.MESSAGE_PREVIEW_TEXT}
-                        layout={Layout.IRC}
-                        userId={this.state.userId}
-                        displayName={this.state.displayName}
-                        avatarUrl={this.state.avatarUrl}
-                    />
-                    <StyledRadioButton
-                        name="layout"
-                        value="irc"
-                        checked={this.state.layout === Layout.IRC}
-                        onChange={this.onLayoutChange}
-                    >
-                        { _t("IRC") }
-                    </StyledRadioButton>
-                </label>
-                <label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
-                    mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout == Layout.Group,
-                })}>
-                    <EventTilePreview
-                        className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
-                        message={this.MESSAGE_PREVIEW_TEXT}
-                        layout={Layout.Group}
-                        userId={this.state.userId}
-                        displayName={this.state.displayName}
-                        avatarUrl={this.state.avatarUrl}
-                    />
-                    <StyledRadioButton
-                        name="layout"
-                        value="group"
-                        checked={this.state.layout == Layout.Group}
-                        onChange={this.onLayoutChange}
-                    >
-                        { _t("Modern") }
-                    </StyledRadioButton>
-                </label>
-                <label className={classNames("mx_AppearanceUserSettingsTab_Layout_RadioButton", {
-                    mx_AppearanceUserSettingsTab_Layout_RadioButton_selected: this.state.layout === Layout.Bubble,
-                })}>
-                    <EventTilePreview
-                        className="mx_AppearanceUserSettingsTab_Layout_RadioButton_preview"
-                        message={this.MESSAGE_PREVIEW_TEXT}
-                        layout={Layout.Bubble}
-                        userId={this.state.userId}
-                        displayName={this.state.displayName}
-                        avatarUrl={this.state.avatarUrl}
-                    />
-                    <StyledRadioButton
-                        name="layout"
-                        value="bubble"
-                        checked={this.state.layout == Layout.Bubble}
-                        onChange={this.onLayoutChange}
-                    >
-                        { _t("Message bubbles") }
-                    </StyledRadioButton>
-                </label>
-            </div>
-        </div>;
-    };
-
     private renderAdvancedSection() {
         if (!SettingsStore.getValue(UIFeature.AdvancedSettings)) return null;
 
@@ -527,6 +448,19 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
     render() {
         const brand = SdkConfig.get().brand;
 
+        let layoutSection;
+        if (SettingsStore.getValue("feature_new_layout_switcher")) {
+            layoutSection = (
+                <LayoutSwitcher
+                    userId={this.state.userId}
+                    displayName={this.state.displayName}
+                    avatarUrl={this.state.avatarUrl}
+                    messagePreviewText={this.MESSAGE_PREVIEW_TEXT}
+                    onLayoutChanged={this.onLayoutChanged}
+                />
+            );
+        }
+
         return (
             <div className="mx_SettingsTab mx_AppearanceUserSettingsTab">
                 <div className="mx_SettingsTab_heading">{ _t("Customise your appearance") }</div>
@@ -534,7 +468,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
                     { _t("Appearance Settings only affect this %(brand)s session.", { brand }) }
                 </div>
                 { this.renderThemeSection() }
-                { SettingsStore.getValue("feature_new_layout_switcher") ? this.renderLayoutSection() : null }
+                { layoutSection }
                 { this.renderFontSection() }
                 { this.renderAdvancedSection() }
             </div>
diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx
index eaf52e6062..904fdf0914 100644
--- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx
+++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx
@@ -15,9 +15,9 @@ limitations under the License.
 */
 
 import React from 'react';
+import AccessibleButton, { ButtonEvent } from "../../../elements/AccessibleButton";
 import { _t, getCurrentLanguage } from "../../../../../languageHandler";
 import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
-import AccessibleButton from "../../../elements/AccessibleButton";
 import AccessibleTooltipButton from '../../../elements/AccessibleTooltipButton';
 import SdkConfig from "../../../../../SdkConfig";
 import createRoom from "../../../../../createRoom";
@@ -69,6 +69,18 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
         if (this.closeCopiedTooltip) this.closeCopiedTooltip();
     }
 
+    private getVersionInfo(): { appVersion: string, olmVersion: string } {
+        const brand = SdkConfig.get().brand;
+        const appVersion = this.state.appVersion || 'unknown';
+        let olmVersion = MatrixClientPeg.get().olmVersion;
+        olmVersion = olmVersion ? `${olmVersion[0]}.${olmVersion[1]}.${olmVersion[2]}` : '<not-enabled>';
+
+        return {
+            appVersion: `${_t("%(brand)s version:", { brand })} ${appVersion}`,
+            olmVersion: `${_t("Olm version:")} ${olmVersion}`,
+        };
+    }
+
     private onClearCacheAndReload = (e) => {
         if (!PlatformPeg.get()) return;
 
@@ -173,17 +185,26 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
         );
     }
 
-    onAccessTokenCopyClick = async (e) => {
+    private async copy(text: string, e: ButtonEvent) {
         e.preventDefault();
-        const target = e.target; // copy target before we go async and React throws it away
+        const target = e.target as HTMLDivElement; // copy target before we go async and React throws it away
 
-        const successful = await copyPlaintext(MatrixClientPeg.get().getAccessToken());
+        const successful = await copyPlaintext(text);
         const buttonRect = target.getBoundingClientRect();
         const { close } = ContextMenu.createMenu(GenericTextContextMenu, {
             ...toRightOf(buttonRect, 2),
             message: successful ? _t('Copied!') : _t('Failed to copy'),
         });
         this.closeCopiedTooltip = target.onmouseleave = close;
+    }
+
+    private onAccessTokenCopyClick = (e: ButtonEvent) => {
+        this.copy(MatrixClientPeg.get().getAccessToken(), e);
+    };
+
+    private onCopyVersionClicked = (e: ButtonEvent) => {
+        const { appVersion, olmVersion } = this.getVersionInfo();
+        this.copy(`${appVersion}\n${olmVersion}`, e);
     };
 
     render() {
@@ -232,11 +253,6 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
             );
         }
 
-        const appVersion = this.state.appVersion || 'unknown';
-
-        let olmVersion = MatrixClientPeg.get().olmVersion;
-        olmVersion = olmVersion ? `${olmVersion[0]}.${olmVersion[1]}.${olmVersion[2]}` : '<not-enabled>';
-
         let updateButton = null;
         if (this.state.canUpdate) {
             updateButton = <UpdateCheckButton />;
@@ -275,6 +291,8 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
             );
         }
 
+        const { appVersion, olmVersion } = this.getVersionInfo();
+
         return (
             <div className="mx_SettingsTab mx_HelpUserSettingsTab">
                 <div className="mx_SettingsTab_heading">{ _t("Help & About") }</div>
@@ -291,8 +309,15 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
                 <div className='mx_SettingsTab_section mx_HelpUserSettingsTab_versions'>
                     <span className='mx_SettingsTab_subheading'>{ _t("Versions") }</span>
                     <div className='mx_SettingsTab_subsectionText'>
-                        { _t("%(brand)s version:", { brand }) } { appVersion }<br />
-                        { _t("olm version:") } { olmVersion }<br />
+                        <div className="mx_HelpUserSettingsTab_copy">
+                            { appVersion }<br />
+                            { olmVersion }<br />
+                            <AccessibleTooltipButton
+                                title={_t("Copy")}
+                                onClick={this.onCopyVersionClicked}
+                                className="mx_HelpUserSettingsTab_copyButton"
+                            />
+                        </div>
                         { updateButton }
                     </div>
                 </div>
@@ -308,12 +333,12 @@ export default class HelpUserSettingsTab extends React.Component<IProps, IState>
                             <summary>{ _t("Access Token") }</summary><br />
                             <b>{ _t("Your access token gives full access to your account."
                                + " Do not share it with anyone." ) }</b>
-                            <div className="mx_HelpUserSettingsTab_accessToken">
+                            <div className="mx_HelpUserSettingsTab_copy">
                                 <code>{ MatrixClientPeg.get().getAccessToken() }</code>
                                 <AccessibleTooltipButton
                                     title={_t("Copy")}
                                     onClick={this.onAccessTokenCopyClick}
-                                    className="mx_HelpUserSettingsTab_accessToken_copy"
+                                    className="mx_HelpUserSettingsTab_copyButton"
                                 />
                             </div>
                         </details><br />
diff --git a/src/components/views/settings/tabs/user/LabsUserSettingsTab.js b/src/components/views/settings/tabs/user/LabsUserSettingsTab.js
index fa854fc4d8..19a97151d6 100644
--- a/src/components/views/settings/tabs/user/LabsUserSettingsTab.js
+++ b/src/components/views/settings/tabs/user/LabsUserSettingsTab.js
@@ -19,11 +19,12 @@ import { _t } from "../../../../../languageHandler";
 import PropTypes from "prop-types";
 import SettingsStore from "../../../../../settings/SettingsStore";
 import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
-import * as sdk from "../../../../../index";
 import { SettingLevel } from "../../../../../settings/SettingLevel";
 import { replaceableComponent } from "../../../../../utils/replaceableComponent";
 import SdkConfig from "../../../../../SdkConfig";
 import BetaCard from "../../../beta/BetaCard";
+import SettingsFlag from '../../../elements/SettingsFlag';
+import { MatrixClientPeg } from '../../../../../MatrixClientPeg';
 
 export class LabsSettingToggle extends React.Component {
     static propTypes = {
@@ -47,6 +48,14 @@ export class LabsSettingToggle extends React.Component {
 export default class LabsUserSettingsTab extends React.Component {
     constructor() {
         super();
+
+        MatrixClientPeg.get().doesServerSupportUnstableFeature("org.matrix.msc2285").then((showHiddenReadReceipts) => {
+            this.setState({ showHiddenReadReceipts });
+        });
+
+        this.state = {
+            showHiddenReadReceipts: false,
+        };
     }
 
     render() {
@@ -65,15 +74,22 @@ export default class LabsUserSettingsTab extends React.Component {
 
         let labsSection;
         if (SdkConfig.get()['showLabsSettings']) {
-            const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag");
             const flags = labs.map(f => <LabsSettingToggle featureId={f} key={f} />);
 
+            let hiddenReadReceipts;
+            if (this.state.showHiddenReadReceipts) {
+                hiddenReadReceipts = (
+                    <SettingsFlag name="feature_hidden_read_receipts" level={SettingLevel.ACCOUNT} />
+                );
+            }
+
             labsSection = <div className="mx_SettingsTab_section">
                 { flags }
                 <SettingsFlag name="enableWidgetScreenshots" level={SettingLevel.ACCOUNT} />
                 <SettingsFlag name="showHiddenEventsInTimeline" level={SettingLevel.DEVICE} />
                 <SettingsFlag name="lowBandwidth" level={SettingLevel.DEVICE} />
                 <SettingsFlag name="advancedRoomListLogging" level={SettingLevel.DEVICE} />
+                { hiddenReadReceipts }
             </div>;
         }
 
diff --git a/src/components/views/voip/CallPreview.tsx b/src/components/views/voip/CallPreview.tsx
index 46ff8ca838..2aa3080e60 100644
--- a/src/components/views/voip/CallPreview.tsx
+++ b/src/components/views/voip/CallPreview.tsx
@@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React, { createRef } from 'react';
+import React from 'react';
 
 import CallView from "./CallView";
 import RoomViewStore from '../../../stores/RoomViewStore';
@@ -27,23 +27,8 @@ import SettingsStore from "../../../settings/SettingsStore";
 import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
 import { MatrixClientPeg } from '../../../MatrixClientPeg';
 import { replaceableComponent } from "../../../utils/replaceableComponent";
-import UIStore from '../../../stores/UIStore';
-import { lerp } from '../../../utils/AnimationUtils';
-import { MarkedExecution } from '../../../utils/MarkedExecution';
 import { EventSubscription } from 'fbemitter';
-
-const PIP_VIEW_WIDTH = 336;
-const PIP_VIEW_HEIGHT = 232;
-
-const MOVING_AMT = 0.2;
-const SNAPPING_AMT = 0.1;
-
-const PADDING = {
-    top: 58,
-    bottom: 58,
-    left: 76,
-    right: 8,
-};
+import PictureInPictureDragger from './PictureInPictureDragger';
 
 const SHOW_CALL_IN_STATES = [
     CallState.Connected,
@@ -66,10 +51,6 @@ interface IState {
     // Any other call we're displaying: only if the user is on two calls and not viewing either of the rooms
     // they belong to
     secondaryCall: MatrixCall;
-
-    // Position of the CallPreview
-    translationX: number;
-    translationY: number;
 }
 
 // Splits a list of calls into one 'primary' one and a list
@@ -112,16 +93,6 @@ export default class CallPreview extends React.Component<IProps, IState> {
     private roomStoreToken: EventSubscription;
     private dispatcherRef: string;
     private settingsWatcherRef: string;
-    private callViewWrapper = createRef<HTMLDivElement>();
-    private initX = 0;
-    private initY = 0;
-    private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH;
-    private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH;
-    private moving = false;
-    private scheduledUpdate = new MarkedExecution(
-        () => this.animationCallback(),
-        () => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
-    );
 
     constructor(props: IProps) {
         super(props);
@@ -136,17 +107,12 @@ export default class CallPreview extends React.Component<IProps, IState> {
             roomId,
             primaryCall: primaryCall,
             secondaryCall: secondaryCalls[0],
-            translationX: UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH,
-            translationY: UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH,
         };
     }
 
     public componentDidMount() {
         CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
         this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
-        document.addEventListener("mousemove", this.onMoving);
-        document.addEventListener("mouseup", this.onEndMoving);
-        window.addEventListener("resize", this.onResize);
         this.dispatcherRef = dis.register(this.onAction);
         MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
     }
@@ -154,9 +120,6 @@ export default class CallPreview extends React.Component<IProps, IState> {
     public componentWillUnmount() {
         CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
         MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
-        document.removeEventListener("mousemove", this.onMoving);
-        document.removeEventListener("mouseup", this.onEndMoving);
-        window.removeEventListener("resize", this.onResize);
         if (this.roomStoreToken) {
             this.roomStoreToken.remove();
         }
@@ -164,94 +127,6 @@ export default class CallPreview extends React.Component<IProps, IState> {
         SettingsStore.unwatchSetting(this.settingsWatcherRef);
     }
 
-    private onResize = (): void => {
-        this.snap(false);
-    };
-
-    private animationCallback = () => {
-        // If the PiP isn't being dragged and there is only a tiny difference in
-        // the desiredTranslation and translation, quit the animationCallback
-        // loop. If that is the case, it means the PiP has snapped into its
-        // position and there is nothing to do. Not doing this would cause an
-        // infinite loop
-        if (
-            !this.moving &&
-            Math.abs(this.state.translationX - this.desiredTranslationX) <= 1 &&
-            Math.abs(this.state.translationY - this.desiredTranslationY) <= 1
-        ) return;
-
-        const amt = this.moving ? MOVING_AMT : SNAPPING_AMT;
-        this.setState({
-            translationX: lerp(this.state.translationX, this.desiredTranslationX, amt),
-            translationY: lerp(this.state.translationY, this.desiredTranslationY, amt),
-        });
-        this.scheduledUpdate.mark();
-    };
-
-    private setTranslation(inTranslationX: number, inTranslationY: number) {
-        const width = this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH;
-        const height = this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT;
-
-        // Avoid overflow on the x axis
-        if (inTranslationX + width >= UIStore.instance.windowWidth) {
-            this.desiredTranslationX = UIStore.instance.windowWidth - width;
-        } else if (inTranslationX <= 0) {
-            this.desiredTranslationX = 0;
-        } else {
-            this.desiredTranslationX = inTranslationX;
-        }
-
-        // Avoid overflow on the y axis
-        if (inTranslationY + height >= UIStore.instance.windowHeight) {
-            this.desiredTranslationY = UIStore.instance.windowHeight - height;
-        } else if (inTranslationY <= 0) {
-            this.desiredTranslationY = 0;
-        } else {
-            this.desiredTranslationY = inTranslationY;
-        }
-    }
-
-    private snap(animate?: boolean): void {
-        const translationX = this.desiredTranslationX;
-        const translationY = this.desiredTranslationY;
-        // We subtract the PiP size from the window size in order to calculate
-        // the position to snap to from the PiP center and not its top-left
-        // corner
-        const windowWidth = (
-            UIStore.instance.windowWidth -
-            (this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH)
-        );
-        const windowHeight = (
-            UIStore.instance.windowHeight -
-            (this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT)
-        );
-
-        if (translationX >= windowWidth / 2 && translationY >= windowHeight / 2) {
-            this.desiredTranslationX = windowWidth - PADDING.right;
-            this.desiredTranslationY = windowHeight - PADDING.bottom;
-        } else if (translationX >= windowWidth / 2 && translationY <= windowHeight / 2) {
-            this.desiredTranslationX = windowWidth - PADDING.right;
-            this.desiredTranslationY = PADDING.top;
-        } else if (translationX <= windowWidth / 2 && translationY >= windowHeight / 2) {
-            this.desiredTranslationX = PADDING.left;
-            this.desiredTranslationY = windowHeight - PADDING.bottom;
-        } else {
-            this.desiredTranslationX = PADDING.left;
-            this.desiredTranslationY = PADDING.top;
-        }
-
-        if (animate) {
-            // We start animating here because we want the PiP to move when we're
-            // resizing the window
-            this.scheduledUpdate.mark();
-        } else {
-            this.setState({
-                translationX: this.desiredTranslationX,
-                translationY: this.desiredTranslationY,
-            });
-        }
-    }
-
     private onRoomViewStoreUpdate = () => {
         if (RoomViewStore.getRoomId() === this.state.roomId) return;
 
@@ -269,9 +144,10 @@ export default class CallPreview extends React.Component<IProps, IState> {
 
     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': {
+                // listen for call state changes to prod the render method, which
+                // may hide the global CallView if the call it is tracking is dead
+
                 this.updateCalls();
                 break;
             }
@@ -300,57 +176,26 @@ export default class CallPreview extends React.Component<IProps, IState> {
         });
     };
 
-    private onStartMoving = (event: React.MouseEvent) => {
-        event.preventDefault();
-        event.stopPropagation();
-
-        this.moving = true;
-        this.initX = event.pageX - this.desiredTranslationX;
-        this.initY = event.pageY - this.desiredTranslationY;
-        this.scheduledUpdate.mark();
-    };
-
-    private onMoving = (event: React.MouseEvent | MouseEvent) => {
-        if (!this.moving) return;
-
-        event.preventDefault();
-        event.stopPropagation();
-
-        this.setTranslation(event.pageX - this.initX, event.pageY - this.initY);
-    };
-
-    private onEndMoving = () => {
-        this.moving = false;
-        this.snap(true);
-    };
-
     public render() {
+        const pipMode = true;
         if (this.state.primaryCall) {
-            const translatePixelsX = this.state.translationX + "px";
-            const translatePixelsY = this.state.translationY + "px";
-            const style = {
-                transform: `translateX(${translatePixelsX})
-                            translateY(${translatePixelsY})`,
-            };
-
             return (
-                <div
+                <PictureInPictureDragger
                     className="mx_CallPreview"
-                    style={style}
-                    ref={this.callViewWrapper}
+                    draggable={pipMode}
                 >
-                    <CallView
+                    { ({ onStartMoving, onResize }) => <CallView
+                        onMouseDownOnHeader={onStartMoving}
                         call={this.state.primaryCall}
                         secondaryCall={this.state.secondaryCall}
-                        pipMode={true}
-                        onMouseDownOnHeader={this.onStartMoving}
-                        onResize={this.onResize}
-                    />
-                </div>
+                        pipMode={pipMode}
+                        onResize={onResize}
+                    /> }
+                </PictureInPictureDragger>
+
             );
         }
 
         return <PersistentApp />;
     }
 }
-
diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx
index 570d49f715..76e6a43ca5 100644
--- a/src/components/views/voip/CallView.tsx
+++ b/src/components/views/voip/CallView.tsx
@@ -42,28 +42,29 @@ import DesktopCapturerSourcePicker from "../elements/DesktopCapturerSourcePicker
 import Modal from '../../../Modal';
 import { SDPStreamMetadataPurpose } from 'matrix-js-sdk/src/webrtc/callEventTypes';
 import CallViewSidebar from './CallViewSidebar';
+import CallViewHeader from './CallView/CallViewHeader';
 import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
 import { Alignment } from "../elements/Tooltip";
 
 interface IProps {
-        // The call for us to display
-        call: MatrixCall;
+    // The call for us to display
+    call: MatrixCall;
 
-        // Another ongoing call to display information about
-        secondaryCall?: MatrixCall;
+    // Another ongoing call to display information about
+    secondaryCall?: MatrixCall;
 
-        // a callback which is called when the content in the CallView changes
-        // in a way that is likely to cause a resize.
-        onResize?: any;
+    // a callback which is called when the content in the CallView changes
+    // in a way that is likely to cause a resize.
+    onResize?: (event: Event) => void;
 
-        // Whether this call view is for picture-in-picture mode
-        // otherwise, it's the larger call view when viewing the room the call is in.
-        // This is sort of a proxy for a number of things but we currently have no
-        // need to control those things separately, so this is simpler.
-        pipMode?: boolean;
+    // Whether this call view is for picture-in-picture mode
+    // otherwise, it's the larger call view when viewing the room the call is in.
+    // This is sort of a proxy for a number of things but we currently have no
+    // need to control those things separately, so this is simpler.
+    pipMode?: boolean;
 
-        // Used for dragging the PiP CallView
-        onMouseDownOnHeader?: (event: React.MouseEvent) => void;
+    // Used for dragging the PiP CallView
+    onMouseDownOnHeader?: (event: React.MouseEvent<Element, MouseEvent>) => void;
 }
 
 interface IState {
@@ -152,6 +153,7 @@ export default class CallView extends React.Component<IProps, IState> {
     public componentDidMount() {
         this.dispatcherRef = dis.register(this.onAction);
         document.addEventListener('keydown', this.onNativeKeyDown);
+        this.showControls();
     }
 
     public componentWillUnmount() {
@@ -239,21 +241,6 @@ export default class CallView extends React.Component<IProps, IState> {
         });
     };
 
-    private onFullscreenClick = () => {
-        dis.dispatch({
-            action: 'video_fullscreen',
-            fullscreen: true,
-        });
-    };
-
-    private onExpandClick = () => {
-        const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
-        dis.dispatch({
-            action: 'view_room',
-            room_id: userFacingRoomId,
-        });
-    };
-
     private onControlsHideTimer = () => {
         if (this.state.hoveringControls || this.state.showDialpad || this.state.showMoreMenu) return;
         this.controlsHideTimer = null;
@@ -397,23 +384,6 @@ export default class CallView extends React.Component<IProps, IState> {
         this.setState({ hoveringControls: false });
     };
 
-    private onRoomAvatarClick = (): void => {
-        const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
-        dis.dispatch({
-            action: 'view_room',
-            room_id: userFacingRoomId,
-        });
-    };
-
-    private onSecondaryRoomAvatarClick = (): void => {
-        const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall);
-
-        dis.dispatch({
-            action: 'view_room',
-            room_id: userFacingRoomId,
-        });
-    };
-
     private onCallResumeClick = (): void => {
         const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call);
         CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId);
@@ -726,7 +696,7 @@ export default class CallView extends React.Component<IProps, IState> {
                 let onHoldBackground = null;
                 const backgroundStyle: CSSProperties = {};
                 const backgroundAvatarUrl = avatarUrlForMember(
-                // is it worth getting the size of the div to pass here?
+                    // is it worth getting the size of the div to pass here?
                     this.props.call.getOpponentMember(), 1024, 1024, 'crop',
                 );
                 backgroundStyle.backgroundImage = 'url(' + backgroundAvatarUrl + ')';
@@ -746,7 +716,7 @@ export default class CallView extends React.Component<IProps, IState> {
                     mx_CallView_voice_hold: isOnHold,
                 });
 
-                contentView =(
+                contentView = (
                     <div className={classes} onMouseMove={this.onMouseMove}>
                         <div className="mx_CallView_voice_avatarsContainer">
                             <div
@@ -848,83 +818,15 @@ export default class CallView extends React.Component<IProps, IState> {
             );
         }
 
-        const callTypeText = isVideoCall ? _t("Video Call") : _t("Voice Call");
-        let myClassName;
-
-        let fullScreenButton;
-        if (!this.props.pipMode) {
-            fullScreenButton = (
-                <AccessibleTooltipButton
-                    className="mx_CallView_header_button mx_CallView_header_button_fullscreen"
-                    onClick={this.onFullscreenClick}
-                    title={_t("Fill Screen")}
-                />
-            );
-        }
-
-        let expandButton;
-        if (this.props.pipMode) {
-            expandButton = <AccessibleTooltipButton
-                className="mx_CallView_header_button mx_CallView_header_button_expand"
-                onClick={this.onExpandClick}
-                title={_t("Return to call")}
-            />;
-        }
-
-        const headerControls = <div className="mx_CallView_header_controls">
-            { fullScreenButton }
-            { expandButton }
-        </div>;
-
-        const callTypeIconClassName = classNames("mx_CallView_header_callTypeIcon", {
-            "mx_CallView_header_callTypeIcon_voice": !isVideoCall,
-            "mx_CallView_header_callTypeIcon_video": isVideoCall,
-        });
-
-        let header: React.ReactNode;
-        if (!this.props.pipMode) {
-            header = <div className="mx_CallView_header">
-                <div className={callTypeIconClassName} />
-                <span className="mx_CallView_header_callType">{ callTypeText }</span>
-                { headerControls }
-            </div>;
-            myClassName = 'mx_CallView_large';
-        } else {
-            let secondaryCallInfo;
-            if (this.props.secondaryCall) {
-                secondaryCallInfo = <span className="mx_CallView_header_secondaryCallInfo">
-                    <AccessibleButton element='span' onClick={this.onSecondaryRoomAvatarClick}>
-                        <RoomAvatar room={secCallRoom} height={16} width={16} />
-                        <span className="mx_CallView_secondaryCall_roomName">
-                            { _t("%(name)s on hold", { name: secCallRoom.name }) }
-                        </span>
-                    </AccessibleButton>
-                </span>;
-            }
-
-            header = (
-                <div
-                    className="mx_CallView_header"
-                    onMouseDown={this.props.onMouseDownOnHeader}
-                >
-                    <AccessibleButton onClick={this.onRoomAvatarClick}>
-                        <RoomAvatar room={callRoom} height={32} width={32} />
-                    </AccessibleButton>
-                    <div className="mx_CallView_header_callInfo">
-                        <div className="mx_CallView_header_roomName">{ callRoom.name }</div>
-                        <div className="mx_CallView_header_callTypeSmall">
-                            { callTypeText }
-                            { secondaryCallInfo }
-                        </div>
-                    </div>
-                    { headerControls }
-                </div>
-            );
-            myClassName = 'mx_CallView_pip';
-        }
+        const myClassName = this.props.pipMode ? 'mx_CallView_pip' : 'mx_CallView_large';
 
         return <div className={"mx_CallView " + myClassName}>
-            { header }
+            <CallViewHeader
+                onPipMouseDown={this.props.onMouseDownOnHeader}
+                pipMode={this.props.pipMode}
+                type={this.props.call.type}
+                callRooms={[callRoom, secCallRoom]}
+            />
             { contentView }
         </div>;
     }
diff --git a/src/components/views/voip/CallView/CallViewHeader.tsx b/src/components/views/voip/CallView/CallViewHeader.tsx
new file mode 100644
index 0000000000..d9a49e5010
--- /dev/null
+++ b/src/components/views/voip/CallView/CallViewHeader.tsx
@@ -0,0 +1,135 @@
+/*
+Copyright 2021 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { CallType } from 'matrix-js-sdk/src/webrtc/call';
+import { Room } from 'matrix-js-sdk/src/models/room';
+import React from 'react';
+import { _t, _td } from '../../../../languageHandler';
+import RoomAvatar from '../../avatars/RoomAvatar';
+import AccessibleButton from '../../elements/AccessibleButton';
+import dis from '../../../../dispatcher/dispatcher';
+import classNames from 'classnames';
+import AccessibleTooltipButton from '../../elements/AccessibleTooltipButton';
+
+const callTypeTranslationByType: Record<CallType, string> = {
+    [CallType.Video]: _td("Video Call"),
+    [CallType.Voice]: _td("Voice Call"),
+};
+
+interface CallViewHeaderProps {
+    pipMode: boolean;
+    type: CallType;
+    callRooms?: Room[];
+    onPipMouseDown: (event: React.MouseEvent<Element, MouseEvent>) => void;
+}
+
+const onRoomAvatarClick = (roomId: string) => {
+    dis.dispatch({
+        action: 'view_room',
+        room_id: roomId,
+    });
+};
+
+const onFullscreenClick = () => {
+    dis.dispatch({
+        action: 'video_fullscreen',
+        fullscreen: true,
+    });
+};
+
+const onExpandClick = (roomId: string) => {
+    dis.dispatch({
+        action: 'view_room',
+        room_id: roomId,
+    });
+};
+
+type CallControlsProps = Pick<CallViewHeaderProps, 'pipMode' | 'type'> & {
+    roomId: string;
+};
+const CallViewHeaderControls: React.FC<CallControlsProps> = ({ pipMode = false, type, roomId }) => {
+    return <div className="mx_CallViewHeader_controls">
+        { !pipMode && <AccessibleTooltipButton
+            className="mx_CallViewHeader_button mx_CallViewHeader_button_fullscreen"
+            onClick={onFullscreenClick}
+            title={_t("Fill Screen")}
+        /> }
+        { pipMode && <AccessibleTooltipButton
+            className="mx_CallViewHeader_button mx_CallViewHeader_button_expand"
+            onClick={() => onExpandClick(roomId)}
+            title={_t("Return to call")}
+        /> }
+    </div>;
+};
+const SecondaryCallInfo: React.FC<{ callRoom: Room }> = ({ callRoom }) => {
+    return <span className="mx_CallViewHeader_secondaryCallInfo">
+        <AccessibleButton element='span' onClick={() => onRoomAvatarClick(callRoom.roomId)}>
+            <RoomAvatar room={callRoom} height={16} width={16} />
+            <span className="mx_CallView_secondaryCall_roomName">
+                { _t("%(name)s on hold", { name: callRoom.name }) }
+            </span>
+        </AccessibleButton>
+    </span>;
+};
+
+const CallTypeIcon: React.FC<{ type: CallType }> = ({ type }) => {
+    const classes = classNames({
+        'mx_CallViewHeader_callTypeIcon': true,
+        'mx_CallViewHeader_callTypeIcon_video': type === CallType.Video,
+        'mx_CallViewHeader_callTypeIcon_voice': type === CallType.Voice,
+    });
+    return <div className={classes} />;
+};
+
+const CallViewHeader: React.FC<CallViewHeaderProps> = ({
+    type,
+    pipMode = false,
+    callRooms = [],
+    onPipMouseDown,
+}) => {
+    const [callRoom, onHoldCallRoom] = callRooms;
+    const callTypeText = _t(callTypeTranslationByType[type]);
+    const callRoomName = callRoom.name;
+    const { roomId } = callRoom;
+
+    if (!pipMode) {
+        return <div className="mx_CallViewHeader">
+            <CallTypeIcon type={type} />
+            <span className="mx_CallViewHeader_callType">{ callTypeText }</span>
+            <CallViewHeaderControls roomId={roomId} pipMode={pipMode} type={type} />
+        </div>;
+    }
+    return (
+        <div
+            className="mx_CallViewHeader"
+            onMouseDown={onPipMouseDown}
+        >
+            <AccessibleButton onClick={() => onRoomAvatarClick(roomId)}>
+                <RoomAvatar room={callRoom} height={32} width={32} />
+            </AccessibleButton>
+            <div className="mx_CallViewHeader_callInfo">
+                <div className="mx_CallViewHeader_roomName">{ callRoomName }</div>
+                <div className="mx_CallViewHeader_callTypeSmall">
+                    { callTypeText }
+                    { onHoldCallRoom && <SecondaryCallInfo callRoom={onHoldCallRoom} /> }
+                </div>
+            </div>
+            <CallViewHeaderControls roomId={roomId} pipMode={pipMode} type={type} />
+        </div>
+    );
+};
+
+export default CallViewHeader;
diff --git a/src/components/views/voip/PictureInPictureDragger.tsx b/src/components/views/voip/PictureInPictureDragger.tsx
new file mode 100644
index 0000000000..23a09b20d8
--- /dev/null
+++ b/src/components/views/voip/PictureInPictureDragger.tsx
@@ -0,0 +1,229 @@
+/*
+Copyright 2021 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React, { createRef } from 'react';
+import UIStore from '../../../stores/UIStore';
+import { lerp } from '../../../utils/AnimationUtils';
+import { MarkedExecution } from '../../../utils/MarkedExecution';
+import { replaceableComponent } from '../../../utils/replaceableComponent';
+
+const PIP_VIEW_WIDTH = 336;
+const PIP_VIEW_HEIGHT = 232;
+
+const MOVING_AMT = 0.2;
+const SNAPPING_AMT = 0.1;
+
+const PADDING = {
+    top: 58,
+    bottom: 58,
+    left: 76,
+    right: 8,
+};
+
+interface IChildrenOptions {
+    onStartMoving: (event: React.MouseEvent<Element, MouseEvent>) => void;
+    onResize: (event: Event) => void;
+}
+
+interface IProps {
+    className?: string;
+    children: ({ onStartMoving, onResize }: IChildrenOptions) => React.ReactNode;
+    draggable: boolean;
+}
+
+interface IState {
+    // Position of the PictureInPictureDragger
+    translationX: number;
+    translationY: number;
+}
+
+/**
+ * PictureInPictureDragger shows a small version of CallView hovering over the UI in 'picture-in-picture'
+ * (PiP mode). It displays the call(s) which is *not* in the room the user is currently viewing.
+ */
+@replaceableComponent("views.voip.PictureInPictureDragger")
+export default class PictureInPictureDragger extends React.Component<IProps, IState> {
+    private callViewWrapper = createRef<HTMLDivElement>();
+    private initX = 0;
+    private initY = 0;
+    private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH;
+    private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_HEIGHT;
+    private moving = false;
+    private scheduledUpdate = new MarkedExecution(
+        () => this.animationCallback(),
+        () => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
+    );
+
+    constructor(props: IProps) {
+        super(props);
+
+        this.state = {
+            translationX: UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH,
+            translationY: UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_HEIGHT,
+        };
+    }
+
+    public componentDidMount() {
+        document.addEventListener("mousemove", this.onMoving);
+        document.addEventListener("mouseup", this.onEndMoving);
+        window.addEventListener("resize", this.onResize);
+    }
+
+    public componentWillUnmount() {
+        document.removeEventListener("mousemove", this.onMoving);
+        document.removeEventListener("mouseup", this.onEndMoving);
+        window.removeEventListener("resize", this.onResize);
+    }
+
+    private animationCallback = () => {
+        // If the PiP isn't being dragged and there is only a tiny difference in
+        // the desiredTranslation and translation, quit the animationCallback
+        // loop. If that is the case, it means the PiP has snapped into its
+        // position and there is nothing to do. Not doing this would cause an
+        // infinite loop
+        if (
+            !this.moving &&
+            Math.abs(this.state.translationX - this.desiredTranslationX) <= 1 &&
+            Math.abs(this.state.translationY - this.desiredTranslationY) <= 1
+        ) return;
+
+        const amt = this.moving ? MOVING_AMT : SNAPPING_AMT;
+        this.setState({
+            translationX: lerp(this.state.translationX, this.desiredTranslationX, amt),
+            translationY: lerp(this.state.translationY, this.desiredTranslationY, amt),
+        });
+        this.scheduledUpdate.mark();
+    };
+
+    private setTranslation(inTranslationX: number, inTranslationY: number) {
+        const width = this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH;
+        const height = this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT;
+
+        // Avoid overflow on the x axis
+        if (inTranslationX + width >= UIStore.instance.windowWidth) {
+            this.desiredTranslationX = UIStore.instance.windowWidth - width;
+        } else if (inTranslationX <= 0) {
+            this.desiredTranslationX = 0;
+        } else {
+            this.desiredTranslationX = inTranslationX;
+        }
+
+        // Avoid overflow on the y axis
+        if (inTranslationY + height >= UIStore.instance.windowHeight) {
+            this.desiredTranslationY = UIStore.instance.windowHeight - height;
+        } else if (inTranslationY <= 0) {
+            this.desiredTranslationY = 0;
+        } else {
+            this.desiredTranslationY = inTranslationY;
+        }
+    }
+
+    private onResize = (): void => {
+        this.snap(false);
+    };
+
+    private snap = (animate = false) => {
+        const translationX = this.desiredTranslationX;
+        const translationY = this.desiredTranslationY;
+        // We subtract the PiP size from the window size in order to calculate
+        // the position to snap to from the PiP center and not its top-left
+        // corner
+        const windowWidth = (
+            UIStore.instance.windowWidth -
+            (this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH)
+        );
+        const windowHeight = (
+            UIStore.instance.windowHeight -
+            (this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT)
+        );
+
+        if (translationX >= windowWidth / 2 && translationY >= windowHeight / 2) {
+            this.desiredTranslationX = windowWidth - PADDING.right;
+            this.desiredTranslationY = windowHeight - PADDING.bottom;
+        } else if (translationX >= windowWidth / 2 && translationY <= windowHeight / 2) {
+            this.desiredTranslationX = windowWidth - PADDING.right;
+            this.desiredTranslationY = PADDING.top;
+        } else if (translationX <= windowWidth / 2 && translationY >= windowHeight / 2) {
+            this.desiredTranslationX = PADDING.left;
+            this.desiredTranslationY = windowHeight - PADDING.bottom;
+        } else {
+            this.desiredTranslationX = PADDING.left;
+            this.desiredTranslationY = PADDING.top;
+        }
+
+        // We start animating here because we want the PiP to move when we're
+        // resizing the window
+        this.scheduledUpdate.mark();
+
+        if (animate) {
+            // We start animating here because we want the PiP to move when we're
+            // resizing the window
+            this.scheduledUpdate.mark();
+        } else {
+            this.setState({
+                translationX: this.desiredTranslationX,
+                translationY: this.desiredTranslationY,
+            });
+        }
+    };
+
+    private onStartMoving = (event: React.MouseEvent | MouseEvent) => {
+        event.preventDefault();
+        event.stopPropagation();
+
+        this.moving = true;
+        this.initX = event.pageX - this.desiredTranslationX;
+        this.initY = event.pageY - this.desiredTranslationY;
+        this.scheduledUpdate.mark();
+    };
+
+    private onMoving = (event: React.MouseEvent | MouseEvent) => {
+        if (!this.moving) return;
+
+        event.preventDefault();
+        event.stopPropagation();
+
+        this.setTranslation(event.pageX - this.initX, event.pageY - this.initY);
+    };
+
+    private onEndMoving = () => {
+        this.moving = false;
+        this.snap(true);
+    };
+
+    public render() {
+        const translatePixelsX = this.state.translationX + "px";
+        const translatePixelsY = this.state.translationY + "px";
+        const style = {
+            transform: `translateX(${translatePixelsX})
+                        translateY(${translatePixelsY})`,
+        };
+        return (
+            <div
+                className={this.props.className}
+                style={this.props.draggable ? style : undefined}
+                ref={this.callViewWrapper}
+            >
+                <>
+                    { this.props.children({
+                        onStartMoving: this.onStartMoving,
+                        onResize: this.onResize,
+                    }) }
+                </>
+            </div>
+        );
+    }
+}
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index e0b458dd8e..008cbabac3 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -822,6 +822,7 @@
     "Enable advanced debugging for the room list": "Enable advanced debugging for the room list",
     "Show info about bridges in room settings": "Show info about bridges in room settings",
     "New layout switcher (with message bubbles)": "New layout switcher (with message bubbles)",
+    "Don't send read receipts": "Don't send read receipts",
     "Font size": "Font size",
     "Use custom size": "Use custom size",
     "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
@@ -1140,6 +1141,10 @@
     "Connecting to integration manager...": "Connecting to integration manager...",
     "Cannot connect to integration manager": "Cannot connect to integration manager",
     "The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.",
+    "Message layout": "Message layout",
+    "IRC": "IRC",
+    "Modern": "Modern",
+    "Message bubbles": "Message bubbles",
     "Messages containing keywords": "Messages containing keywords",
     "Error saving notification preferences": "Error saving notification preferences",
     "An error occurred whilst saving your notification preferences.": "An error occurred whilst saving your notification preferences.",
@@ -1252,10 +1257,6 @@
     "Custom theme URL": "Custom theme URL",
     "Add theme": "Add theme",
     "Theme": "Theme",
-    "Message layout": "Message layout",
-    "IRC": "IRC",
-    "Modern": "Modern",
-    "Message bubbles": "Message bubbles",
     "Set the name of a font installed on your system & %(brand)s will attempt to use it.": "Set the name of a font installed on your system & %(brand)s will attempt to use it.",
     "Enable experimental, compact IRC style layout": "Enable experimental, compact IRC style layout",
     "Customise your appearance": "Customise your appearance",
@@ -1276,6 +1277,8 @@
     "Deactivate Account": "Deactivate Account",
     "Deactivate account": "Deactivate account",
     "Discovery": "Discovery",
+    "%(brand)s version:": "%(brand)s version:",
+    "Olm version:": "Olm version:",
     "Legal": "Legal",
     "Credits": "Credits",
     "For help with using %(brand)s, click <a>here</a>.": "For help with using %(brand)s, click <a>here</a>.",
@@ -1289,13 +1292,11 @@
     "FAQ": "FAQ",
     "Keyboard Shortcuts": "Keyboard Shortcuts",
     "Versions": "Versions",
-    "%(brand)s version:": "%(brand)s version:",
-    "olm version:": "olm version:",
+    "Copy": "Copy",
     "Homeserver is": "Homeserver is",
     "Identity server is": "Identity server is",
     "Access Token": "Access Token",
     "Your access token gives full access to your account. Do not share it with anyone.": "Your access token gives full access to your account. Do not share it with anyone.",
-    "Copy": "Copy",
     "Clear cache and reload": "Clear cache and reload",
     "Labs": "Labs",
     "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. <a>Learn more</a>.": "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. <a>Learn more</a>.",
@@ -1546,6 +1547,7 @@
     "Send a reply…": "Send a reply…",
     "Send an encrypted message…": "Send an encrypted message…",
     "Send a message…": "Send a message…",
+    "Send voice message": "Send voice message",
     "The conversation continues here.": "The conversation continues here.",
     "This room has been replaced and is no longer active.": "This room has been replaced and is no longer active.",
     "You do not have permission to post to this room": "You do not have permission to post to this room",
@@ -1720,7 +1722,6 @@
     "We were unable to access your microphone. Please check your browser settings and try again.": "We were unable to access your microphone. Please check your browser settings and try again.",
     "No microphone found": "No microphone found",
     "We didn't find a microphone on your device. Please check your settings and try again.": "We didn't find a microphone on your device. Please check your settings and try again.",
-    "Send voice message": "Send voice message",
     "Stop recording": "Stop recording",
     "Error updating main address": "Error updating main address",
     "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.",
@@ -2094,6 +2095,8 @@
     "%(severalUsers)schanged the server ACLs %(count)s times|one": "%(severalUsers)schanged the server ACLs",
     "%(oneUser)schanged the server ACLs %(count)s times|other": "%(oneUser)schanged the server ACLs %(count)s times",
     "%(oneUser)schanged the server ACLs %(count)s times|one": "%(oneUser)schanged the server ACLs",
+    "%(severalUsers)schanged the <a>pinned messages</a> for the room %(count)s times.|other": "%(severalUsers)schanged the <a>pinned messages</a> for the room %(count)s times.",
+    "%(oneUser)schanged the <a>pinned messages</a> for the room %(count)s times.|other": "%(oneUser)schanged the <a>pinned messages</a> for the room %(count)s times.",
     "Power level": "Power level",
     "Custom level": "Custom level",
     "QR Code": "QR Code",
diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx
index ca0253a838..d170f8d357 100644
--- a/src/settings/Settings.tsx
+++ b/src/settings/Settings.tsx
@@ -311,6 +311,13 @@ export const SETTINGS: {[setting: string]: ISetting} = {
         supportedLevels: LEVELS_ACCOUNT_SETTINGS,
         default: null,
     },
+    "feature_hidden_read_receipts": {
+        supportedLevels: LEVELS_FEATURE,
+        displayName: _td(
+            "Don't send read receipts",
+        ),
+        default: false,
+    },
     "baseFontSize": {
         displayName: _td("Font size"),
         supportedLevels: LEVELS_ACCOUNT_SETTINGS,
diff --git a/src/utils/FormattingUtils.ts b/src/utils/FormattingUtils.ts
index 1fe3669f26..b527ee7ea2 100644
--- a/src/utils/FormattingUtils.ts
+++ b/src/utils/FormattingUtils.ts
@@ -16,6 +16,7 @@ limitations under the License.
 */
 
 import { _t } from '../languageHandler';
+import { jsxJoin } from './ReactUtils';
 
 /**
  * formats numbers to fit into ~3 characters, suitable for badge counts
@@ -103,7 +104,7 @@ export function getUserNameColorClass(userId: string): string {
  * @returns {string} a string constructed by joining `items` with a comma
  * between each item, but with the last item appended as " and [lastItem]".
  */
-export function formatCommaSeparatedList(items: string[], itemLimit?: number): string {
+export function formatCommaSeparatedList(items: Array<string | JSX.Element>, itemLimit?: number): string | JSX.Element {
     const remaining = itemLimit === undefined ? 0 : Math.max(
         items.length - itemLimit, 0,
     );
@@ -113,9 +114,9 @@ export function formatCommaSeparatedList(items: string[], itemLimit?: number): s
         return items[0];
     } else if (remaining > 0) {
         items = items.slice(0, itemLimit);
-        return _t("%(items)s and %(count)s others", { items: items.join(', '), count: remaining } );
+        return _t("%(items)s and %(count)s others", { items: jsxJoin(items, ', '), count: remaining } );
     } else {
         const lastItem = items.pop();
-        return _t("%(items)s and %(lastItem)s", { items: items.join(', '), lastItem: lastItem });
+        return _t("%(items)s and %(lastItem)s", { items: jsxJoin(items, ', '), lastItem: lastItem });
     }
 }
diff --git a/src/utils/ReactUtils.tsx b/src/utils/ReactUtils.tsx
new file mode 100644
index 0000000000..4cd2d750f3
--- /dev/null
+++ b/src/utils/ReactUtils.tsx
@@ -0,0 +1,33 @@
+/*
+Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
+
+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";
+
+/**
+ * Joins an array into one value with a joiner. E.g. join(["hello", "world"], " ") -> <span>hello world</span>
+ * @param array the array of element to join
+ * @param joiner the string/JSX.Element to join with
+ * @returns the joined array
+ */
+export function jsxJoin(array: Array<string | JSX.Element>, joiner?: string | JSX.Element): JSX.Element {
+    const newArray = [];
+    array.forEach((element, index) => {
+        newArray.push(element, (index === array.length - 1) ? null : joiner);
+    });
+    return (
+        <span>{ newArray }</span>
+    );
+}
diff --git a/tsconfig.json b/tsconfig.json
index 2e0131609c..02904af9d1 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -12,12 +12,6 @@
     "outDir": "./lib",
     "declaration": true,
     "jsx": "react",
-    "types": [
-      "node",
-      "react",
-      "flux",
-      "react-transition-group"
-    ],
     "lib": [
       "es2019",
       "dom",
@@ -27,5 +21,5 @@
   "include": [
     "./src/**/*.ts",
     "./src/**/*.tsx"
-  ],  
+  ],
 }