diff --git a/package.json b/package.json
index 3ab523ee9a..e66d0aabcf 100644
--- a/package.json
+++ b/package.json
@@ -79,6 +79,7 @@
     "linkifyjs": "^2.1.9",
     "lodash": "^4.17.19",
     "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
+    "matrix-widget-api": "^0.1.0-beta.2",
     "minimist": "^1.2.5",
     "pako": "^1.0.11",
     "parse5": "^5.1.1",
diff --git a/release.sh b/release.sh
index 23b8822041..e2cefcbe74 100755
--- a/release.sh
+++ b/release.sh
@@ -9,6 +9,9 @@ set -e
 
 cd `dirname $0`
 
+# This link seems to get eaten by the release process, so ensure it exists.
+yarn link matrix-js-sdk
+
 for i in matrix-js-sdk
 do
     echo "Checking version of $i..."
diff --git a/res/css/_components.scss b/res/css/_components.scss
index 4c253c7d9a..261b35690e 100644
--- a/res/css/_components.scss
+++ b/res/css/_components.scss
@@ -141,6 +141,7 @@
 @import "./views/messages/_MEmoteBody.scss";
 @import "./views/messages/_MFileBody.scss";
 @import "./views/messages/_MImageBody.scss";
+@import "./views/messages/_MJitsiWidgetEvent.scss";
 @import "./views/messages/_MNoticeBody.scss";
 @import "./views/messages/_MStickerBody.scss";
 @import "./views/messages/_MTextBody.scss";
diff --git a/res/css/structures/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss
index e0814182f5..29e6fecd34 100644
--- a/res/css/structures/_RoomDirectory.scss
+++ b/res/css/structures/_RoomDirectory.scss
@@ -133,6 +133,10 @@ limitations under the License.
 .mx_RoomDirectory_topic {
     cursor: initial;
     color: $light-fg-color;
+    display: -webkit-box;
+    -webkit-box-orient: vertical;
+    -webkit-line-clamp: 3;
+    overflow: hidden;
 }
 
 .mx_RoomDirectory_alias {
diff --git a/res/css/views/messages/_MJitsiWidgetEvent.scss b/res/css/views/messages/_MJitsiWidgetEvent.scss
new file mode 100644
index 0000000000..3e51e89744
--- /dev/null
+++ b/res/css/views/messages/_MJitsiWidgetEvent.scss
@@ -0,0 +1,55 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_MJitsiWidgetEvent {
+    display: grid;
+    grid-template-columns: 24px minmax(0, 1fr) min-content;
+
+    &::before {
+        grid-column: 1;
+        grid-row: 1 / 3;
+        width: 16px;
+        height: 16px;
+        content: "";
+        top: 0;
+        bottom: 0;
+        left: 0;
+        right: 0;
+        mask-repeat: no-repeat;
+        mask-position: center;
+        mask-size: contain;
+        background-color: $composer-e2e-icon-color; // XXX: Variable abuse
+        margin-top: 4px;
+        mask-image: url('$(res)/img/element-icons/call/video-call.svg');
+    }
+
+    .mx_MJitsiWidgetEvent_title {
+        font-weight: 600;
+        font-size: $font-15px;
+        grid-column: 2;
+        grid-row: 1;
+    }
+
+    .mx_MJitsiWidgetEvent_subtitle {
+        grid-column: 2;
+        grid-row: 2;
+    }
+
+    .mx_MJitsiWidgetEvent_title,
+    .mx_MJitsiWidgetEvent_subtitle {
+        overflow-wrap: break-word;
+    }
+}
diff --git a/res/css/views/rooms/_AppsDrawer.scss b/res/css/views/rooms/_AppsDrawer.scss
index fee3d61153..451704bd88 100644
--- a/res/css/views/rooms/_AppsDrawer.scss
+++ b/res/css/views/rooms/_AppsDrawer.scss
@@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-$MiniAppTileHeight: 114px;
+$MiniAppTileHeight: 200px;
 
 .mx_AppsDrawer {
     margin: 5px 5px 5px 18px;
@@ -78,10 +78,6 @@ $MiniAppTileHeight: 114px;
     font-size: $font-12px;
 }
 
-.mx_AddWidget_button_full_width {
-    max-width: 960px;
-}
-
 .mx_SetAppURLDialog_input {
     border-radius: 3px;
     border: 1px solid $input-border-color;
@@ -92,7 +88,6 @@ $MiniAppTileHeight: 114px;
 }
 
 .mx_AppTile {
-    max-width: 960px;
     width: 50%;
     border: 5px solid $widget-menu-bar-bg-color;
     border-radius: 4px;
@@ -105,7 +100,6 @@ $MiniAppTileHeight: 114px;
 }
 
 .mx_AppTileFullWidth {
-    max-width: 960px;
     width: 100%;
     margin: 0;
     padding: 0;
@@ -116,7 +110,6 @@ $MiniAppTileHeight: 114px;
 }
 
 .mx_AppTile_mini {
-    max-width: 960px;
     width: 100%;
     margin: 0;
     padding: 0;
@@ -220,9 +213,10 @@ $MiniAppTileHeight: 114px;
 }
 
 .mx_AppTileBody_mini {
-    height: 112px;
+    height: $MiniAppTileHeight;
     width: 100%;
     overflow: hidden;
+    border-radius: 8px;
 }
 
 .mx_AppTile .mx_AppTileBody,
diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss
index a403a8dc4c..71c0db947e 100644
--- a/res/css/views/rooms/_MessageComposer.scss
+++ b/res/css/views/rooms/_MessageComposer.scss
@@ -217,7 +217,7 @@ limitations under the License.
         }
     }
 
-    &.mx_MessageComposer_hangup::before {
+    &.mx_MessageComposer_hangup:not(.mx_AccessibleButton_disabled)::before {
         background-color: $warning-color;
     }
 }
diff --git a/res/css/views/settings/_AvatarSetting.scss b/res/css/views/settings/_AvatarSetting.scss
index 52a0ee95d7..4a1f57a00e 100644
--- a/res/css/views/settings/_AvatarSetting.scss
+++ b/res/css/views/settings/_AvatarSetting.scss
@@ -16,6 +16,7 @@ limitations under the License.
 
 .mx_AvatarSetting_avatar {
     width: 90px;
+    min-width: 90px; // so it doesn't get crushed by the flexbox in languages with longer words
     height: 90px;
     margin-top: 8px;
     position: relative;
diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss
index 4d26d8a312..759797ae7b 100644
--- a/res/css/views/voip/_CallContainer.scss
+++ b/res/css/views/voip/_CallContainer.scss
@@ -23,9 +23,16 @@ limitations under the License.
     z-index: 100;
     box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08);
 
-    cursor: pointer;
+    // Disable pointer events for Jitsi widgets to function. Direct
+    // calls have their own cursor and behaviour, but we need to make
+    // sure the cursor hits the iframe for Jitsi which will be at a
+    // different level.
+    pointer-events: none;
 
     .mx_CallPreview {
+        pointer-events: initial; // restore pointer events so the user can leave/interact
+        cursor: pointer;
+
         .mx_VideoView {
             width: 350px;
         }
@@ -37,7 +44,7 @@ limitations under the License.
     }
 
     .mx_AppTile_persistedWrapper div {
-        min-width: 300px;
+        min-width: 350px;
     }
 
     .mx_IncomingCallBox {
@@ -45,11 +52,14 @@ limitations under the License.
         background-color: $primary-bg-color;
         padding: 8px;
 
+        pointer-events: initial; // restore pointer events so the user can accept/decline
+        cursor: pointer;
+
         .mx_IncomingCallBox_CallerInfo {
             display: flex;
             direction: row;
 
-            img {
+            img, .mx_BaseAvatar_initial {
                 margin: 8px;
             }
 
diff --git a/src/Avatar.js b/src/Avatar.js
index d76ea6f2c4..1c1182b98d 100644
--- a/src/Avatar.js
+++ b/src/Avatar.js
@@ -82,6 +82,7 @@ function urlForColor(color) {
 const colorToDataURLCache = new Map();
 
 export function defaultAvatarUrlForString(s) {
+    if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake
     const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8'];
     let total = 0;
     for (let i = 0; i < s.length; ++i) {
diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx
index 62b91f938b..5b368016b6 100644
--- a/src/CallHandler.tsx
+++ b/src/CallHandler.tsx
@@ -74,6 +74,9 @@ import {base32} from "rfc4648";
 
 import QuestionDialog from "./components/views/dialogs/QuestionDialog";
 import ErrorDialog from "./components/views/dialogs/ErrorDialog";
+import WidgetStore from "./stores/WidgetStore";
+import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore";
+import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
 
 // until we ts-ify the js-sdk voip code
 type Call = any;
@@ -110,11 +113,9 @@ export default class CallHandler {
     }
 
     getAnyActiveCall() {
-        const roomsWithCalls = Object.keys(this.calls);
-        for (let i = 0; i < roomsWithCalls.length; i++) {
-            if (this.calls.get(roomsWithCalls[i]) &&
-                    this.calls.get(roomsWithCalls[i]).call_state !== "ended") {
-                return this.calls.get(roomsWithCalls[i]);
+        for (const call of this.calls.values()) {
+            if (call.state !== "ended") {
+                return call;
             }
         }
         return null;
@@ -180,7 +181,7 @@ export default class CallHandler {
             });
         });
         call.on("hangup", () => {
-            this.setCallState(undefined, call.roomId, "ended");
+            this.removeCallForRoom(call.roomId);
         });
         // map web rtc states to dummy UI state
         // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing
@@ -192,7 +193,7 @@ export default class CallHandler {
                 this.setCallState(call, call.roomId, "ringback");
                 this.play("ringbackAudio");
             } else if (newState === "ended" && oldState === "connected") {
-                this.setCallState(undefined, call.roomId, "ended");
+                this.removeCallForRoom(call.roomId);
                 this.pause("ringbackAudio");
                 this.play("callendAudio");
             } else if (newState === "ended" && oldState === "invite_sent" &&
@@ -223,7 +224,11 @@ export default class CallHandler {
         console.log(
             `Call state in ${roomId} changed to ${status} (${call ? call.call_state : "-"})`,
         );
-        this.calls.set(roomId, call);
+        if (call) {
+            this.calls.set(roomId, call);
+        } else {
+            this.calls.delete(roomId);
+        }
 
         if (status === "ringing") {
             this.play("ringAudio");
@@ -241,6 +246,10 @@ export default class CallHandler {
         });
     }
 
+    private removeCallForRoom(roomId: string) {
+        this.setCallState(null, roomId, null);
+    }
+
     private showICEFallbackPrompt() {
         const cli = MatrixClientPeg.get();
         const code = sub => <code>{sub}</code>;
@@ -283,7 +292,7 @@ export default class CallHandler {
             } else if (payload.type === 'screensharing') {
                 const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
                 if (screenCapErrorString) {
-                    this.setCallState(undefined, newCall.roomId, "ended");
+                    this.removeCallForRoom(newCall.roomId);
                     console.log("Can't capture screen: " + screenCapErrorString);
                     Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, {
                         title: _t('Unable to capture screen'),
@@ -351,6 +360,14 @@ export default class CallHandler {
                 console.info("Place conference call in %s", payload.room_id);
                 this.startCallApp(payload.room_id, payload.type);
                 break;
+            case 'end_conference':
+                console.info("Terminating conference call in %s", payload.room_id);
+                this.terminateCallApp(payload.room_id);
+                break;
+            case 'hangup_conference':
+                console.info("Leaving conference call in %s", payload.room_id);
+                this.hangupCallApp(payload.room_id);
+                break;
             case 'incoming_call':
                 {
                     if (this.getAnyActiveCall()) {
@@ -376,7 +393,7 @@ export default class CallHandler {
                     return; // no call to hangup
                 }
                 this.calls.get(payload.room_id).hangup();
-                this.setCallState(null, payload.room_id, "ended");
+                this.removeCallForRoom(payload.room_id);
                 break;
             case 'answer':
                 if (!this.calls.get(payload.room_id)) {
@@ -398,10 +415,12 @@ export default class CallHandler {
             show: true,
         });
 
+        // prevent double clicking the call button
         const room = MatrixClientPeg.get().getRoom(roomId);
         const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI);
-
-        if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI)) {
+        const hasJitsi = currentJitsiWidgets.length > 0
+            || WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI);
+        if (hasJitsi) {
             Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, {
                 title: _t('Call in Progress'),
                 description: _t('A call is currently being placed!'),
@@ -409,33 +428,6 @@ export default class CallHandler {
             return;
         }
 
-        if (currentJitsiWidgets.length > 0) {
-            console.warn(
-                "Refusing to start conference call widget in " + roomId +
-                " a conference call widget is already present",
-            );
-
-            if (WidgetUtils.canUserModifyWidgets(roomId)) {
-                Modal.createTrackedDialog('Already have Jitsi Widget', '', QuestionDialog, {
-                    title: _t('End Call'),
-                    description: _t('Remove the group call from the room?'),
-                    button: _t('End Call'),
-                    cancelButton: _t('Cancel'),
-                    onFinished: (endCall) => {
-                        if (endCall) {
-                            WidgetUtils.setRoomWidget(roomId, currentJitsiWidgets[0].getContent()['id']);
-                        }
-                    },
-                });
-            } else {
-                Modal.createTrackedDialog('Already have Jitsi Widget', '', ErrorDialog, {
-                    title: _t('Call in Progress'),
-                    description: _t("You don't have permission to remove the call from the room"),
-                });
-            }
-            return;
-        }
-
         const jitsiDomain = Jitsi.getInstance().preferredDomain;
         const jitsiAuth = await Jitsi.getInstance().getJitsiAuth();
         let confId;
@@ -484,4 +476,38 @@ export default class CallHandler {
             console.error(e);
         });
     }
+
+    private terminateCallApp(roomId: string) {
+        Modal.createTrackedDialog('Confirm Jitsi Terminate', '', QuestionDialog, {
+            hasCancelButton: true,
+            title: _t("End conference"),
+            description: _t("This will end the conference for everyone. Continue?"),
+            button: _t("End conference"),
+            onFinished: (proceed) => {
+                if (!proceed) return;
+
+                // We'll just obliterate them all. There should only ever be one, but might as well
+                // be safe.
+                const roomInfo = WidgetStore.instance.getRoom(roomId);
+                const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type));
+                jitsiWidgets.forEach(w => {
+                    // setting invalid content removes it
+                    WidgetUtils.setRoomWidget(roomId, w.id);
+                });
+            },
+        });
+    }
+
+    private hangupCallApp(roomId: string) {
+        const roomInfo = WidgetStore.instance.getRoom(roomId);
+        if (!roomInfo) return; // "should never happen" clauses go here
+
+        const jitsiWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type));
+        jitsiWidgets.forEach(w => {
+            const messaging = WidgetMessagingStore.instance.getMessagingForId(w.id);
+            if (!messaging) return; // more "should never happen" words
+
+            messaging.transport.send(ElementWidgetActions.HangupCall, {});
+        });
+    }
 }
diff --git a/src/FromWidgetPostMessageApi.js b/src/FromWidgetPostMessageApi.js
deleted file mode 100644
index d5d7c08d50..0000000000
--- a/src/FromWidgetPostMessageApi.js
+++ /dev/null
@@ -1,275 +0,0 @@
-/*
-Copyright 2018 New Vector Ltd
-Copyright 2019 Travis Ralston
-Copyright 2019 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the 'License');
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an 'AS IS' BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import URL from 'url';
-import dis from './dispatcher/dispatcher';
-import WidgetMessagingEndpoint from './WidgetMessagingEndpoint';
-import ActiveWidgetStore from './stores/ActiveWidgetStore';
-import {MatrixClientPeg} from "./MatrixClientPeg";
-import RoomViewStore from "./stores/RoomViewStore";
-import {IntegrationManagers} from "./integrations/IntegrationManagers";
-import SettingsStore from "./settings/SettingsStore";
-import {Capability} from "./widgets/WidgetApi";
-import {objectClone} from "./utils/objects";
-
-const WIDGET_API_VERSION = '0.0.2'; // Current API version
-const SUPPORTED_WIDGET_API_VERSIONS = [
-    '0.0.1',
-    '0.0.2',
-];
-const INBOUND_API_NAME = 'fromWidget';
-
-// Listen for and handle incoming requests using the 'fromWidget' postMessage
-// API and initiate responses
-export default class FromWidgetPostMessageApi {
-    constructor() {
-        this.widgetMessagingEndpoints = [];
-        this.widgetListeners = {}; // {action: func[]}
-
-        this.start = this.start.bind(this);
-        this.stop = this.stop.bind(this);
-        this.onPostMessage = this.onPostMessage.bind(this);
-    }
-
-    start() {
-        window.addEventListener('message', this.onPostMessage);
-    }
-
-    stop() {
-        window.removeEventListener('message', this.onPostMessage);
-    }
-
-    /**
-     * Adds a listener for a given action
-     * @param {string} action The action to listen for.
-     * @param {Function} callbackFn A callback function to be called when the action is
-     * encountered. Called with two parameters: the interesting request information and
-     * the raw event received from the postMessage API. The raw event is meant to be used
-     * for sendResponse and similar functions.
-     */
-    addListener(action, callbackFn) {
-        if (!this.widgetListeners[action]) this.widgetListeners[action] = [];
-        this.widgetListeners[action].push(callbackFn);
-    }
-
-    /**
-     * Removes a listener for a given action.
-     * @param {string} action The action that was subscribed to.
-     * @param {Function} callbackFn The original callback function that was used to subscribe
-     * to updates.
-     */
-    removeListener(action, callbackFn) {
-        if (!this.widgetListeners[action]) return;
-
-        const idx = this.widgetListeners[action].indexOf(callbackFn);
-        if (idx !== -1) this.widgetListeners[action].splice(idx, 1);
-    }
-
-    /**
-     * Register a widget endpoint for trusted postMessage communication
-     * @param {string} widgetId    Unique widget identifier
-     * @param {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
-     */
-    addEndpoint(widgetId, endpointUrl) {
-        const u = URL.parse(endpointUrl);
-        if (!u || !u.protocol || !u.host) {
-            console.warn('Add FromWidgetPostMessageApi endpoint - Invalid origin:', endpointUrl);
-            return;
-        }
-
-        const origin = u.protocol + '//' + u.host;
-        const endpoint = new WidgetMessagingEndpoint(widgetId, origin);
-        if (this.widgetMessagingEndpoints.some(function(ep) {
-            return (ep.widgetId === widgetId && ep.endpointUrl === endpointUrl);
-        })) {
-            // Message endpoint already registered
-            console.warn('Add FromWidgetPostMessageApi - Endpoint already registered');
-            return;
-        } else {
-            console.log(`Adding fromWidget messaging endpoint for ${widgetId}`, endpoint);
-            this.widgetMessagingEndpoints.push(endpoint);
-        }
-    }
-
-    /**
-     * De-register a widget endpoint from trusted communication sources
-     * @param  {string} widgetId Unique widget identifier
-     * @param  {string} endpointUrl Widget wurl origin (protocol + (optional port) + host)
-     * @return {boolean} True if endpoint was successfully removed
-     */
-    removeEndpoint(widgetId, endpointUrl) {
-        const u = URL.parse(endpointUrl);
-        if (!u || !u.protocol || !u.host) {
-            console.warn('Remove widget messaging endpoint - Invalid origin');
-            return;
-        }
-
-        const origin = u.protocol + '//' + u.host;
-        if (this.widgetMessagingEndpoints && this.widgetMessagingEndpoints.length > 0) {
-            const length = this.widgetMessagingEndpoints.length;
-            this.widgetMessagingEndpoints = this.widgetMessagingEndpoints
-                .filter((endpoint) => endpoint.widgetId !== widgetId || endpoint.endpointUrl !== origin);
-            return (length > this.widgetMessagingEndpoints.length);
-        }
-        return false;
-    }
-
-    /**
-     * Handle widget postMessage events
-     * Messages are only handled where a valid, registered messaging endpoints
-     * @param  {Event} event Event to handle
-     * @return {undefined}
-     */
-    onPostMessage(event) {
-        if (!event.origin) { // Handle chrome
-            event.origin = event.originalEvent.origin;
-        }
-
-        // Event origin is empty string if undefined
-        if (
-            event.origin.length === 0 ||
-            !this.trustedEndpoint(event.origin) ||
-            event.data.api !== INBOUND_API_NAME ||
-            !event.data.widgetId
-        ) {
-            return; // don't log this - debugging APIs like to spam postMessage which floods the log otherwise
-        }
-
-        // Call any listeners we have registered
-        if (this.widgetListeners[event.data.action]) {
-            for (const fn of this.widgetListeners[event.data.action]) {
-                fn(event.data, event);
-            }
-        }
-
-        // Although the requestId is required, we don't use it. We'll be nice and process the message
-        // if the property is missing, but with a warning for widget developers.
-        if (!event.data.requestId) {
-            console.warn("fromWidget action '" + event.data.action + "' does not have a requestId");
-        }
-
-        const action = event.data.action;
-        const widgetId = event.data.widgetId;
-        if (action === 'content_loaded') {
-            console.log('Widget reported content loaded for', widgetId);
-            dis.dispatch({
-                action: 'widget_content_loaded',
-                widgetId: widgetId,
-            });
-            this.sendResponse(event, {success: true});
-        } else if (action === 'supported_api_versions') {
-            this.sendResponse(event, {
-                api: INBOUND_API_NAME,
-                supported_versions: SUPPORTED_WIDGET_API_VERSIONS,
-            });
-        } else if (action === 'api_version') {
-            this.sendResponse(event, {
-                api: INBOUND_API_NAME,
-                version: WIDGET_API_VERSION,
-            });
-        } else if (action === 'm.sticker') {
-            // console.warn('Got sticker message from widget', widgetId);
-            // NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually
-            const data = event.data.data || event.data.widgetData;
-            dis.dispatch({action: 'm.sticker', data: data, widgetId: event.data.widgetId});
-        } else if (action === 'integration_manager_open') {
-            // Close the stickerpicker
-            dis.dispatch({action: 'stickerpicker_close'});
-            // Open the integration manager
-            // NOTE -- The widgetData field is deprecated (in favour of the 'data' field) and will be removed eventually
-            const data = event.data.data || event.data.widgetData;
-            const integType = (data && data.integType) ? data.integType : null;
-            const integId = (data && data.integId) ? data.integId : null;
-
-            // TODO: Open the right integration manager for the widget
-            if (SettingsStore.getValue("feature_many_integration_managers")) {
-                IntegrationManagers.sharedInstance().openAll(
-                    MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
-                    `type_${integType}`,
-                    integId,
-                );
-            } else {
-                IntegrationManagers.sharedInstance().getPrimaryManager().open(
-                    MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
-                    `type_${integType}`,
-                    integId,
-                );
-            }
-        } else if (action === 'set_always_on_screen') {
-            // This is a new message: there is no reason to support the deprecated widgetData here
-            const data = event.data.data;
-            const val = data.value;
-
-            if (ActiveWidgetStore.widgetHasCapability(widgetId, Capability.AlwaysOnScreen)) {
-                ActiveWidgetStore.setWidgetPersistence(widgetId, val);
-            }
-        } else if (action === 'get_openid') {
-            // Handled by caller
-        } else {
-            console.warn('Widget postMessage event unhandled');
-            this.sendError(event, {message: 'The postMessage was unhandled'});
-        }
-    }
-
-    /**
-     * Check if message origin is registered as trusted
-     * @param  {string} origin PostMessage origin to check
-     * @return {boolean}       True if trusted
-     */
-    trustedEndpoint(origin) {
-        if (!origin) {
-            return false;
-        }
-
-        return this.widgetMessagingEndpoints.some((endpoint) => {
-            // TODO / FIXME -- Should this also check the widgetId?
-            return endpoint.endpointUrl === origin;
-        });
-    }
-
-    /**
-     * Send a postmessage response to a postMessage request
-     * @param  {Event} event  The original postMessage request event
-     * @param  {Object} res   Response data
-     */
-    sendResponse(event, res) {
-        const data = objectClone(event.data);
-        data.response = res;
-        event.source.postMessage(data, event.origin);
-    }
-
-    /**
-     * Send an error response to a postMessage request
-     * @param  {Event} event        The original postMessage request event
-     * @param  {string} msg         Error message
-     * @param  {Error} nestedError  Nested error event (optional)
-     */
-    sendError(event, msg, nestedError) {
-        console.error('Action:' + event.data.action + ' failed with message: ' + msg);
-        const data = objectClone(event.data);
-        data.response = {
-            error: {
-                message: msg,
-            },
-        };
-        if (nestedError) {
-            data.response.error._error = nestedError;
-        }
-        event.source.postMessage(data, event.origin);
-    }
-}
diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx
index f991d2df5d..c503247bf7 100644
--- a/src/HtmlUtils.tsx
+++ b/src/HtmlUtils.tsx
@@ -53,7 +53,7 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i');
 
 const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/;
 
-const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
+export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet'];
 
 /*
  * Return true if the given string contains emoji
diff --git a/src/Lifecycle.js b/src/Lifecycle.js
index 3a48de5eef..dc04e47535 100644
--- a/src/Lifecycle.js
+++ b/src/Lifecycle.js
@@ -186,6 +186,8 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) {
         console.log("Logged in with token");
         return _clearStorage().then(() => {
             _persistCredentialsToLocalStorage(creds);
+            // remember that we just logged in
+            sessionStorage.setItem("mx_fresh_login", true);
             return true;
         });
     }).catch((err) => {
@@ -312,6 +314,9 @@ async function _restoreFromLocalStorage(opts) {
             console.log("No pickle key available");
         }
 
+        const freshLogin = sessionStorage.getItem("mx_fresh_login");
+        sessionStorage.removeItem("mx_fresh_login");
+
         console.log(`Restoring session for ${userId}`);
         await _doSetLoggedIn({
             userId: userId,
@@ -321,6 +326,7 @@ async function _restoreFromLocalStorage(opts) {
             identityServerUrl: isUrl,
             guest: isGuest,
             pickleKey: pickleKey,
+            freshLogin: freshLogin,
         }, false);
         return true;
     } else {
@@ -364,6 +370,7 @@ async function _handleLoadSessionFailure(e) {
  * @returns {Promise} promise which resolves to the new MatrixClient once it has been started
  */
 export async function setLoggedIn(credentials) {
+    credentials.freshLogin = true;
     stopMatrixClient();
     const pickleKey = credentials.userId && credentials.deviceId
           ? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId)
@@ -429,6 +436,7 @@ async function _doSetLoggedIn(credentials, clearStorage) {
         " guest: " + credentials.guest +
         " hs: " + credentials.homeserverUrl +
         " softLogout: " + softLogout,
+        " freshLogin: " + credentials.freshLogin,
     );
 
     // This is dispatched to indicate that the user is still in the process of logging in
@@ -462,10 +470,28 @@ async function _doSetLoggedIn(credentials, clearStorage) {
 
     Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
 
+    MatrixClientPeg.replaceUsingCreds(credentials);
+    const client = MatrixClientPeg.get();
+
+    if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) {
+        // If we just logged in, try to rehydrate a device instead of using a
+        // new device.  If it succeeds, we'll get a new device ID, so make sure
+        // we persist that ID to localStorage
+        const newDeviceId = await client.rehydrateDevice();
+        if (newDeviceId) {
+            credentials.deviceId = newDeviceId;
+        }
+
+        delete credentials.freshLogin;
+    }
+
     if (localStorage) {
         try {
             _persistCredentialsToLocalStorage(credentials);
 
+            // make sure we don't think that it's a fresh login any more
+            sessionStorage.removeItem("mx_fresh_login");
+
             // The user registered as a PWLU (PassWord-Less User), the generated password
             // is cached here such that the user can change it at a later time.
             if (credentials.password) {
@@ -482,12 +508,10 @@ async function _doSetLoggedIn(credentials, clearStorage) {
         console.warn("No local storage available: can't persist session!");
     }
 
-    MatrixClientPeg.replaceUsingCreds(credentials);
-
     dis.dispatch({ action: 'on_logged_in' });
 
     await startMatrixClient(/*startSyncing=*/!softLogout);
-    return MatrixClientPeg.get();
+    return client;
 }
 
 function _showStorageEvictedDialog() {
diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts
index 9589130e7f..69e586c58d 100644
--- a/src/MatrixClientPeg.ts
+++ b/src/MatrixClientPeg.ts
@@ -31,7 +31,7 @@ import {verificationMethods} from 'matrix-js-sdk/src/crypto';
 import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler";
 import * as StorageManager from './utils/StorageManager';
 import IdentityAuthClient from './IdentityAuthClient';
-import { crossSigningCallbacks } from './SecurityManager';
+import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager';
 import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode";
 
 export interface IMatrixClientCreds {
@@ -42,6 +42,7 @@ export interface IMatrixClientCreds {
     accessToken: string;
     guest: boolean;
     pickleKey?: string;
+    freshLogin?: boolean;
 }
 
 // TODO: Move this to the js-sdk
@@ -192,6 +193,7 @@ class _MatrixClientPeg implements IMatrixClientPeg {
                 this.matrixClient.setCryptoTrustCrossSignedDevices(
                     !SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'),
                 );
+                await tryToUnlockSecretStorageWithDehydrationKey(this.matrixClient);
                 StorageManager.setCryptoInitialised(true);
             }
         } catch (e) {
diff --git a/src/SecurityManager.js b/src/SecurityManager.js
index f6b9c993d0..3272c0f015 100644
--- a/src/SecurityManager.js
+++ b/src/SecurityManager.js
@@ -24,6 +24,7 @@ import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib";
 import { isSecureBackupRequired } from './utils/WellKnownUtils';
 import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog';
 import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog';
+import SettingsStore from "./settings/SettingsStore";
 
 // This stores the secret storage private keys in memory for the JS SDK. This is
 // only meant to act as a cache to avoid prompting the user multiple times
@@ -31,8 +32,13 @@ import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreK
 // single secret storage operation, as it will clear the cached keys once the
 // operation ends.
 let secretStorageKeys = {};
+let secretStorageKeyInfo = {};
 let secretStorageBeingAccessed = false;
 
+let nonInteractive = false;
+
+let dehydrationCache = {};
+
 function isCachingAllowed() {
     return secretStorageBeingAccessed;
 }
@@ -66,6 +72,20 @@ async function confirmToDismiss() {
     return !sure;
 }
 
+function makeInputToKey(keyInfo) {
+    return async ({ passphrase, recoveryKey }) => {
+        if (passphrase) {
+            return deriveKey(
+                passphrase,
+                keyInfo.passphrase.salt,
+                keyInfo.passphrase.iterations,
+            );
+        } else {
+            return decodeRecoveryKey(recoveryKey);
+        }
+    };
+}
+
 async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
     const keyInfoEntries = Object.entries(keyInfos);
     if (keyInfoEntries.length > 1) {
@@ -78,17 +98,18 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
         return [keyId, secretStorageKeys[keyId]];
     }
 
-    const inputToKey = async ({ passphrase, recoveryKey }) => {
-        if (passphrase) {
-            return deriveKey(
-                passphrase,
-                keyInfo.passphrase.salt,
-                keyInfo.passphrase.iterations,
-            );
-        } else {
-            return decodeRecoveryKey(recoveryKey);
+    if (dehydrationCache.key) {
+        if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationCache.key, keyInfo)) {
+            cacheSecretStorageKey(keyId, dehydrationCache.key, keyInfo);
+            return [keyId, dehydrationCache.key];
         }
-    };
+    }
+
+    if (nonInteractive) {
+        throw new Error("Could not unlock non-interactively");
+    }
+
+    const inputToKey = makeInputToKey(keyInfo);
     const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
         AccessSecretStorageDialog,
         /* props= */
@@ -118,14 +139,56 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) {
     const key = await inputToKey(input);
 
     // Save to cache to avoid future prompts in the current session
-    cacheSecretStorageKey(keyId, key);
+    cacheSecretStorageKey(keyId, key, keyInfo);
 
     return [keyId, key];
 }
 
-function cacheSecretStorageKey(keyId, key) {
+export async function getDehydrationKey(keyInfo, checkFunc) {
+    const inputToKey = makeInputToKey(keyInfo);
+    const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "",
+        AccessSecretStorageDialog,
+        /* props= */
+        {
+            keyInfo,
+            checkPrivateKey: async (input) => {
+                const key = await inputToKey(input);
+                try {
+                    checkFunc(key);
+                    return true;
+                } catch (e) {
+                    return false;
+                }
+            },
+        },
+        /* className= */ null,
+        /* isPriorityModal= */ false,
+        /* isStaticModal= */ false,
+        /* options= */ {
+            onBeforeClose: async (reason) => {
+                if (reason === "backgroundClick") {
+                    return confirmToDismiss();
+                }
+                return true;
+            },
+        },
+    );
+    const [input] = await finished;
+    if (!input) {
+        throw new AccessCancelledError();
+    }
+    const key = await inputToKey(input);
+
+    // need to copy the key because rehydration (unpickling) will clobber it
+    dehydrationCache = {key: new Uint8Array(key), keyInfo};
+
+    return key;
+}
+
+function cacheSecretStorageKey(keyId, key, keyInfo) {
     if (isCachingAllowed()) {
         secretStorageKeys[keyId] = key;
+        secretStorageKeyInfo[keyId] = keyInfo;
     }
 }
 
@@ -176,6 +239,7 @@ export const crossSigningCallbacks = {
     getSecretStorageKey,
     cacheSecretStorageKey,
     onSecretRequested,
+    getDehydrationKey,
 };
 
 export async function promptForBackupPassphrase() {
@@ -262,6 +326,18 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
             await cli.bootstrapSecretStorage({
                 getKeyBackupPassphrase: promptForBackupPassphrase,
             });
+
+            const keyId = Object.keys(secretStorageKeys)[0];
+            if (keyId && SettingsStore.getValue("feature_dehydration")) {
+                const dehydrationKeyInfo =
+                      secretStorageKeyInfo[keyId] && secretStorageKeyInfo[keyId].passphrase
+                      ? {passphrase: secretStorageKeyInfo[keyId].passphrase}
+                      : {};
+                console.log("Setting dehydration key");
+                await cli.setDehydrationKey(secretStorageKeys[keyId], dehydrationKeyInfo, "Backup device");
+            } else {
+                console.log("Not setting dehydration key: no SSSS key found");
+            }
         }
 
         // `return await` needed here to ensure `finally` block runs after the
@@ -272,6 +348,57 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
         secretStorageBeingAccessed = false;
         if (!isCachingAllowed()) {
             secretStorageKeys = {};
+            secretStorageKeyInfo = {};
+        }
+    }
+}
+
+// FIXME: this function name is a bit of a mouthful
+export async function tryToUnlockSecretStorageWithDehydrationKey(client) {
+    const key = dehydrationCache.key;
+    let restoringBackup = false;
+    if (key && await client.isSecretStorageReady()) {
+        console.log("Trying to set up cross-signing using dehydration key");
+        secretStorageBeingAccessed = true;
+        nonInteractive = true;
+        try {
+            await client.checkOwnCrossSigningTrust();
+
+            // we also need to set a new dehydrated device to replace the
+            // device we rehydrated
+            const dehydrationKeyInfo =
+                  dehydrationCache.keyInfo && dehydrationCache.keyInfo.passphrase
+                  ? {passphrase: dehydrationCache.keyInfo.passphrase}
+                  : {};
+            await client.setDehydrationKey(key, dehydrationKeyInfo, "Backup device");
+
+            // and restore from backup
+            const backupInfo = await client.getKeyBackupVersion();
+            if (backupInfo) {
+                restoringBackup = true;
+                // don't await, because this can take a long time
+                client.restoreKeyBackupWithSecretStorage(backupInfo)
+                    .finally(() => {
+                        secretStorageBeingAccessed = false;
+                        nonInteractive = false;
+                        if (!isCachingAllowed()) {
+                            secretStorageKeys = {};
+                            secretStorageKeyInfo = {};
+                        }
+                    });
+            }
+        } finally {
+            dehydrationCache = {};
+            // the secret storage cache is needed for restoring from backup, so
+            // don't clear it yet if we're restoring from backup
+            if (!restoringBackup) {
+                secretStorageBeingAccessed = false;
+                nonInteractive = false;
+                if (!isCachingAllowed()) {
+                    secretStorageKeys = {};
+                    secretStorageKeyInfo = {};
+                }
+            }
         }
     }
 }
diff --git a/src/TextForEvent.js b/src/TextForEvent.js
index f9cda23650..34d40bf1fd 100644
--- a/src/TextForEvent.js
+++ b/src/TextForEvent.js
@@ -18,7 +18,6 @@ import { _t } from './languageHandler';
 import * as Roles from './Roles';
 import {isValid3pidInvite} from "./RoomInvite";
 import SettingsStore from "./settings/SettingsStore";
-import {WidgetType} from "./widgets/WidgetType";
 import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList";
 
 function textForMemberEvent(ev) {
@@ -464,10 +463,6 @@ function textForWidgetEvent(event) {
     const {name: prevName, type: prevType, url: prevUrl} = event.getPrevContent();
     const {name, type, url} = event.getContent() || {};
 
-    if (WidgetType.JITSI.matches(type) || WidgetType.JITSI.matches(prevType)) {
-        return textForJitsiWidgetEvent(event, senderName, url, prevUrl);
-    }
-
     let widgetName = name || prevName || type || prevType || '';
     // Apply sentence case to widget name
     if (widgetName && widgetName.length > 0) {
@@ -493,24 +488,6 @@ function textForWidgetEvent(event) {
     }
 }
 
-function textForJitsiWidgetEvent(event, senderName, url, prevUrl) {
-    if (url) {
-        if (prevUrl) {
-            return _t('Group call modified by %(senderName)s', {
-                senderName,
-            });
-        } else {
-            return _t('Group call started by %(senderName)s', {
-                senderName,
-            });
-        }
-    } else {
-        return _t('Group call ended by %(senderName)s', {
-            senderName,
-        });
-    }
-}
-
 function textForMjolnirEvent(event) {
     const senderName = event.getSender();
     const {entity: prevEntity} = event.getPrevContent();
diff --git a/src/ToWidgetPostMessageApi.js b/src/ToWidgetPostMessageApi.js
deleted file mode 100644
index 00309d252c..0000000000
--- a/src/ToWidgetPostMessageApi.js
+++ /dev/null
@@ -1,84 +0,0 @@
-/*
-Copyright 2018 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.
-*/
-
-// const OUTBOUND_API_NAME = 'toWidget';
-
-// Initiate requests using the "toWidget" postMessage API and handle responses
-// NOTE: ToWidgetPostMessageApi only handles message events with a data payload with a
-// response field
-export default class ToWidgetPostMessageApi {
-    constructor(timeoutMs) {
-        this._timeoutMs = timeoutMs || 5000; // default to 5s timer
-        this._counter = 0;
-        this._requestMap = {
-            // $ID: {resolve, reject}
-        };
-        this.start = this.start.bind(this);
-        this.stop = this.stop.bind(this);
-        this.onPostMessage = this.onPostMessage.bind(this);
-    }
-
-    start() {
-        window.addEventListener('message', this.onPostMessage);
-    }
-
-    stop() {
-        window.removeEventListener('message', this.onPostMessage);
-    }
-
-    onPostMessage(ev) {
-        // THIS IS ALL UNSAFE EXECUTION.
-        // We do not verify who the sender of `ev` is!
-        const payload = ev.data;
-        // NOTE: Workaround for running in a mobile WebView where a
-        // postMessage immediately triggers this callback even though it is
-        // not the response.
-        if (payload.response === undefined) {
-            return;
-        }
-        const promise = this._requestMap[payload.requestId];
-        if (!promise) {
-            return;
-        }
-        delete this._requestMap[payload.requestId];
-        promise.resolve(payload);
-    }
-
-    // Initiate outbound requests (toWidget)
-    exec(action, targetWindow, targetOrigin) {
-        targetWindow = targetWindow || window.parent; // default to parent window
-        targetOrigin = targetOrigin || "*";
-        this._counter += 1;
-        action.requestId = Date.now() + "-" + Math.random().toString(36) + "-" + this._counter;
-
-        return new Promise((resolve, reject) => {
-            this._requestMap[action.requestId] = {resolve, reject};
-            targetWindow.postMessage(action, targetOrigin);
-
-            if (this._timeoutMs > 0) {
-                setTimeout(() => {
-                    if (!this._requestMap[action.requestId]) {
-                        return;
-                    }
-                    console.error("postMessage request timed out. Sent object: " + JSON.stringify(action),
-                        this._requestMap);
-                    this._requestMap[action.requestId].reject(new Error("Timed out"));
-                    delete this._requestMap[action.requestId];
-                }, this._timeoutMs);
-            }
-        });
-    }
-}
diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js
deleted file mode 100644
index c68e926ac1..0000000000
--- a/src/WidgetMessaging.js
+++ /dev/null
@@ -1,212 +0,0 @@
-/*
-Copyright 2017 New Vector Ltd
-Copyright 2019 Travis Ralston
-
-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.
-*/
-
-/*
-* See - https://docs.google.com/document/d/1uPF7XWY_dXTKVKV7jZQ2KmsI19wn9-kFRgQ1tFQP7wQ/edit?usp=sharing for
-* spec. details / documentation.
-*/
-
-import FromWidgetPostMessageApi from './FromWidgetPostMessageApi';
-import ToWidgetPostMessageApi from './ToWidgetPostMessageApi';
-import Modal from "./Modal";
-import {MatrixClientPeg} from "./MatrixClientPeg";
-import SettingsStore from "./settings/SettingsStore";
-import WidgetOpenIDPermissionsDialog from "./components/views/dialogs/WidgetOpenIDPermissionsDialog";
-import WidgetUtils from "./utils/WidgetUtils";
-import {KnownWidgetActions} from "./widgets/WidgetApi";
-
-if (!global.mxFromWidgetMessaging) {
-    global.mxFromWidgetMessaging = new FromWidgetPostMessageApi();
-    global.mxFromWidgetMessaging.start();
-}
-if (!global.mxToWidgetMessaging) {
-    global.mxToWidgetMessaging = new ToWidgetPostMessageApi();
-    global.mxToWidgetMessaging.start();
-}
-
-const OUTBOUND_API_NAME = 'toWidget';
-
-export default class WidgetMessaging {
-    /**
-     * @param {string} widgetId The widget's ID
-     * @param {string} wurl The raw URL of the widget as in the event (the 'wURL')
-     * @param {string} renderedUrl The url used in the widget's iframe (either similar to the wURL
-     *     or a different URL of the clients choosing if it is using its own impl).
-     * @param {bool} isUserWidget If true, the widget is a user widget, otherwise it's a room widget
-     * @param {object} target Where widget messages should be sent (eg. the iframe object)
-     */
-    constructor(widgetId, wurl, renderedUrl, isUserWidget, target) {
-        this.widgetId = widgetId;
-        this.wurl = wurl;
-        this.renderedUrl = renderedUrl;
-        this.isUserWidget = isUserWidget;
-        this.target = target;
-        this.fromWidget = global.mxFromWidgetMessaging;
-        this.toWidget = global.mxToWidgetMessaging;
-        this._onOpenIdRequest = this._onOpenIdRequest.bind(this);
-        this.start();
-    }
-
-    messageToWidget(action) {
-        action.widgetId = this.widgetId; // Required to be sent for all outbound requests
-
-        return this.toWidget.exec(action, this.target).then((data) => {
-            // Check for errors and reject if found
-            if (data.response === undefined) { // null is valid
-                throw new Error("Missing 'response' field");
-            }
-            if (data.response && data.response.error) {
-                const err = data.response.error;
-                const msg = String(err.message ? err.message : "An error was returned");
-                if (err._error) {
-                    console.error(err._error);
-                }
-                // Potential XSS attack if 'msg' is not appropriately sanitized,
-                // as it is untrusted input by our parent window (which we assume is Element).
-                // We can't aggressively sanitize [A-z0-9] since it might be a translation.
-                throw new Error(msg);
-            }
-            // Return the response field for the request
-            return data.response;
-        });
-    }
-
-    /**
-     * Tells the widget that the client is ready to handle further widget requests.
-     * @returns {Promise<*>} Resolves after the widget has acknowledged the ready message.
-     */
-    flagReadyToContinue() {
-        return this.messageToWidget({
-            api: OUTBOUND_API_NAME,
-            action: KnownWidgetActions.ClientReady,
-        });
-    }
-
-    /**
-     * Tells the widget that it should terminate now.
-     * @returns {Promise<*>} Resolves when widget has acknowledged the message.
-     */
-    terminate() {
-        return this.messageToWidget({
-            api: OUTBOUND_API_NAME,
-            action: KnownWidgetActions.Terminate,
-        });
-    }
-
-    /**
-     * Request a screenshot from a widget
-     * @return {Promise} To be resolved with screenshot data when it has been generated
-     */
-    getScreenshot() {
-        console.log('Requesting screenshot for', this.widgetId);
-        return this.messageToWidget({
-                api: OUTBOUND_API_NAME,
-                action: "screenshot",
-            })
-            .catch((error) => new Error("Failed to get screenshot: " + error.message))
-            .then((response) => response.screenshot);
-    }
-
-    /**
-     * Request capabilities required by the widget
-     * @return {Promise} To be resolved with an array of requested widget capabilities
-     */
-    getCapabilities() {
-        console.log('Requesting capabilities for', this.widgetId);
-        return this.messageToWidget({
-                api: OUTBOUND_API_NAME,
-                action: "capabilities",
-            }).then((response) => {
-                console.log('Got capabilities for', this.widgetId, response.capabilities);
-                return response.capabilities;
-            });
-    }
-
-    sendVisibility(visible) {
-        return this.messageToWidget({
-            api: OUTBOUND_API_NAME,
-            action: "visibility",
-            visible,
-        })
-        .catch((error) => {
-            console.error("Failed to send visibility: ", error);
-        });
-    }
-
-    start() {
-        this.fromWidget.addEndpoint(this.widgetId, this.renderedUrl);
-        this.fromWidget.addListener("get_openid", this._onOpenIdRequest);
-    }
-
-    stop() {
-        this.fromWidget.removeEndpoint(this.widgetId, this.renderedUrl);
-        this.fromWidget.removeListener("get_openid", this._onOpenIdRequest);
-    }
-
-    async _onOpenIdRequest(ev, rawEv) {
-        if (ev.widgetId !== this.widgetId) return; // not interesting
-
-        const widgetSecurityKey = WidgetUtils.getWidgetSecurityKey(this.widgetId, this.wurl, this.isUserWidget);
-
-        const settings = SettingsStore.getValue("widgetOpenIDPermissions");
-        if (settings.deny && settings.deny.includes(widgetSecurityKey)) {
-            this.fromWidget.sendResponse(rawEv, {state: "blocked"});
-            return;
-        }
-        if (settings.allow && settings.allow.includes(widgetSecurityKey)) {
-            const responseBody = {state: "allowed"};
-            const credentials = await MatrixClientPeg.get().getOpenIdToken();
-            Object.assign(responseBody, credentials);
-            this.fromWidget.sendResponse(rawEv, responseBody);
-            return;
-        }
-
-        // Confirm that we received the request
-        this.fromWidget.sendResponse(rawEv, {state: "request"});
-
-        // Actually ask for permission to send the user's data
-        Modal.createTrackedDialog("OpenID widget permissions", '',
-            WidgetOpenIDPermissionsDialog, {
-                widgetUrl: this.wurl,
-                widgetId: this.widgetId,
-                isUserWidget: this.isUserWidget,
-
-                onFinished: async (confirm) => {
-                    const responseBody = {
-                        // Legacy (early draft) fields
-                        success: confirm,
-
-                        // New style MSC1960 fields
-                        state: confirm ? "allowed" : "blocked",
-                        original_request_id: ev.requestId, // eslint-disable-line camelcase
-                    };
-                    if (confirm) {
-                        const credentials = await MatrixClientPeg.get().getOpenIdToken();
-                        Object.assign(responseBody, credentials);
-                    }
-                    this.messageToWidget({
-                        api: OUTBOUND_API_NAME,
-                        action: "openid_credentials",
-                        data: responseBody,
-                    }).catch((error) => {
-                        console.error("Failed to send OpenID credentials: ", error);
-                    });
-                },
-            },
-        );
-    }
-}
diff --git a/src/WidgetMessagingEndpoint.js b/src/WidgetMessagingEndpoint.js
deleted file mode 100644
index 9114e12137..0000000000
--- a/src/WidgetMessagingEndpoint.js
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
-Copyright 2018 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.
-*/
-
-
-/**
- * Represents mapping of widget instance to URLs for trusted postMessage communication.
- */
-export default class WidgetMessageEndpoint {
-    /**
-     * Mapping of widget instance to URL for trusted postMessage communication.
-     * @param  {string} widgetId    Unique widget identifier
-     * @param  {string} endpointUrl Widget wurl origin.
-     */
-    constructor(widgetId, endpointUrl) {
-        if (!widgetId) {
-            throw new Error("No widgetId specified in widgetMessageEndpoint constructor");
-        }
-        if (!endpointUrl) {
-            throw new Error("No endpoint specified in widgetMessageEndpoint constructor");
-        }
-        this.widgetId = widgetId;
-        this.endpointUrl = endpointUrl;
-    }
-}
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx
index a638ad6de1..19418df414 100644
--- a/src/components/structures/MatrixChat.tsx
+++ b/src/components/structures/MatrixChat.tsx
@@ -1019,7 +1019,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
         const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId();
         if (communityId) {
             // double check the user will have permission to associate this room with the community
-            if (CommunityPrototypeStore.instance.isAdminOf(communityId)) {
+            if (!CommunityPrototypeStore.instance.isAdminOf(communityId)) {
                 Modal.createTrackedDialog('Pre-failure to create room', '', ErrorDialog, {
                     title: _t("Cannot create rooms in this community"),
                     description: _t("You do not have permission to create rooms in this community."),
diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js
index 6c6d8700a5..021cdb438d 100644
--- a/src/components/structures/RightPanel.js
+++ b/src/components/structures/RightPanel.js
@@ -1,9 +1,6 @@
 /*
-Copyright 2015, 2016 OpenMarket Ltd
-Copyright 2017 Vector Creations Ltd
-Copyright 2017, 2018 New Vector Ltd
 Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
-Copyright 2019 The Matrix.org Foundation C.I.C.
+Copyright 2015 - 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -162,7 +159,7 @@ export default class RightPanel extends React.Component {
     }
 
     onRoomStateMember(ev, state, member) {
-        if (member.roomId !== this.props.room.roomId) {
+        if (!this.props.room || member.roomId !== this.props.room.roomId) {
             return;
         }
         // redraw the badge on the membership list
@@ -202,13 +199,19 @@ export default class RightPanel extends React.Component {
             dis.dispatch({
                 action: "view_home_page",
             });
+        } else if (this.state.phase === RightPanelPhases.EncryptionPanel &&
+            this.state.verificationRequest && this.state.verificationRequest.pending
+        ) {
+            // When the user clicks close on the encryption panel cancel the pending request first if any
+            this.state.verificationRequest.cancel();
         } else {
             // Otherwise we have got our user from RoomViewStore which means we're being shown
             // within a room/group, so go back to the member panel if we were in the encryption panel,
             // or the member list if we were in the member panel... phew.
+            const isEncryptionPhase = this.state.phase === RightPanelPhases.EncryptionPanel;
             dis.dispatch({
                 action: Action.ViewUser,
-                member: this.state.phase === RightPanelPhases.EncryptionPanel ? this.state.member : null,
+                member: isEncryptionPhase ? this.state.member : null,
             });
         }
     };
diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js
index 55c6527f06..df580e8de0 100644
--- a/src/components/structures/RoomDirectory.js
+++ b/src/components/structures/RoomDirectory.js
@@ -35,7 +35,7 @@ import GroupStore from "../../stores/GroupStore";
 import FlairStore from "../../stores/FlairStore";
 
 const MAX_NAME_LENGTH = 80;
-const MAX_TOPIC_LENGTH = 160;
+const MAX_TOPIC_LENGTH = 800;
 
 function track(action) {
     Analytics.trackEvent('RoomDirectory', action);
@@ -497,6 +497,9 @@ export default class RoomDirectory extends React.Component {
         }
 
         let topic = room.topic || '';
+        // Additional truncation based on line numbers is done via CSS,
+        // but to ensure that the DOM is not polluted with a huge string
+        // we give it a hard limit before rendering.
         if (topic.length > MAX_TOPIC_LENGTH) {
             topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
         }
diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx
index 4927c6b712..3aedaa5219 100644
--- a/src/components/structures/RoomView.tsx
+++ b/src/components/structures/RoomView.tsx
@@ -1820,7 +1820,6 @@ export default class RoomView extends React.Component<IProps, IState> {
         let aux = null;
         let previewBar;
         let hideCancel = false;
-        let forceHideRightPanel = false;
         if (this.state.forwardingEvent) {
             aux = <ForwardMessage onCancelClick={this.onCancelClick} />;
         } else if (this.state.searching) {
@@ -1865,8 +1864,6 @@ export default class RoomView extends React.Component<IProps, IState> {
                         { previewBar }
                     </div>
                 );
-            } else {
-                forceHideRightPanel = true;
             }
         } else if (hiddenHighlightCount > 0) {
             aux = (
@@ -2069,7 +2066,7 @@ export default class RoomView extends React.Component<IProps, IState> {
             "mx_fadable_faded": this.props.disabled,
         });
 
-        const showRightPanel = !forceHideRightPanel && this.state.room && this.state.showRightPanel;
+        const showRightPanel = this.state.room && this.state.showRightPanel;
         const rightPanel = showRightPanel
             ? <RightPanel room={this.state.room} resizeNotifier={this.props.resizeNotifier} />
             : null;
diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx
index 8fd51d3715..60b043016b 100644
--- a/src/components/views/avatars/MemberAvatar.tsx
+++ b/src/components/views/avatars/MemberAvatar.tsx
@@ -23,7 +23,7 @@ import {Action} from "../../../dispatcher/actions";
 import {MatrixClientPeg} from "../../../MatrixClientPeg";
 import BaseAvatar from "./BaseAvatar";
 
-interface IProps {
+interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> {
     member: RoomMember;
     fallbackUserId?: string;
     width: number;
diff --git a/src/components/views/dialogs/SetPasswordDialog.js b/src/components/views/dialogs/SetPasswordDialog.js
index 3649190ac9..f2d5a96b4c 100644
--- a/src/components/views/dialogs/SetPasswordDialog.js
+++ b/src/components/views/dialogs/SetPasswordDialog.js
@@ -117,7 +117,9 @@ export default class SetPasswordDialog extends React.Component {
                         autoFocusNewPasswordInput={true}
                         shouldAskForEmail={true}
                         onError={this._onPasswordChangeError}
-                        onFinished={this._onPasswordChanged} />
+                        onFinished={this._onPasswordChanged}
+                        buttonLabel={_t("Set Password")}
+                    />
                     <div className="error">
                         { this.state.error }
                     </div>
diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js
index 6aaeab060f..3405d4ff16 100644
--- a/src/components/views/elements/AppTile.js
+++ b/src/components/views/elements/AppTile.js
@@ -18,11 +18,9 @@ limitations under the License.
 */
 
 import url from 'url';
-import qs from 'qs';
 import React, {createRef} from 'react';
 import PropTypes from 'prop-types';
 import {MatrixClientPeg} from '../../../MatrixClientPeg';
-import WidgetMessaging from '../../../WidgetMessaging';
 import AccessibleButton from './AccessibleButton';
 import Modal from '../../../Modal';
 import { _t } from '../../../languageHandler';
@@ -34,37 +32,16 @@ import WidgetUtils from '../../../utils/WidgetUtils';
 import dis from '../../../dispatcher/dispatcher';
 import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
 import classNames from 'classnames';
-import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
 import SettingsStore from "../../../settings/SettingsStore";
 import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
 import PersistedElement from "./PersistedElement";
 import {WidgetType} from "../../../widgets/WidgetType";
-import {Capability} from "../../../widgets/WidgetApi";
-import {sleep} from "../../../utils/promise";
 import {SettingLevel} from "../../../settings/SettingLevel";
 import WidgetStore from "../../../stores/WidgetStore";
 import {Action} from "../../../dispatcher/actions";
-
-const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
-const ENABLE_REACT_PERF = false;
-
-/**
- * Does template substitution on a URL (or any string). Variables will be
- * passed through encodeURIComponent.
- * @param {string} uriTemplate The path with template variables e.g. '/foo/$bar'.
- * @param {Object} variables The key/value pairs to replace the template
- * variables with. E.g. { '$bar': 'baz' }.
- * @return {string} The result of replacing all template variables e.g. '/foo/baz'.
- */
-function uriFromTemplate(uriTemplate, variables) {
-    let out = uriTemplate;
-    for (const [key, val] of Object.entries(variables)) {
-        out = out.replace(
-            '$' + key, encodeURIComponent(val),
-        );
-    }
-    return out;
-}
+import {StopGapWidget} from "../../../stores/widgets/StopGapWidget";
+import {ElementWidgetActions} from "../../../stores/widgets/ElementWidgetActions";
+import {MatrixCapabilities} from "matrix-widget-api";
 
 export default class AppTile extends React.Component {
     constructor(props) {
@@ -72,11 +49,13 @@ export default class AppTile extends React.Component {
 
         // The key used for PersistedElement
         this._persistKey = 'widget_' + this.props.app.id;
+        this._sgWidget = new StopGapWidget(this.props);
+        this._sgWidget.on("ready", this._onWidgetReady);
+        this.iframe = null; // ref to the iframe (callback style)
 
         this.state = this._getNewState(props);
 
         this._onAction = this._onAction.bind(this);
-        this._onLoaded = this._onLoaded.bind(this);
         this._onEditClick = this._onEditClick.bind(this);
         this._onDeleteClick = this._onDeleteClick.bind(this);
         this._onRevokeClicked = this._onRevokeClicked.bind(this);
@@ -89,7 +68,6 @@ export default class AppTile extends React.Component {
         this._onReloadWidgetClick = this._onReloadWidgetClick.bind(this);
 
         this._contextMenuButton = createRef();
-        this._appFrame = createRef();
         this._menu_bar = createRef();
     }
 
@@ -108,12 +86,10 @@ export default class AppTile extends React.Component {
             return !!currentlyAllowedWidgets[newProps.app.eventId];
         };
 
-        const PersistedElement = sdk.getComponent("elements.PersistedElement");
         return {
             initialising: true, // True while we are mangling the widget URL
             // True while the iframe content is loading
             loading: this.props.waitForIframeLoad && !PersistedElement.isMounted(this._persistKey),
-            widgetUrl: this._addWurlParams(newProps.app.url),
             // Assume that widget has permission to load if we are the user who
             // added it to the room, or if explicitly granted by the user
             hasPermissionToLoad: newProps.userId === newProps.creatorUserId || hasPermissionToLoad(),
@@ -124,43 +100,6 @@ export default class AppTile extends React.Component {
         };
     }
 
-    /**
-     * Does the widget support a given capability
-     * @param  {string}  capability Capability to check for
-     * @return {Boolean}            True if capability supported
-     */
-    _hasCapability(capability) {
-        return ActiveWidgetStore.widgetHasCapability(this.props.app.id, capability);
-    }
-
-    /**
-     * Add widget instance specific parameters to pass in wUrl
-     * Properties passed to widget instance:
-     *  - widgetId
-     *  - origin / parent URL
-     * @param {string} urlString Url string to modify
-     * @return {string}
-     * Url string with parameters appended.
-     * If url can not be parsed, it is returned unmodified.
-     */
-    _addWurlParams(urlString) {
-        try {
-            const parsed = new URL(urlString);
-
-            // TODO: Replace these with proper widget params
-            // See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833
-            parsed.searchParams.set('widgetId', this.props.app.id);
-            parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]);
-
-            // Replace the encoded dollar signs back to dollar signs. They have no special meaning
-            // in HTTP, but URL parsers encode them anyways.
-            return parsed.toString().replace(/%24/g, '$');
-        } catch (e) {
-            console.error("Failed to add widget URL params:", e);
-            return urlString;
-        }
-    }
-
     isMixedContent() {
         const parentContentProtocol = window.location.protocol;
         const u = url.parse(this.props.app.url);
@@ -176,7 +115,7 @@ export default class AppTile extends React.Component {
     componentDidMount() {
         // Only fetch IM token on mount if we're showing and have permission to load
         if (this.props.show && this.state.hasPermissionToLoad) {
-            this.setScalarToken();
+            this._startWidget();
         }
 
         // Widget action listeners
@@ -190,93 +129,44 @@ export default class AppTile extends React.Component {
         // if it's not remaining on screen, get rid of the PersistedElement container
         if (!ActiveWidgetStore.getWidgetPersistence(this.props.app.id)) {
             ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
-            const PersistedElement = sdk.getComponent("elements.PersistedElement");
             PersistedElement.destroyElement(this._persistKey);
         }
+
+        if (this._sgWidget) {
+            this._sgWidget.stop();
+        }
     }
 
-    // TODO: Generify the name of this function. It's not just scalar tokens.
-    /**
-     * Adds a scalar token to the widget URL, if required
-     * Component initialisation is only complete when this function has resolved
-     */
-    setScalarToken() {
-        if (!WidgetUtils.isScalarUrl(this.props.app.url)) {
-            console.warn('Widget does not match integration manager, refusing to set auth token', url);
-            this.setState({
-                error: null,
-                widgetUrl: this._addWurlParams(this.props.app.url),
-                initialising: false,
-            });
-            return;
+    _resetWidget(newProps) {
+        if (this._sgWidget) {
+            this._sgWidget.stop();
         }
+        this._sgWidget = new StopGapWidget(newProps);
+        this._sgWidget.on("ready", this._onWidgetReady);
+        this._startWidget();
+    }
 
-        const managers = IntegrationManagers.sharedInstance();
-        if (!managers.hasManager()) {
-            console.warn("No integration manager - not setting scalar token", url);
-            this.setState({
-                error: null,
-                widgetUrl: this._addWurlParams(this.props.app.url),
-                initialising: false,
-            });
-            return;
-        }
-
-        // TODO: Pick the right manager for the widget
-
-        const defaultManager = managers.getPrimaryManager();
-        if (!WidgetUtils.isScalarUrl(defaultManager.apiUrl)) {
-            console.warn('Unknown integration manager, refusing to set auth token', url);
-            this.setState({
-                error: null,
-                widgetUrl: this._addWurlParams(this.props.app.url),
-                initialising: false,
-            });
-            return;
-        }
-
-        // Fetch the token before loading the iframe as we need it to mangle the URL
-        if (!this._scalarClient) {
-            this._scalarClient = defaultManager.getScalarClient();
-        }
-        this._scalarClient.getScalarToken().then((token) => {
-            // Append scalar_token as a query param if not already present
-            this._scalarClient.scalarToken = token;
-            const u = url.parse(this._addWurlParams(this.props.app.url));
-            const params = qs.parse(u.query);
-            if (!params.scalar_token) {
-                params.scalar_token = encodeURIComponent(token);
-                // u.search must be set to undefined, so that u.format() uses query parameters - https://nodejs.org/docs/latest/api/url.html#url_url_format_url_options
-                u.search = undefined;
-                u.query = params;
-            }
-
-            this.setState({
-                error: null,
-                widgetUrl: u.format(),
-                initialising: false,
-            });
-
-            // Fetch page title from remote content if not already set
-            if (!this.state.widgetPageTitle && params.url) {
-                this._fetchWidgetTitle(params.url);
-            }
-        }, (err) => {
-            console.error("Failed to get scalar_token", err);
-            this.setState({
-                error: err.message,
-                initialising: false,
-            });
+    _startWidget() {
+        this._sgWidget.prepare().then(() => {
+            this.setState({initialising: false});
         });
     }
 
+    _iframeRefChange = (ref) => {
+        this.iframe = ref;
+        if (ref) {
+            this._sgWidget.start(ref);
+        } else {
+            this._resetWidget(this.props);
+        }
+    };
+
     // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
     UNSAFE_componentWillReceiveProps(nextProps) { // eslint-disable-line camelcase
         if (nextProps.app.url !== this.props.app.url) {
             this._getNewState(nextProps);
-            // Fetch IM token for new URL if we're showing and have permission to load
             if (this.props.show && this.state.hasPermissionToLoad) {
-                this.setScalarToken();
+                this._resetWidget(nextProps);
             }
         }
 
@@ -287,9 +177,9 @@ export default class AppTile extends React.Component {
                     loading: true,
                 });
             }
-            // Fetch IM token now that we're showing if we already have permission to load
+            // Start the widget now that we're showing if we already have permission to load
             if (this.state.hasPermissionToLoad) {
-                this.setScalarToken();
+                this._startWidget();
             }
         }
 
@@ -319,7 +209,14 @@ export default class AppTile extends React.Component {
     }
 
     _onSnapshotClick() {
-        WidgetUtils.snapshotWidget(this.props.app);
+        this._sgWidget.widgetApi.takeScreenshot().then(data => {
+            dis.dispatch({
+                action: 'picture_snapshot',
+                file: data.screenshot,
+            });
+        }).catch(err => {
+            console.error("Failed to take screenshot: ", err);
+        });
     }
 
     /**
@@ -327,35 +224,24 @@ export default class AppTile extends React.Component {
      * @private
      * @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed.
      */
-    _endWidgetActions() {
-        let terminationPromise;
-
-        if (this._hasCapability(Capability.ReceiveTerminate)) {
-            // Wait for widget to terminate within a timeout
-            const timeout = 2000;
-            const messaging = ActiveWidgetStore.getWidgetMessaging(this.props.app.id);
-            terminationPromise = Promise.race([messaging.terminate(), sleep(timeout)]);
-        } else {
-            terminationPromise = Promise.resolve();
+    async _endWidgetActions() { // widget migration dev note: async to maintain signature
+        // HACK: This is a really dirty way to ensure that Jitsi cleans up
+        // its hold on the webcam. Without this, the widget holds a media
+        // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351
+        if (this.iframe) {
+            // In practice we could just do `+= ''` to trick the browser
+            // into thinking the URL changed, however I can foresee this
+            // being optimized out by a browser. Instead, we'll just point
+            // the iframe at a page that is reasonably safe to use in the
+            // event the iframe doesn't wink away.
+            // This is relative to where the Element instance is located.
+            this.iframe.src = 'about:blank';
         }
 
-        return terminationPromise.finally(() => {
-            // HACK: This is a really dirty way to ensure that Jitsi cleans up
-            // its hold on the webcam. Without this, the widget holds a media
-            // stream open, even after death. See https://github.com/vector-im/element-web/issues/7351
-            if (this._appFrame.current) {
-                // In practice we could just do `+= ''` to trick the browser
-                // into thinking the URL changed, however I can foresee this
-                // being optimized out by a browser. Instead, we'll just point
-                // the iframe at a page that is reasonably safe to use in the
-                // event the iframe doesn't wink away.
-                // This is relative to where the Element instance is located.
-                this._appFrame.current.src = 'about:blank';
-            }
+        // Delete the widget from the persisted store for good measure.
+        PersistedElement.destroyElement(this._persistKey);
 
-            // Delete the widget from the persisted store for good measure.
-            PersistedElement.destroyElement(this._persistKey);
-        });
+        this._sgWidget.stop();
     }
 
     /* If user has permission to modify widgets, delete the widget,
@@ -409,73 +295,18 @@ export default class AppTile extends React.Component {
         this._revokeWidgetPermission();
     }
 
-    /**
-     * Called when widget iframe has finished loading
-     */
-    _onLoaded() {
-        // Destroy the old widget messaging before starting it back up again. Some widgets
-        // have startup routines that run when they are loaded, so we just need to reinitialize
-        // the messaging for them.
-        ActiveWidgetStore.delWidgetMessaging(this.props.app.id);
-        this._setupWidgetMessaging();
-
-        ActiveWidgetStore.setRoomId(this.props.app.id, this.props.room.roomId);
+    _onWidgetReady = () => {
         this.setState({loading: false});
-    }
-
-    _setupWidgetMessaging() {
-        // FIXME: There's probably no reason to do this here: it should probably be done entirely
-        // in ActiveWidgetStore.
-        const widgetMessaging = new WidgetMessaging(
-            this.props.app.id,
-            this.props.app.url,
-            this._getRenderedUrl(),
-            this.props.userWidget,
-            this._appFrame.current.contentWindow,
-        );
-        ActiveWidgetStore.setWidgetMessaging(this.props.app.id, widgetMessaging);
-        widgetMessaging.getCapabilities().then((requestedCapabilities) => {
-            console.log(`Widget ${this.props.app.id} requested capabilities: ` + requestedCapabilities);
-            requestedCapabilities = requestedCapabilities || [];
-
-            // Allow whitelisted capabilities
-            let requestedWhitelistCapabilies = [];
-
-            if (this.props.whitelistCapabilities && this.props.whitelistCapabilities.length > 0) {
-                requestedWhitelistCapabilies = requestedCapabilities.filter(function(e) {
-                    return this.indexOf(e)>=0;
-                }, this.props.whitelistCapabilities);
-
-                if (requestedWhitelistCapabilies.length > 0 ) {
-                    console.log(`Widget ${this.props.app.id} allowing requested, whitelisted properties: ` +
-                        requestedWhitelistCapabilies,
-                    );
-                }
-            }
-
-            // TODO -- Add UI to warn about and optionally allow requested capabilities
-
-            ActiveWidgetStore.setWidgetCapabilities(this.props.app.id, requestedWhitelistCapabilies);
-
-            if (this.props.onCapabilityRequest) {
-                this.props.onCapabilityRequest(requestedCapabilities);
-            }
-
-            // We only tell Jitsi widgets that we're ready because they're realistically the only ones
-            // using this custom extension to the widget API.
-            if (WidgetType.JITSI.matches(this.props.app.type)) {
-                widgetMessaging.flagReadyToContinue();
-            }
-        }).catch((err) => {
-            console.log(`Failed to get capabilities for widget type ${this.props.app.type}`, this.props.app.id, err);
-        });
-    }
+        if (WidgetType.JITSI.matches(this.props.app.type)) {
+            this._sgWidget.widgetApi.transport.send(ElementWidgetActions.ClientReady, {});
+        }
+    };
 
     _onAction(payload) {
         if (payload.widgetId === this.props.app.id) {
             switch (payload.action) {
                 case 'm.sticker':
-                    if (this._hasCapability('m.sticker')) {
+                    if (this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.StickerSending)) {
                         dis.dispatch({action: 'post_sticker_message', data: payload.data});
                     } else {
                         console.warn('Ignoring sticker message. Invalid capability');
@@ -493,20 +324,6 @@ export default class AppTile extends React.Component {
         }
     }
 
-    /**
-     * Set remote content title on AppTile
-     * @param {string} url Url to check for title
-     */
-    _fetchWidgetTitle(url) {
-        this._scalarClient.getScalarPageTitle(url).then((widgetPageTitle) => {
-            if (widgetPageTitle) {
-                this.setState({widgetPageTitle: widgetPageTitle});
-            }
-        }, (err) =>{
-            console.error("Failed to get page title", err);
-        });
-    }
-
     _grantWidgetPermission() {
         const roomId = this.props.room.roomId;
         console.info("Granting permission for widget to load: " + this.props.app.eventId);
@@ -516,7 +333,7 @@ export default class AppTile extends React.Component {
             this.setState({hasPermissionToLoad: true});
 
             // Fetch a token for the integration manager, now that we're allowed to
-            this.setScalarToken();
+            this._startWidget();
         }).catch(err => {
             console.error(err);
             // We don't really need to do anything about this - the user will just hit the button again.
@@ -535,6 +352,7 @@ export default class AppTile extends React.Component {
             ActiveWidgetStore.destroyPersistentWidget(this.props.app.id);
             const PersistedElement = sdk.getComponent("elements.PersistedElement");
             PersistedElement.destroyElement(this._persistKey);
+            this._sgWidget.stop();
         }).catch(err => {
             console.error(err);
             // We don't really need to do anything about this - the user will just hit the button again.
@@ -572,40 +390,6 @@ export default class AppTile extends React.Component {
         }
     }
 
-    /**
-     * Replace the widget template variables in a url with their values
-     *
-     * @param {string} u The URL with template variables
-     * @param {string} widgetType The widget's type
-     *
-     * @returns {string} url with temlate variables replaced
-     */
-    _templatedUrl(u, widgetType: string) {
-        const targetData = {};
-        if (WidgetType.JITSI.matches(widgetType)) {
-            targetData['domain'] = 'jitsi.riot.im'; // v1 jitsi widgets have this hardcoded
-        }
-        const myUserId = MatrixClientPeg.get().credentials.userId;
-        const myUser = MatrixClientPeg.get().getUser(myUserId);
-        const vars = Object.assign(targetData, this.props.app.data, {
-            'matrix_user_id': myUserId,
-            'matrix_room_id': this.props.room.roomId,
-            'matrix_display_name': myUser ? myUser.displayName : myUserId,
-            'matrix_avatar_url': myUser ? MatrixClientPeg.get().mxcUrlToHttp(myUser.avatarUrl) : '',
-
-            // TODO: Namespace themes through some standard
-            'theme': SettingsStore.getValue("theme"),
-        });
-
-        if (vars.conferenceId === undefined) {
-            // we'll need to parse the conference ID out of the URL for v1 Jitsi widgets
-            const parsedUrl = new URL(this.props.app.url);
-            vars.conferenceId = parsedUrl.searchParams.get("confId");
-        }
-
-        return uriFromTemplate(u, vars);
-    }
-
     /**
      * Whether we're using a local version of the widget rather than loading the
      * actual widget URL
@@ -615,67 +399,11 @@ export default class AppTile extends React.Component {
         return WidgetType.JITSI.matches(this.props.app.type);
     }
 
-    /**
-     * Get the URL used in the iframe
-     * In cases where we supply our own UI for a widget, this is an internal
-     * URL different to the one used if the widget is popped out to a separate
-     * tab / browser
-     *
-     * @returns {string} url
-     */
-    _getRenderedUrl() {
-        let url;
-
-        if (WidgetType.JITSI.matches(this.props.app.type)) {
-            console.log("Replacing Jitsi widget URL with local wrapper");
-            url = WidgetUtils.getLocalJitsiWrapperUrl({
-                forLocalRender: true,
-                auth: this.props.app.data ? this.props.app.data.auth : null,
-            });
-            url = this._addWurlParams(url);
-        } else {
-            url = this._getSafeUrl(this.state.widgetUrl);
-        }
-        return this._templatedUrl(url, this.props.app.type);
-    }
-
-    _getPopoutUrl() {
-        if (WidgetType.JITSI.matches(this.props.app.type)) {
-            return this._templatedUrl(
-                WidgetUtils.getLocalJitsiWrapperUrl({
-                    forLocalRender: false,
-                    auth: this.props.app.data ? this.props.app.data.auth : null,
-                }),
-                this.props.app.type,
-            );
-        } else {
-            // use app.url, not state.widgetUrl, because we want the one without
-            // the wURL params for the popped-out version.
-            return this._templatedUrl(this._getSafeUrl(this.props.app.url), this.props.app.type);
-        }
-    }
-
-    _getSafeUrl(u) {
-        const parsedWidgetUrl = url.parse(u, true);
-        if (ENABLE_REACT_PERF) {
-            parsedWidgetUrl.search = null;
-            parsedWidgetUrl.query.react_perf = true;
-        }
-        let safeWidgetUrl = '';
-        if (ALLOWED_APP_URL_SCHEMES.includes(parsedWidgetUrl.protocol)) {
-            safeWidgetUrl = url.format(parsedWidgetUrl);
-        }
-
-        // Replace all the dollar signs back to dollar signs as they don't affect HTTP at all.
-        // We also need the dollar signs in-tact for variable substitution.
-        return safeWidgetUrl.replace(/%24/g, '$');
-    }
-
     _getTileTitle() {
         const name = this.formatAppTileName();
         const titleSpacer = <span>&nbsp;-&nbsp;</span>;
         let title = '';
-        if (this.state.widgetPageTitle && this.state.widgetPageTitle != this.formatAppTileName()) {
+        if (this.state.widgetPageTitle && this.state.widgetPageTitle !== this.formatAppTileName()) {
             title = this.state.widgetPageTitle;
         }
 
@@ -698,9 +426,9 @@ export default class AppTile extends React.Component {
         // twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop).
         if (WidgetType.JITSI.matches(this.props.app.type) && this.props.show) {
             this._endWidgetActions().then(() => {
-                if (this._appFrame.current) {
+                if (this.iframe) {
                     // Reload iframe
-                    this._appFrame.current.src = this._getRenderedUrl();
+                    this.iframe.src = this._sgWidget.embedUrl;
                     this.setState({});
                 }
             });
@@ -708,13 +436,13 @@ export default class AppTile extends React.Component {
         // Using Object.assign workaround as the following opens in a new window instead of a new tab.
         // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
         Object.assign(document.createElement('a'),
-            { target: '_blank', href: this._getPopoutUrl(), rel: 'noreferrer noopener'}).click();
+            { target: '_blank', href: this._sgWidget.popoutUrl, rel: 'noreferrer noopener'}).click();
     }
 
     _onReloadWidgetClick() {
         // Reload iframe in this way to avoid cross-origin restrictions
         // eslint-disable-next-line no-self-assign
-        this._appFrame.current.src = this._appFrame.current.src;
+        this.iframe.src = this.iframe.src;
     }
 
     _onContextMenuClick = () => {
@@ -760,7 +488,7 @@ export default class AppTile extends React.Component {
                         <AppPermission
                             roomId={this.props.room.roomId}
                             creatorUserId={this.props.creatorUserId}
-                            url={this.state.widgetUrl}
+                            url={this._sgWidget.embedUrl}
                             isRoomEncrypted={isEncrypted}
                             onPermissionGranted={this._grantWidgetPermission}
                         />
@@ -785,11 +513,11 @@ export default class AppTile extends React.Component {
                             { this.state.loading && loadingElement }
                             <iframe
                                 allow={iframeFeatures}
-                                ref={this._appFrame}
-                                src={this._getRenderedUrl()}
+                                ref={this._iframeRefChange}
+                                src={this._sgWidget.embedUrl}
                                 allowFullScreen={true}
                                 sandbox={sandboxFlags}
-                                onLoad={this._onLoaded} />
+                            />
                         </div>
                     );
                     // if the widget would be allowed to remain on screen, we must put it in
@@ -833,9 +561,10 @@ export default class AppTile extends React.Component {
             const elementRect = this._contextMenuButton.current.getBoundingClientRect();
 
             const canUserModify = this._canUserModify();
-            const showEditButton = Boolean(this._scalarClient && canUserModify);
+            const showEditButton = Boolean(this._sgWidget.isManagedByManager && canUserModify);
             const showDeleteButton = (this.props.showDelete === undefined || this.props.showDelete) && canUserModify;
-            const showPictureSnapshotButton = this._hasCapability('m.capability.screenshot') && this.props.show;
+            const showPictureSnapshotButton = this.props.show && this._sgWidget.widgetApi &&
+                this._sgWidget.widgetApi.hasCapability(MatrixCapabilities.Screenshots);
 
             const WidgetContextMenu = sdk.getComponent('views.context_menus.WidgetContextMenu');
             contextMenu = (
@@ -943,9 +672,6 @@ AppTile.propTypes = {
     // NOTE -- Use with caution. This is intended to aid better integration / UX
     // basic widget capabilities, e.g. injecting sticker message events.
     whitelistCapabilities: PropTypes.array,
-    // Optional function to be called on widget capability request
-    // Called with an array of the requested capabilities
-    onCapabilityRequest: PropTypes.func,
     // Is this an instance of a user widget
     userWidget: PropTypes.bool,
 };
diff --git a/src/components/views/elements/EventListSummary.js b/src/components/views/elements/EventListSummary.tsx
similarity index 81%
rename from src/components/views/elements/EventListSummary.js
rename to src/components/views/elements/EventListSummary.tsx
index 5a4a6e4f5a..1d3b6e8764 100644
--- a/src/components/views/elements/EventListSummary.js
+++ b/src/components/views/elements/EventListSummary.tsx
@@ -1,5 +1,5 @@
 /*
-Copyright 2019 The Matrix.org Foundation C.I.C.
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -14,15 +14,41 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React, {useEffect} from 'react';
-import PropTypes from 'prop-types';
+import React, {ReactChildren, useEffect} from 'react';
+import {MatrixEvent} from "matrix-js-sdk/src/models/event";
+import {RoomMember} from "matrix-js-sdk/src/models/room-member";
+
 import MemberAvatar from '../avatars/MemberAvatar';
 import { _t } from '../../../languageHandler';
-import {MatrixEvent, RoomMember} from "matrix-js-sdk";
 import {useStateToggle} from "../../../hooks/useStateToggle";
 import AccessibleButton from "./AccessibleButton";
 
-const EventListSummary = ({events, children, threshold=3, onToggle, startExpanded, summaryMembers=[], summaryText}) => {
+interface IProps {
+    // An array of member events to summarise
+    events: MatrixEvent[];
+    // The minimum number of events needed to trigger summarisation
+    threshold?: number;
+    // Whether or not to begin with state.expanded=true
+    startExpanded?: boolean,
+    // 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,
+    // An array of EventTiles to render when expanded
+    children: ReactChildren,
+    // Called when the event list expansion is toggled
+    onToggle?(): void;
+}
+
+const EventListSummary: React.FC<IProps> = ({
+    events,
+    children,
+    threshold = 3,
+    onToggle,
+    startExpanded,
+    summaryMembers = [],
+    summaryText,
+}) => {
     const [expanded, toggleExpanded] = useStateToggle(startExpanded);
 
     // Whenever expanded changes call onToggle
@@ -75,22 +101,4 @@ const EventListSummary = ({events, children, threshold=3, onToggle, startExpande
     );
 };
 
-EventListSummary.propTypes = {
-    // An array of member events to summarise
-    events: PropTypes.arrayOf(PropTypes.instanceOf(MatrixEvent)).isRequired,
-    // An array of EventTiles to render when expanded
-    children: PropTypes.arrayOf(PropTypes.element).isRequired,
-    // The minimum number of events needed to trigger summarisation
-    threshold: PropTypes.number,
-    // Called when the event list expansion is toggled
-    onToggle: PropTypes.func,
-    // Whether or not to begin with state.expanded=true
-    startExpanded: PropTypes.bool,
-
-    // The list of room members for which to show avatars next to the summary
-    summaryMembers: PropTypes.arrayOf(PropTypes.instanceOf(RoomMember)),
-    // The text to show as the summary of this event list
-    summaryText: PropTypes.string,
-};
-
 export default EventListSummary;
diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.tsx
similarity index 70%
rename from src/components/views/elements/MemberEventListSummary.js
rename to src/components/views/elements/MemberEventListSummary.tsx
index e16b52c8a2..073bedf207 100644
--- a/src/components/views/elements/MemberEventListSummary.js
+++ b/src/components/views/elements/MemberEventListSummary.tsx
@@ -16,32 +16,60 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, { ReactChildren } from 'react';
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { RoomMember } from "matrix-js-sdk/src/models/room-member";
+
 import { _t } from '../../../languageHandler';
 import { formatCommaSeparatedList } from '../../../utils/FormattingUtils';
-import * as sdk from "../../../index";
-import {MatrixEvent} from "matrix-js-sdk";
-import {isValid3pidInvite} from "../../../RoomInvite";
+import { isValid3pidInvite } from "../../../RoomInvite";
+import EventListSummary from "./EventListSummary";
 
-export default class MemberEventListSummary extends React.Component {
-    static propTypes = {
-        // An array of member events to summarise
-        events: PropTypes.arrayOf(PropTypes.instanceOf(MatrixEvent)).isRequired,
-        // An array of EventTiles to render when expanded
-        children: PropTypes.array.isRequired,
-        // The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
-        summaryLength: PropTypes.number,
-        // The maximum number of avatars to display in the summary
-        avatarsMaxLength: PropTypes.number,
-        // The minimum number of events needed to trigger summarisation
-        threshold: PropTypes.number,
-        // Called when the MELS expansion is toggled
-        onToggle: PropTypes.func,
-        // Whether or not to begin with state.expanded=true
-        startExpanded: PropTypes.bool,
-    };
+interface IProps {
+    // An array of member events to summarise
+    events: MatrixEvent[];
+    // The maximum number of names to show in either each summary e.g. 2 would result "A, B and 234 others left"
+    summaryLength?: number;
+    // The maximum number of avatars to display in the summary
+    avatarsMaxLength?: number;
+    // The minimum number of events needed to trigger summarisation
+    threshold?: number,
+    // Whether or not to begin with state.expanded=true
+    startExpanded?: boolean,
+    // An array of EventTiles to render when expanded
+    children: ReactChildren;
+    // Called when the MELS expansion is toggled
+    onToggle?(): void,
+}
 
+interface IUserEvents {
+    // The original event
+    mxEvent: MatrixEvent;
+    // The display name of the user (if not, then user ID)
+    displayName: string;
+    // The original index of the event in this.props.events
+    index: number;
+}
+
+enum TransitionType {
+    Joined = "joined",
+    Left = "left",
+    JoinedAndLeft = "joined_and_left",
+    LeftAndJoined = "left_and_joined",
+    InviteReject = "invite_reject",
+    InviteWithdrawal = "invite_withdrawal",
+    Invited = "invited",
+    Banned = "banned",
+    Unbanned = "unbanned",
+    Kicked = "kicked",
+    ChangedName = "changed_name",
+    ChangedAvatar = "changed_avatar",
+    NoChange = "no_change",
+}
+
+const SEP = ",";
+
+export default class MemberEventListSummary extends React.Component<IProps> {
     static defaultProps = {
         summaryLength: 1,
         threshold: 3,
@@ -62,30 +90,28 @@ export default class MemberEventListSummary extends React.Component {
     /**
      * Generate the text for users aggregated by their transition sequences (`eventAggregates`) where
      * the sequences are ordered by `orderedTransitionSequences`.
-     * @param {object[]} eventAggregates a map of transition sequence to array of user display names
+     * @param {object} eventAggregates a map of transition sequence to array of user display names
      * or user IDs.
      * @param {string[]} orderedTransitionSequences an array which is some ordering of
      * `Object.keys(eventAggregates)`.
      * @returns {string} the textual summary of the aggregated events that occurred.
      */
-    _generateSummary(eventAggregates, orderedTransitionSequences) {
+    private generateSummary(eventAggregates: Record<string, string[]>, orderedTransitionSequences: string[]) {
         const summaries = orderedTransitionSequences.map((transitions) => {
             const userNames = eventAggregates[transitions];
-            const nameList = this._renderNameList(userNames);
+            const nameList = this.renderNameList(userNames);
 
-            const splitTransitions = transitions.split(',');
+            const splitTransitions = transitions.split(SEP) as TransitionType[];
 
             // Some neighbouring transitions are common, so canonicalise some into "pair"
             // transitions
-            const canonicalTransitions = this._getCanonicalTransitions(splitTransitions);
+            const canonicalTransitions = MemberEventListSummary.getCanonicalTransitions(splitTransitions);
             // Transform into consecutive repetitions of the same transition (like 5
             // consecutive 'joined_and_left's)
-            const coalescedTransitions = this._coalesceRepeatedTransitions(
-                canonicalTransitions,
-            );
+            const coalescedTransitions = MemberEventListSummary.coalesceRepeatedTransitions(canonicalTransitions);
 
             const descs = coalescedTransitions.map((t) => {
-                return this._getDescriptionForTransition(
+                return MemberEventListSummary.getDescriptionForTransition(
                     t.transitionType, userNames.length, t.repeats,
                 );
             });
@@ -108,7 +134,7 @@ export default class MemberEventListSummary extends React.Component {
      * more items in `users` than `this.props.summaryLength`, which is the number of names
      * included before "and [n] others".
      */
-    _renderNameList(users) {
+    private renderNameList(users: string[]) {
         return formatCommaSeparatedList(users, this.props.summaryLength);
     }
 
@@ -119,22 +145,22 @@ export default class MemberEventListSummary extends React.Component {
      * @param {string[]} transitions an array of transitions.
      * @returns {string[]} an array of transitions.
      */
-    _getCanonicalTransitions(transitions) {
+    private static getCanonicalTransitions(transitions: TransitionType[]): TransitionType[] {
         const modMap = {
-            'joined': {
-                'after': 'left',
-                'newTransition': 'joined_and_left',
+            [TransitionType.Joined]: {
+                after: TransitionType.Left,
+                newTransition: TransitionType.JoinedAndLeft,
             },
-            'left': {
-                'after': 'joined',
-                'newTransition': 'left_and_joined',
+            [TransitionType.Left]: {
+                after: TransitionType.Joined,
+                newTransition: TransitionType.LeftAndJoined,
             },
             // $currentTransition : {
             //     'after' : $nextTransition,
             //     'newTransition' : 'new_transition_type',
             // },
         };
-        const res = [];
+        const res: TransitionType[] = [];
 
         for (let i = 0; i < transitions.length; i++) {
             const t = transitions[i];
@@ -166,8 +192,12 @@ export default class MemberEventListSummary extends React.Component {
      * @param {string[]} transitions the array of transitions to transform.
      * @returns {object[]} an array of coalesced transitions.
      */
-    _coalesceRepeatedTransitions(transitions) {
-        const res = [];
+    private static coalesceRepeatedTransitions(transitions: TransitionType[]) {
+        const res: {
+            transitionType: TransitionType;
+            repeats: number;
+        }[] = [];
+
         for (let i = 0; i < transitions.length; i++) {
             if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) {
                 res[res.length - 1].repeats += 1;
@@ -189,7 +219,7 @@ export default class MemberEventListSummary extends React.Component {
      * @param {number} repeats the number of times the transition was repeated in a row.
      * @returns {string} the written Human Readable equivalent of the transition.
      */
-    _getDescriptionForTransition(t, userCount, repeats) {
+    private static getDescriptionForTransition(t: TransitionType, userCount: number, repeats: number) {
         // 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.
@@ -217,12 +247,18 @@ export default class MemberEventListSummary extends React.Component {
                 break;
             case "invite_reject":
                 res = (userCount > 1)
-                    ? _t("%(severalUsers)srejected their invitations %(count)s times", { severalUsers: "", count: repeats })
+                    ? _t("%(severalUsers)srejected their invitations %(count)s times", {
+                        severalUsers: "",
+                        count: repeats,
+                    })
                     : _t("%(oneUser)srejected their invitation %(count)s times", { oneUser: "", count: repeats });
                 break;
             case "invite_withdrawal":
                 res = (userCount > 1)
-                    ? _t("%(severalUsers)shad their invitations withdrawn %(count)s times", { severalUsers: "", count: repeats })
+                    ? _t("%(severalUsers)shad their invitations withdrawn %(count)s times", {
+                        severalUsers: "",
+                        count: repeats,
+                    })
                     : _t("%(oneUser)shad their invitation withdrawn %(count)s times", { oneUser: "", count: repeats });
                 break;
             case "invited":
@@ -265,8 +301,8 @@ export default class MemberEventListSummary extends React.Component {
         return res;
     }
 
-    _getTransitionSequence(events) {
-        return events.map(this._getTransition);
+    private static getTransitionSequence(events: MatrixEvent[]) {
+        return events.map(MemberEventListSummary.getTransition);
     }
 
     /**
@@ -277,60 +313,60 @@ export default class MemberEventListSummary extends React.Component {
      * @returns {string?} the transition type given to this event. This defaults to `null`
      * if a transition is not recognised.
      */
-    _getTransition(e) {
+    private static getTransition(e: MatrixEvent): TransitionType {
         if (e.mxEvent.getType() === 'm.room.third_party_invite') {
             // Handle 3pid invites the same as invites so they get bundled together
             if (!isValid3pidInvite(e.mxEvent)) {
-                return 'invite_withdrawal';
+                return TransitionType.InviteWithdrawal;
             }
-            return 'invited';
+            return TransitionType.Invited;
         }
 
         switch (e.mxEvent.getContent().membership) {
-            case 'invite': return 'invited';
-            case 'ban': return 'banned';
+            case 'invite': return TransitionType.Invited;
+            case 'ban': return TransitionType.Banned;
             case 'join':
                 if (e.mxEvent.getPrevContent().membership === 'join') {
                     if (e.mxEvent.getContent().displayname !==
                         e.mxEvent.getPrevContent().displayname) {
-                        return 'changed_name';
+                        return TransitionType.ChangedName;
                     } else if (e.mxEvent.getContent().avatar_url !==
                         e.mxEvent.getPrevContent().avatar_url) {
-                        return 'changed_avatar';
+                        return TransitionType.ChangedAvatar;
                     }
                     // console.log("MELS ignoring duplicate membership join event");
-                    return 'no_change';
+                    return TransitionType.NoChange;
                 } else {
-                    return 'joined';
+                    return TransitionType.Joined;
                 }
             case 'leave':
                 if (e.mxEvent.getSender() === e.mxEvent.getStateKey()) {
                     switch (e.mxEvent.getPrevContent().membership) {
-                        case 'invite': return 'invite_reject';
-                        default: return 'left';
+                        case 'invite': return TransitionType.InviteReject;
+                        default: return TransitionType.Left;
                     }
                 }
                 switch (e.mxEvent.getPrevContent().membership) {
-                    case 'invite': return 'invite_withdrawal';
-                    case 'ban': return 'unbanned';
+                    case 'invite': return TransitionType.InviteWithdrawal;
+                    case 'ban': return TransitionType.Unbanned;
                     // sender is not target and made the target leave, if not from invite/ban then this is a kick
-                    default: return 'kicked';
+                    default: return TransitionType.Kicked;
                 }
             default: return null;
         }
     }
 
-    _getAggregate(userEvents) {
+    getAggregate(userEvents: Record<string, IUserEvents[]>) {
         // A map of aggregate type to arrays of display names. Each aggregate type
         // is a comma-delimited string of transitions, e.g. "joined,left,kicked".
         // The array of display names is the array of users who went through that
         // sequence during eventsToRender.
-        const aggregate = {
+        const aggregate: Record<string, string[]> = {
             // $aggregateType : []:string
         };
         // A map of aggregate types to the indices that order them (the index of
         // the first event for a given transition sequence)
-        const aggregateIndices = {
+        const aggregateIndices: Record<string, number> = {
             // $aggregateType : int
         };
 
@@ -340,7 +376,7 @@ export default class MemberEventListSummary extends React.Component {
                 const firstEvent = userEvents[userId][0];
                 const displayName = firstEvent.displayName;
 
-                const seq = this._getTransitionSequence(userEvents[userId]);
+                const seq = MemberEventListSummary.getTransitionSequence(userEvents[userId]).join(SEP);
                 if (!aggregate[seq]) {
                     aggregate[seq] = [];
                     aggregateIndices[seq] = -1;
@@ -349,8 +385,9 @@ export default class MemberEventListSummary extends React.Component {
                 aggregate[seq].push(displayName);
 
                 if (aggregateIndices[seq] === -1 ||
-                    firstEvent.index < aggregateIndices[seq]) {
-                        aggregateIndices[seq] = firstEvent.index;
+                    firstEvent.index < aggregateIndices[seq]
+                ) {
+                    aggregateIndices[seq] = firstEvent.index;
                 }
             },
         );
@@ -364,25 +401,21 @@ export default class MemberEventListSummary extends React.Component {
     render() {
         const eventsToRender = this.props.events;
 
-        // Map user IDs to an array of objects:
-        const userEvents = {
-            // $userId : [{
-            //     // The original event
-            //     mxEvent: e,
-            //     // The display name of the user (if not, then user ID)
-            //     displayName: e.target.name || userId,
-            //     // The original index of the event in this.props.events
-            //     index: index,
-            // }]
-        };
+        // Map user IDs to latest Avatar Member. ES6 Maps are ordered by when the key was created,
+        // so this works perfectly for us to match event order whilst storing the latest Avatar Member
+        const latestUserAvatarMember = new Map<string, RoomMember>();
 
-        const avatarMembers = [];
+        // Object mapping user IDs to an array of IUserEvents
+        const userEvents: Record<string, IUserEvents[]> = {};
         eventsToRender.forEach((e, index) => {
             const userId = e.getStateKey();
             // Initialise a user's events
             if (!userEvents[userId]) {
                 userEvents[userId] = [];
-                if (e.target) avatarMembers.push(e.target);
+            }
+
+            if (e.target) {
+                latestUserAvatarMember.set(userId, e.target);
             }
 
             let displayName = userId;
@@ -399,21 +432,20 @@ export default class MemberEventListSummary extends React.Component {
             });
         });
 
-        const aggregate = this._getAggregate(userEvents);
+        const aggregate = this.getAggregate(userEvents);
 
         // Sort types by order of lowest event index within sequence
         const orderedTransitionSequences = Object.keys(aggregate.names).sort(
-            (seq1, seq2) => aggregate.indices[seq1] > aggregate.indices[seq2],
+            (seq1, seq2) => aggregate.indices[seq1] - aggregate.indices[seq2],
         );
 
-        const EventListSummary = sdk.getComponent("views.elements.EventListSummary");
         return <EventListSummary
             events={this.props.events}
             threshold={this.props.threshold}
             onToggle={this.props.onToggle}
             startExpanded={this.props.startExpanded}
             children={this.props.children}
-            summaryMembers={avatarMembers}
-            summaryText={this._generateSummary(aggregate.names, orderedTransitionSequences)} />;
+            summaryMembers={[...latestUserAvatarMember.values()]}
+            summaryText={this.generateSummary(aggregate.names, orderedTransitionSequences)} />;
     }
 }
diff --git a/src/components/views/elements/PersistentApp.js b/src/components/views/elements/PersistentApp.js
index 686739a9f7..a3e413151a 100644
--- a/src/components/views/elements/PersistentApp.js
+++ b/src/components/views/elements/PersistentApp.js
@@ -82,6 +82,7 @@ export default class PersistentApp extends React.Component {
                     showDelete={false}
                     showMinimise={false}
                     miniMode={true}
+                    showMenubar={false}
                 />;
             }
         }
diff --git a/src/components/views/elements/ReplyThread.js b/src/components/views/elements/ReplyThread.js
index 2d17c858a2..24b49f2b13 100644
--- a/src/components/views/elements/ReplyThread.js
+++ b/src/components/views/elements/ReplyThread.js
@@ -29,6 +29,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
 import {Action} from "../../../dispatcher/actions";
 import sanitizeHtml from "sanitize-html";
 import {UIFeature} from "../../../settings/UIFeature";
+import {PERMITTED_URL_SCHEMES} from "../../../HtmlUtils";
 
 // This component does no cycle detection, simply because the only way to make such a cycle would be to
 // craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would
@@ -62,6 +63,12 @@ export default class ReplyThread extends React.Component {
             err: false,
         };
 
+        this.unmounted = false;
+        this.context.on("Event.replaced", this.onEventReplaced);
+        this.room = this.context.getRoom(this.props.parentEv.getRoomId());
+        this.room.on("Room.redaction", this.onRoomRedaction);
+        this.room.on("Room.redactionCancelled", this.onRoomRedaction);
+
         this.onQuoteClick = this.onQuoteClick.bind(this);
         this.canCollapse = this.canCollapse.bind(this);
         this.collapse = this.collapse.bind(this);
@@ -106,6 +113,9 @@ export default class ReplyThread extends React.Component {
             {
                 allowedTags: false, // false means allow everything
                 allowedAttributes: false,
+                // we somehow can't allow all schemes, so we allow all that we
+                // know of and mxc (for img tags)
+                allowedSchemes: [...PERMITTED_URL_SCHEMES, 'mxc'],
                 exclusiveFilter: (frame) => frame.tag === "mx-reply",
             },
         );
@@ -213,11 +223,6 @@ export default class ReplyThread extends React.Component {
     }
 
     componentDidMount() {
-        this.unmounted = false;
-        this.room = this.context.getRoom(this.props.parentEv.getRoomId());
-        this.room.on("Room.redaction", this.onRoomRedaction);
-        // same event handler as Room.redaction as for both we just do forceUpdate
-        this.room.on("Room.redactionCancelled", this.onRoomRedaction);
         this.initialize();
     }
 
@@ -227,21 +232,36 @@ export default class ReplyThread extends React.Component {
 
     componentWillUnmount() {
         this.unmounted = true;
+        this.context.removeListener("Event.replaced", this.onEventReplaced);
         if (this.room) {
             this.room.removeListener("Room.redaction", this.onRoomRedaction);
             this.room.removeListener("Room.redactionCancelled", this.onRoomRedaction);
         }
     }
 
-    onRoomRedaction = (ev, room) => {
-        if (this.unmounted) return;
-
-        // If one of the events we are rendering gets redacted, force a re-render
-        if (this.state.events.some(event => event.getId() === ev.getId())) {
+    updateForEventId = (eventId) => {
+        if (this.state.events.some(event => event.getId() === eventId)) {
             this.forceUpdate();
         }
     };
 
+    onEventReplaced = (ev) => {
+        if (this.unmounted) return;
+
+        // If one of the events we are rendering gets replaced, force a re-render
+        this.updateForEventId(ev.getId());
+    };
+
+    onRoomRedaction = (ev) => {
+        if (this.unmounted) return;
+
+        const eventId = ev.getAssociatedId();
+        if (!eventId) return;
+
+        // If one of the events we are rendering gets redacted, force a re-render
+        this.updateForEventId(eventId);
+    };
+
     async initialize() {
         const {parentEv} = this.props;
         // at time of making this component we checked that props.parentEv has a parentEventId
@@ -368,6 +388,7 @@ export default class ReplyThread extends React.Component {
                     isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
                     useIRCLayout={this.props.useIRCLayout}
                     enableFlair={SettingsStore.getValue(UIFeature.Flair)}
+                    replacingEventId={ev.replacingEventId()}
                 />
             </blockquote>;
         });
diff --git a/src/components/views/emojipicker/Category.js b/src/components/views/emojipicker/Category.tsx
similarity index 68%
rename from src/components/views/emojipicker/Category.js
rename to src/components/views/emojipicker/Category.tsx
index eb3f83dcdf..c4feaac8ae 100644
--- a/src/components/views/emojipicker/Category.js
+++ b/src/components/views/emojipicker/Category.tsx
@@ -1,5 +1,6 @@
 /*
 Copyright 2019 Tulir Asokan <tulir@maunium.net>
+Copyright 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -14,32 +15,53 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React from 'react';
-import PropTypes from 'prop-types';
+import React, {RefObject} from 'react';
+
 import { CATEGORY_HEADER_HEIGHT, EMOJI_HEIGHT, EMOJIS_PER_ROW } from "./EmojiPicker";
-import * as sdk from '../../../index';
+import LazyRenderList from "../elements/LazyRenderList";
+import {DATA_BY_CATEGORY, IEmoji} from "../../../emoji";
+import Emoji from './Emoji';
 
 const OVERFLOW_ROWS = 3;
 
-class Category extends React.PureComponent {
-    static propTypes = {
-        emojis: PropTypes.arrayOf(PropTypes.object).isRequired,
-        name: PropTypes.string.isRequired,
-        id: PropTypes.string.isRequired,
-        onMouseEnter: PropTypes.func.isRequired,
-        onMouseLeave: PropTypes.func.isRequired,
-        onClick: PropTypes.func.isRequired,
-        selectedEmojis: PropTypes.instanceOf(Set),
-    };
+export type CategoryKey = (keyof typeof DATA_BY_CATEGORY) | "recent";
 
-    _renderEmojiRow = (rowIndex) => {
+export interface ICategory {
+    id: CategoryKey;
+    name: string;
+    enabled: boolean;
+    visible: boolean;
+    ref: RefObject<HTMLButtonElement>;
+}
+
+interface IProps {
+    id: string;
+    name: string;
+    emojis: IEmoji[];
+    selectedEmojis: Set<string>;
+    heightBefore: number;
+    viewportHeight: number;
+    scrollTop: number;
+    onClick(emoji: IEmoji): void;
+    onMouseEnter(emoji: IEmoji): void;
+    onMouseLeave(emoji: IEmoji): void;
+}
+
+class Category extends React.PureComponent<IProps> {
+    private renderEmojiRow = (rowIndex: number) => {
         const { onClick, onMouseEnter, onMouseLeave, selectedEmojis, emojis } = this.props;
         const emojisForRow = emojis.slice(rowIndex * 8, (rowIndex + 1) * 8);
-        const Emoji = sdk.getComponent("emojipicker.Emoji");
         return (<div key={rowIndex}>{
-            emojisForRow.map(emoji =>
-                <Emoji key={emoji.hexcode} emoji={emoji} selectedEmojis={selectedEmojis}
-                    onClick={onClick} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} />)
+            emojisForRow.map(emoji => ((
+                <Emoji
+                    key={emoji.hexcode}
+                    emoji={emoji}
+                    selectedEmojis={selectedEmojis}
+                    onClick={onClick}
+                    onMouseEnter={onMouseEnter}
+                    onMouseLeave={onMouseLeave}
+                />
+            )))
         }</div>);
     };
 
@@ -52,7 +74,6 @@ class Category extends React.PureComponent {
         for (let counter = 0; counter < rows.length; ++counter) {
             rows[counter] = counter;
         }
-        const LazyRenderList = sdk.getComponent('elements.LazyRenderList');
 
         const viewportTop = scrollTop;
         const viewportBottom = viewportTop + viewportHeight;
@@ -84,7 +105,7 @@ class Category extends React.PureComponent {
                     height={localHeight}
                     overflowItems={OVERFLOW_ROWS}
                     overflowMargin={0}
-                    renderItem={this._renderEmojiRow}>
+                    renderItem={this.renderEmojiRow}>
                 </LazyRenderList>
             </section>
         );
diff --git a/src/components/views/emojipicker/Emoji.js b/src/components/views/emojipicker/Emoji.tsx
similarity index 81%
rename from src/components/views/emojipicker/Emoji.js
rename to src/components/views/emojipicker/Emoji.tsx
index 36aa4ff782..5d715fb935 100644
--- a/src/components/views/emojipicker/Emoji.js
+++ b/src/components/views/emojipicker/Emoji.tsx
@@ -1,5 +1,6 @@
 /*
 Copyright 2019 Tulir Asokan <tulir@maunium.net>
+Copyright 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -15,18 +16,19 @@ limitations under the License.
 */
 
 import React from 'react';
-import PropTypes from 'prop-types';
+
 import {MenuItem} from "../../structures/ContextMenu";
+import {IEmoji} from "../../../emoji";
 
-class Emoji extends React.PureComponent {
-    static propTypes = {
-        onClick: PropTypes.func,
-        onMouseEnter: PropTypes.func,
-        onMouseLeave: PropTypes.func,
-        emoji: PropTypes.object.isRequired,
-        selectedEmojis: PropTypes.instanceOf(Set),
-    };
+interface IProps {
+    emoji: IEmoji;
+    selectedEmojis?: Set<string>;
+    onClick(emoji: IEmoji): void;
+    onMouseEnter(emoji: IEmoji): void;
+    onMouseLeave(emoji: IEmoji): void;
+}
 
+class Emoji extends React.PureComponent<IProps> {
     render() {
         const { onClick, onMouseEnter, onMouseLeave, emoji, selectedEmojis } = this.props;
         const isSelected = selectedEmojis && selectedEmojis.has(emoji.unicode);
diff --git a/src/components/views/emojipicker/EmojiPicker.js b/src/components/views/emojipicker/EmojiPicker.tsx
similarity index 65%
rename from src/components/views/emojipicker/EmojiPicker.js
rename to src/components/views/emojipicker/EmojiPicker.tsx
index 16a0fc67e7..bf0481c51c 100644
--- a/src/components/views/emojipicker/EmojiPicker.js
+++ b/src/components/views/emojipicker/EmojiPicker.tsx
@@ -1,5 +1,6 @@
 /*
 Copyright 2019 Tulir Asokan <tulir@maunium.net>
+Copyright 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -15,25 +16,43 @@ limitations under the License.
 */
 
 import React from 'react';
-import PropTypes from 'prop-types';
 
-import * as sdk from '../../../index';
 import { _t } from '../../../languageHandler';
-
 import * as recent from '../../../emojipicker/recent';
-import {DATA_BY_CATEGORY, getEmojiFromUnicode} from "../../../emoji";
+import {DATA_BY_CATEGORY, getEmojiFromUnicode, IEmoji} from "../../../emoji";
 import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
+import Header from "./Header";
+import Search from "./Search";
+import Preview from "./Preview";
+import QuickReactions from "./QuickReactions";
+import Category, {ICategory, CategoryKey} from "./Category";
 
 export const CATEGORY_HEADER_HEIGHT = 22;
 export const EMOJI_HEIGHT = 37;
 export const EMOJIS_PER_ROW = 8;
 
-class EmojiPicker extends React.Component {
-    static propTypes = {
-        onChoose: PropTypes.func.isRequired,
-        selectedEmojis: PropTypes.instanceOf(Set),
-        showQuickReactions: PropTypes.bool,
-    };
+interface IProps {
+    selectedEmojis: Set<string>;
+    showQuickReactions?: boolean;
+    onChoose(unicode: string): boolean;
+}
+
+interface IState {
+    filter: string;
+    previewEmoji?: IEmoji;
+    scrollTop: number;
+    // initial estimation of height, dialog is hardcoded to 450px height.
+    // should be enough to never have blank rows of emojis as
+    // 3 rows of overflow are also rendered. The actual value is updated on scroll.
+    viewportHeight: number;
+}
+
+class EmojiPicker extends React.Component<IProps, IState> {
+    private readonly recentlyUsed: IEmoji[];
+    private readonly memoizedDataByCategory: Record<CategoryKey, IEmoji[]>;
+    private readonly categories: ICategory[];
+
+    private bodyRef = React.createRef<HTMLDivElement>();
 
     constructor(props) {
         super(props);
@@ -42,9 +61,6 @@ class EmojiPicker extends React.Component {
             filter: "",
             previewEmoji: null,
             scrollTop: 0,
-            // initial estimation of height, dialog is hardcoded to 450px height.
-            // should be enough to never have blank rows of emojis as
-            // 3 rows of overflow are also rendered. The actual value is updated on scroll.
             viewportHeight: 280,
         };
 
@@ -110,18 +126,9 @@ class EmojiPicker extends React.Component {
             visible: false,
             ref: React.createRef(),
         }];
-
-        this.bodyRef = React.createRef();
-
-        this.onChangeFilter = this.onChangeFilter.bind(this);
-        this.onHoverEmoji = this.onHoverEmoji.bind(this);
-        this.onHoverEmojiEnd = this.onHoverEmojiEnd.bind(this);
-        this.onClickEmoji = this.onClickEmoji.bind(this);
-        this.scrollToCategory = this.scrollToCategory.bind(this);
-        this.updateVisibility = this.updateVisibility.bind(this);
     }
 
-    onScroll = () => {
+    private onScroll = () => {
         const body = this.bodyRef.current;
         this.setState({
             scrollTop: body.scrollTop,
@@ -130,7 +137,7 @@ class EmojiPicker extends React.Component {
         this.updateVisibility();
     };
 
-    updateVisibility() {
+    private updateVisibility = () => {
         const body = this.bodyRef.current;
         const rect = body.getBoundingClientRect();
         for (const cat of this.categories) {
@@ -147,21 +154,21 @@ class EmojiPicker extends React.Component {
             // We update this here instead of through React to avoid re-render on scroll.
             if (cat.visible) {
                 cat.ref.current.classList.add("mx_EmojiPicker_anchor_visible");
-                cat.ref.current.setAttribute("aria-selected", true);
-                cat.ref.current.setAttribute("tabindex", 0);
+                cat.ref.current.setAttribute("aria-selected", "true");
+                cat.ref.current.setAttribute("tabindex", "0");
             } else {
                 cat.ref.current.classList.remove("mx_EmojiPicker_anchor_visible");
-                cat.ref.current.setAttribute("aria-selected", false);
-                cat.ref.current.setAttribute("tabindex", -1);
+                cat.ref.current.setAttribute("aria-selected", "false");
+                cat.ref.current.setAttribute("tabindex", "-1");
             }
         }
-    }
+    };
 
-    scrollToCategory(category) {
+    private scrollToCategory = (category: string) => {
         this.bodyRef.current.querySelector(`[data-category-id="${category}"]`).scrollIntoView();
-    }
+    };
 
-    onChangeFilter(filter) {
+    private onChangeFilter = (filter: string) => {
         filter = filter.toLowerCase(); // filter is case insensitive stored lower-case
         for (const cat of this.categories) {
             let emojis;
@@ -181,27 +188,34 @@ class EmojiPicker extends React.Component {
         // Header underlines need to be updated, but updating requires knowing
         // where the categories are, so we wait for a tick.
         setTimeout(this.updateVisibility, 0);
-    }
+    };
 
-    onHoverEmoji(emoji) {
+    private onEnterFilter = () => {
+        const btn = this.bodyRef.current.querySelector<HTMLButtonElement>(".mx_EmojiPicker_item");
+        if (btn) {
+            btn.click();
+        }
+    };
+
+    private onHoverEmoji = (emoji: IEmoji) => {
         this.setState({
             previewEmoji: emoji,
         });
-    }
+    };
 
-    onHoverEmojiEnd(emoji) {
+    private onHoverEmojiEnd = (emoji: IEmoji) => {
         this.setState({
             previewEmoji: null,
         });
-    }
+    };
 
-    onClickEmoji(emoji) {
+    private onClickEmoji = (emoji: IEmoji) => {
         if (this.props.onChoose(emoji.unicode) !== false) {
             recent.add(emoji.unicode);
         }
-    }
+    };
 
-    _categoryHeightForEmojiCount(count) {
+    private static categoryHeightForEmojiCount(count: number) {
         if (count === 0) {
             return 0;
         }
@@ -209,25 +223,37 @@ class EmojiPicker extends React.Component {
     }
 
     render() {
-        const Header = sdk.getComponent("emojipicker.Header");
-        const Search = sdk.getComponent("emojipicker.Search");
-        const Category = sdk.getComponent("emojipicker.Category");
-        const Preview = sdk.getComponent("emojipicker.Preview");
-        const QuickReactions = sdk.getComponent("emojipicker.QuickReactions");
         let heightBefore = 0;
         return (
             <div className="mx_EmojiPicker">
-                <Header categories={this.categories} defaultCategory="recent" onAnchorClick={this.scrollToCategory} />
-                <Search query={this.state.filter} onChange={this.onChangeFilter} />
-                <AutoHideScrollbar className="mx_EmojiPicker_body" wrappedRef={e => this.bodyRef.current = e} onScroll={this.onScroll}>
+                <Header categories={this.categories} onAnchorClick={this.scrollToCategory} />
+                <Search query={this.state.filter} onChange={this.onChangeFilter} onEnter={this.onEnterFilter} />
+                <AutoHideScrollbar
+                    className="mx_EmojiPicker_body"
+                    wrappedRef={ref => {
+                        // @ts-ignore - AutoHideScrollbar should accept a RefObject or fall back to its own instead
+                        this.bodyRef.current = ref
+                    }}
+                    onScroll={this.onScroll}
+                >
                     {this.categories.map(category => {
                         const emojis = this.memoizedDataByCategory[category.id];
-                        const categoryElement = (<Category key={category.id} id={category.id} name={category.name}
-                            heightBefore={heightBefore} viewportHeight={this.state.viewportHeight}
-                            scrollTop={this.state.scrollTop} emojis={emojis} onClick={this.onClickEmoji}
-                            onMouseEnter={this.onHoverEmoji} onMouseLeave={this.onHoverEmojiEnd}
-                            selectedEmojis={this.props.selectedEmojis} />);
-                        const height = this._categoryHeightForEmojiCount(emojis.length);
+                        const categoryElement = ((
+                            <Category
+                                key={category.id}
+                                id={category.id}
+                                name={category.name}
+                                heightBefore={heightBefore}
+                                viewportHeight={this.state.viewportHeight}
+                                scrollTop={this.state.scrollTop}
+                                emojis={emojis}
+                                onClick={this.onClickEmoji}
+                                onMouseEnter={this.onHoverEmoji}
+                                onMouseLeave={this.onHoverEmojiEnd}
+                                selectedEmojis={this.props.selectedEmojis}
+                            />
+                        ));
+                        const height = EmojiPicker.categoryHeightForEmojiCount(emojis.length);
                         heightBefore += height;
                         return categoryElement;
                     })}
diff --git a/src/components/views/emojipicker/Header.js b/src/components/views/emojipicker/Header.tsx
similarity index 83%
rename from src/components/views/emojipicker/Header.js
rename to src/components/views/emojipicker/Header.tsx
index c53437e02d..9a93722483 100644
--- a/src/components/views/emojipicker/Header.js
+++ b/src/components/views/emojipicker/Header.tsx
@@ -1,5 +1,6 @@
 /*
 Copyright 2019 Tulir Asokan <tulir@maunium.net>
+Copyright 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -15,19 +16,19 @@ limitations under the License.
 */
 
 import React from 'react';
-import PropTypes from 'prop-types';
 import classNames from "classnames";
 
 import {_t} from "../../../languageHandler";
 import {Key} from "../../../Keyboard";
+import {CategoryKey, ICategory} from "./Category";
 
-class Header extends React.PureComponent {
-    static propTypes = {
-        categories: PropTypes.arrayOf(PropTypes.object).isRequired,
-        onAnchorClick: PropTypes.func.isRequired,
-    };
+interface IProps {
+    categories: ICategory[];
+    onAnchorClick(id: CategoryKey): void
+}
 
-    findNearestEnabled(index, delta) {
+class Header extends React.PureComponent<IProps> {
+    private findNearestEnabled(index: number, delta: number) {
         index += this.props.categories.length;
         const cats = [...this.props.categories, ...this.props.categories, ...this.props.categories];
 
@@ -37,12 +38,12 @@ class Header extends React.PureComponent {
         }
     }
 
-    changeCategoryRelative(delta) {
+    private changeCategoryRelative(delta: number) {
         const current = this.props.categories.findIndex(c => c.visible);
         this.changeCategoryAbsolute(current + delta, delta);
     }
 
-    changeCategoryAbsolute(index, delta=1) {
+    private changeCategoryAbsolute(index: number, delta=1) {
         const category = this.props.categories[this.findNearestEnabled(index, delta)];
         if (category) {
             this.props.onAnchorClick(category.id);
@@ -52,7 +53,7 @@ class Header extends React.PureComponent {
 
     // Implements ARIA Tabs with Automatic Activation pattern
     // https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-1/tabs.html
-    onKeyDown = (ev) => {
+    private onKeyDown = (ev: React.KeyboardEvent) => {
         let handled = true;
         switch (ev.key) {
             case Key.ARROW_LEFT:
@@ -80,7 +81,12 @@ class Header extends React.PureComponent {
 
     render() {
         return (
-            <nav className="mx_EmojiPicker_header" role="tablist" aria-label={_t("Categories")} onKeyDown={this.onKeyDown}>
+            <nav
+                className="mx_EmojiPicker_header"
+                role="tablist"
+                aria-label={_t("Categories")}
+                onKeyDown={this.onKeyDown}
+            >
                 {this.props.categories.map(category => {
                     const classes = classNames(`mx_EmojiPicker_anchor mx_EmojiPicker_anchor_${category.id}`, {
                         mx_EmojiPicker_anchor_visible: category.visible,
diff --git a/src/components/views/emojipicker/Preview.js b/src/components/views/emojipicker/Preview.tsx
similarity index 88%
rename from src/components/views/emojipicker/Preview.js
rename to src/components/views/emojipicker/Preview.tsx
index bbe2bcb22c..69bfdf4d1c 100644
--- a/src/components/views/emojipicker/Preview.js
+++ b/src/components/views/emojipicker/Preview.tsx
@@ -1,5 +1,6 @@
 /*
 Copyright 2019 Tulir Asokan <tulir@maunium.net>
+Copyright 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -15,19 +16,21 @@ limitations under the License.
 */
 
 import React from 'react';
-import PropTypes from 'prop-types';
 
-class Preview extends React.PureComponent {
-    static propTypes = {
-        emoji: PropTypes.object,
-    };
+import {IEmoji} from "../../../emoji";
 
+interface IProps {
+    emoji: IEmoji;
+}
+
+class Preview extends React.PureComponent<IProps> {
     render() {
         const {
             unicode = "",
             annotation = "",
             shortcodes: [shortcode = ""],
         } = this.props.emoji || {};
+
         return (
             <div className="mx_EmojiPicker_footer mx_EmojiPicker_preview">
                 <div className="mx_EmojiPicker_preview_emoji">
diff --git a/src/components/views/emojipicker/QuickReactions.js b/src/components/views/emojipicker/QuickReactions.tsx
similarity index 69%
rename from src/components/views/emojipicker/QuickReactions.js
rename to src/components/views/emojipicker/QuickReactions.tsx
index 2f30ae767e..0477ecfb93 100644
--- a/src/components/views/emojipicker/QuickReactions.js
+++ b/src/components/views/emojipicker/QuickReactions.tsx
@@ -1,5 +1,6 @@
 /*
 Copyright 2019 Tulir Asokan <tulir@maunium.net>
+Copyright 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -15,11 +16,10 @@ limitations under the License.
 */
 
 import React from 'react';
-import PropTypes from 'prop-types';
 
-import * as sdk from '../../../index';
 import { _t } from '../../../languageHandler';
-import {getEmojiFromUnicode} from "../../../emoji";
+import {getEmojiFromUnicode, IEmoji} from "../../../emoji";
+import Emoji from "./Emoji";
 
 // We use the variation-selector Heart in Quick Reactions for some reason
 const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map(emoji => {
@@ -30,36 +30,36 @@ const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀
     return data;
 });
 
-class QuickReactions extends React.Component {
-    static propTypes = {
-        onClick: PropTypes.func.isRequired,
-        selectedEmojis: PropTypes.instanceOf(Set),
-    };
+interface IProps {
+    selectedEmojis?: Set<string>;
+    onClick(emoji: IEmoji): void;
+}
 
+interface IState {
+    hover?: IEmoji;
+}
+
+class QuickReactions extends React.Component<IProps, IState> {
     constructor(props) {
         super(props);
         this.state = {
             hover: null,
         };
-        this.onMouseEnter = this.onMouseEnter.bind(this);
-        this.onMouseLeave = this.onMouseLeave.bind(this);
     }
 
-    onMouseEnter(emoji) {
+    private onMouseEnter = (emoji: IEmoji) => {
         this.setState({
             hover: emoji,
         });
-    }
+    };
 
-    onMouseLeave() {
+    private onMouseLeave = () => {
         this.setState({
             hover: null,
         });
-    }
+    };
 
     render() {
-        const Emoji = sdk.getComponent("emojipicker.Emoji");
-
         return (
             <section className="mx_EmojiPicker_footer mx_EmojiPicker_quick mx_EmojiPicker_category">
                 <h2 className="mx_EmojiPicker_quick_header mx_EmojiPicker_category_label">
@@ -72,10 +72,16 @@ class QuickReactions extends React.Component {
                     }
                 </h2>
                 <ul className="mx_EmojiPicker_list" aria-label={_t("Quick Reactions")}>
-                    {QUICK_REACTIONS.map(emoji => <Emoji
-                        key={emoji.hexcode} emoji={emoji} onClick={this.props.onClick}
-                        onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}
-                        selectedEmojis={this.props.selectedEmojis} />)}
+                    {QUICK_REACTIONS.map(emoji => ((
+                        <Emoji
+                            key={emoji.hexcode}
+                            emoji={emoji}
+                            onClick={this.props.onClick}
+                            onMouseEnter={this.onMouseEnter}
+                            onMouseLeave={this.onMouseLeave}
+                            selectedEmojis={this.props.selectedEmojis}
+                        />
+                    )))}
                 </ul>
             </section>
         );
diff --git a/src/components/views/emojipicker/ReactionPicker.js b/src/components/views/emojipicker/ReactionPicker.tsx
similarity index 77%
rename from src/components/views/emojipicker/ReactionPicker.js
rename to src/components/views/emojipicker/ReactionPicker.tsx
index 6f8cc46c40..dbef0eadbe 100644
--- a/src/components/views/emojipicker/ReactionPicker.js
+++ b/src/components/views/emojipicker/ReactionPicker.tsx
@@ -1,5 +1,6 @@
 /*
 Copyright 2019 Tulir Asokan <tulir@maunium.net>
+Copyright 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -15,26 +16,29 @@ limitations under the License.
 */
 
 import React from 'react';
-import PropTypes from "prop-types";
+import {MatrixEvent} from "matrix-js-sdk/src/models/event";
+
 import EmojiPicker from "./EmojiPicker";
 import {MatrixClientPeg} from "../../../MatrixClientPeg";
 import dis from "../../../dispatcher/dispatcher";
 
-class ReactionPicker extends React.Component {
-    static propTypes = {
-        mxEvent: PropTypes.object.isRequired,
-        onFinished: PropTypes.func.isRequired,
-        reactions: PropTypes.object,
-    };
+interface IProps {
+    mxEvent: MatrixEvent;
+    reactions: any; // TODO type this once js-sdk is more typescripted
+    onFinished(): void;
+}
 
+interface IState {
+    selectedEmojis: Set<string>;
+}
+
+class ReactionPicker extends React.Component<IProps, IState> {
     constructor(props) {
         super(props);
 
         this.state = {
             selectedEmojis: new Set(Object.keys(this.getReactions())),
         };
-        this.onChoose = this.onChoose.bind(this);
-        this.onReactionsChange = this.onReactionsChange.bind(this);
         this.addListeners();
     }
 
@@ -45,7 +49,7 @@ class ReactionPicker extends React.Component {
         }
     }
 
-    addListeners() {
+    private addListeners() {
         if (this.props.reactions) {
             this.props.reactions.on("Relations.add", this.onReactionsChange);
             this.props.reactions.on("Relations.remove", this.onReactionsChange);
@@ -55,22 +59,13 @@ class ReactionPicker extends React.Component {
 
     componentWillUnmount() {
         if (this.props.reactions) {
-            this.props.reactions.removeListener(
-                "Relations.add",
-                this.onReactionsChange,
-            );
-            this.props.reactions.removeListener(
-                "Relations.remove",
-                this.onReactionsChange,
-            );
-            this.props.reactions.removeListener(
-                "Relations.redaction",
-                this.onReactionsChange,
-            );
+            this.props.reactions.removeListener("Relations.add", this.onReactionsChange);
+            this.props.reactions.removeListener("Relations.remove", this.onReactionsChange);
+            this.props.reactions.removeListener("Relations.redaction", this.onReactionsChange);
         }
     }
 
-    getReactions() {
+    private getReactions() {
         if (!this.props.reactions) {
             return {};
         }
@@ -81,13 +76,13 @@ class ReactionPicker extends React.Component {
             .map(event => [event.getRelation().key, event.getId()]));
     }
 
-    onReactionsChange() {
+    private onReactionsChange = () => {
         this.setState({
             selectedEmojis: new Set(Object.keys(this.getReactions())),
         });
-    }
+    };
 
-    onChoose(reaction) {
+    onChoose = (reaction: string) => {
         this.componentWillUnmount();
         this.props.onFinished();
         const myReactions = this.getReactions();
@@ -109,7 +104,7 @@ class ReactionPicker extends React.Component {
             dis.dispatch({action: "message_sent"});
             return true;
         }
-    }
+    };
 
     render() {
         return <EmojiPicker
diff --git a/src/components/views/emojipicker/Search.js b/src/components/views/emojipicker/Search.tsx
similarity index 56%
rename from src/components/views/emojipicker/Search.js
rename to src/components/views/emojipicker/Search.tsx
index 3432dadea8..fe1fecec7b 100644
--- a/src/components/views/emojipicker/Search.js
+++ b/src/components/views/emojipicker/Search.tsx
@@ -1,5 +1,6 @@
 /*
 Copyright 2019 Tulir Asokan <tulir@maunium.net>
+Copyright 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -15,32 +16,41 @@ limitations under the License.
 */
 
 import React from 'react';
-import PropTypes from 'prop-types';
+
 import { _t } from '../../../languageHandler';
+import {Key} from "../../../Keyboard";
 
-class Search extends React.PureComponent {
-    static propTypes = {
-        query: PropTypes.string.isRequired,
-        onChange: PropTypes.func.isRequired,
-    };
+interface IProps {
+    query: string;
+    onChange(value: string): void;
+    onEnter(): void;
+}
 
-    constructor(props) {
-        super(props);
-        this.inputRef = React.createRef();
-    }
+class Search extends React.PureComponent<IProps> {
+    private inputRef = React.createRef<HTMLInputElement>();
 
     componentDidMount() {
         // For some reason, neither the autoFocus nor just calling focus() here worked, so here's a setTimeout
         setTimeout(() => this.inputRef.current.focus(), 0);
     }
 
+    private onKeyDown = (ev: React.KeyboardEvent) => {
+        if (ev.key === Key.ENTER) {
+            this.props.onEnter();
+            ev.stopPropagation();
+            ev.preventDefault();
+        }
+    };
+
     render() {
         let rightButton;
         if (this.props.query) {
             rightButton = (
-                <button onClick={() => this.props.onChange("")}
-                        className="mx_EmojiPicker_search_icon mx_EmojiPicker_search_clear"
-                        title={_t("Cancel search")} />
+                <button
+                    onClick={() => this.props.onChange("")}
+                    className="mx_EmojiPicker_search_icon mx_EmojiPicker_search_clear"
+                    title={_t("Cancel search")}
+                />
             );
         } else {
             rightButton = <span className="mx_EmojiPicker_search_icon" />;
@@ -48,8 +58,15 @@ class Search extends React.PureComponent {
 
         return (
             <div className="mx_EmojiPicker_search">
-                <input autoFocus type="text" placeholder="Search" value={this.props.query}
-                    onChange={ev => this.props.onChange(ev.target.value)} ref={this.inputRef} />
+                <input
+                    autoFocus
+                    type="text"
+                    placeholder="Search"
+                    value={this.props.query}
+                    onChange={ev => this.props.onChange(ev.target.value)}
+                    onKeyDown={this.onKeyDown}
+                    ref={this.inputRef}
+                />
                 {rightButton}
             </div>
         );
diff --git a/src/components/views/messages/EncryptionEvent.js b/src/components/views/messages/EncryptionEvent.js
index ab0f3fde2e..a9ce10d202 100644
--- a/src/components/views/messages/EncryptionEvent.js
+++ b/src/components/views/messages/EncryptionEvent.js
@@ -25,10 +25,8 @@ export default class EncryptionEvent extends React.Component {
 
         let body;
         let classes = "mx_EventTile_bubble mx_cryptoEvent mx_cryptoEvent_icon";
-        if (
-            mxEvent.getContent().algorithm === 'm.megolm.v1.aes-sha2' &&
-            MatrixClientPeg.get().isRoomEncrypted(mxEvent.getRoomId())
-        ) {
+        const isRoomEncrypted = MatrixClientPeg.get().isRoomEncrypted(mxEvent.getRoomId());
+        if (mxEvent.getContent().algorithm === 'm.megolm.v1.aes-sha2' && isRoomEncrypted) {
             body = <div>
                 <div className="mx_cryptoEvent_title">{_t("Encryption enabled")}</div>
                 <div className="mx_cryptoEvent_subtitle">
@@ -38,6 +36,13 @@ export default class EncryptionEvent extends React.Component {
                     )}
                 </div>
             </div>;
+        } else if (isRoomEncrypted) {
+            body = <div>
+                <div className="mx_cryptoEvent_title">{_t("Encryption enabled")}</div>
+                <div className="mx_cryptoEvent_subtitle">
+                    {_t("Ignored attempt to disable encryption")}
+                </div>
+            </div>;
         } else {
             body = <div>
                 <div className="mx_cryptoEvent_title">{_t("Encryption not enabled")}</div>
diff --git a/src/components/views/messages/MJitsiWidgetEvent.tsx b/src/components/views/messages/MJitsiWidgetEvent.tsx
new file mode 100644
index 0000000000..3d191209f9
--- /dev/null
+++ b/src/components/views/messages/MJitsiWidgetEvent.tsx
@@ -0,0 +1,76 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { _t } from "../../../languageHandler";
+import WidgetStore from "../../../stores/WidgetStore";
+
+interface IProps {
+    mxEvent: MatrixEvent;
+}
+
+export default class MJitsiWidgetEvent extends React.PureComponent<IProps> {
+    constructor(props) {
+        super(props);
+    }
+
+    render() {
+        const url = this.props.mxEvent.getContent()['url'];
+        const prevUrl = this.props.mxEvent.getPrevContent()['url'];
+        const senderName = this.props.mxEvent.sender?.name || this.props.mxEvent.getSender();
+
+        let joinCopy = _t('Join the conference at the top of this room');
+        if (!WidgetStore.instance.isPinned(this.props.mxEvent.getStateKey())) {
+            joinCopy = _t('Join the conference from the room information card on the right');
+        }
+
+        if (!url) {
+            // removed
+            return (
+                <div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'>
+                    <div className='mx_MJitsiWidgetEvent_title'>
+                        {_t('Video conference ended by %(senderName)s', {senderName})}
+                    </div>
+                </div>
+            );
+        } else if (prevUrl) {
+            // modified
+            return (
+                <div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'>
+                    <div className='mx_MJitsiWidgetEvent_title'>
+                        {_t('Video conference updated by %(senderName)s', {senderName})}
+                    </div>
+                    <div className='mx_MJitsiWidgetEvent_subtitle'>
+                        {joinCopy}
+                    </div>
+                </div>
+            );
+        } else {
+            // assume added
+            return (
+                <div className='mx_EventTile_bubble mx_MJitsiWidgetEvent'>
+                    <div className='mx_MJitsiWidgetEvent_title'>
+                        {_t("Video conference started by %(senderName)s", {senderName})}
+                    </div>
+                    <div className='mx_MJitsiWidgetEvent_subtitle'>
+                        {joinCopy}
+                    </div>
+                </div>
+            );
+        }
+    }
+}
diff --git a/src/components/views/right_panel/BaseCard.tsx b/src/components/views/right_panel/BaseCard.tsx
index 3e95da1bc1..5927c7c3cc 100644
--- a/src/components/views/right_panel/BaseCard.tsx
+++ b/src/components/views/right_panel/BaseCard.tsx
@@ -31,6 +31,7 @@ interface IProps {
     className?: string;
     withoutScrollContainer?: boolean;
     previousPhase?: RightPanelPhases;
+    closeLabel?: string;
     onClose?(): void;
 }
 
@@ -47,6 +48,7 @@ export const Group: React.FC<IGroupProps> = ({ className, title, children }) =>
 };
 
 const BaseCard: React.FC<IProps> = ({
+    closeLabel,
     onClose,
     className,
     header,
@@ -68,7 +70,11 @@ const BaseCard: React.FC<IProps> = ({
 
     let closeButton;
     if (onClose) {
-        closeButton = <AccessibleButton className="mx_BaseCard_close" onClick={onClose} title={_t("Close")} />;
+        closeButton = <AccessibleButton
+            className="mx_BaseCard_close"
+            onClick={onClose}
+            title={closeLabel || _t("Close")}
+        />;
     }
 
     if (!withoutScrollContainer) {
diff --git a/src/components/views/right_panel/EncryptionPanel.tsx b/src/components/views/right_panel/EncryptionPanel.tsx
index df52e5cabd..c237a4ade6 100644
--- a/src/components/views/right_panel/EncryptionPanel.tsx
+++ b/src/components/views/right_panel/EncryptionPanel.tsx
@@ -27,6 +27,9 @@ import * as sdk from "../../../index";
 import {_t} from "../../../languageHandler";
 import {VerificationRequest} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
 import {RoomMember} from "matrix-js-sdk/src/models/room-member";
+import dis from "../../../dispatcher/dispatcher";
+import {Action} from "../../../dispatcher/actions";
+import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
 
 // cancellation codes which constitute a key mismatch
 const MISMATCHES = ["m.key_mismatch", "m.user_error", "m.mismatched_sas"];
@@ -42,7 +45,14 @@ interface IProps {
 }
 
 const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
-    const {verificationRequest, verificationRequestPromise, member, onClose, layout, isRoomEncrypted} = props;
+    const {
+        verificationRequest,
+        verificationRequestPromise,
+        member,
+        onClose,
+        layout,
+        isRoomEncrypted,
+    } = props;
     const [request, setRequest] = useState(verificationRequest);
     // state to show a spinner immediately after clicking "start verification",
     // before we have a request
@@ -95,22 +105,6 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
     }, [onClose, request]);
     useEventEmitter(request, "change", changeHandler);
 
-    const onCancel = useCallback(function() {
-        if (request) {
-            request.cancel();
-        }
-    }, [request]);
-
-    let cancelButton: JSX.Element;
-    if (layout !== "dialog" && request && request.pending) {
-        const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
-        cancelButton = (<AccessibleButton
-            className="mx_EncryptionPanel_cancel"
-            onClick={onCancel}
-            title={_t('Cancel')}
-        ></AccessibleButton>);
-    }
-
     const onStartVerification = useCallback(async () => {
         setRequesting(true);
         const cli = MatrixClientPeg.get();
@@ -118,7 +112,13 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
         const verificationRequest_ = await cli.requestVerificationDM(member.userId, roomId);
         setRequest(verificationRequest_);
         setPhase(verificationRequest_.phase);
-    }, [member.userId]);
+        // Notify the RightPanelStore about this
+        dis.dispatch({
+            action: Action.SetRightPanelPhase,
+            phase: RightPanelPhases.EncryptionPanel,
+            refireParams: { member, verificationRequest: verificationRequest_ },
+        });
+    }, [member]);
 
     const requested =
         (!request && isRequesting) ||
@@ -128,8 +128,7 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
         member.userId === MatrixClientPeg.get().getUserId();
     if (!request || requested) {
         const initiatedByMe = (!request && isRequesting) || (request && request.initiatedByMe);
-        return (<React.Fragment>
-            {cancelButton}
+        return (
             <EncryptionInfo
                 isRoomEncrypted={isRoomEncrypted}
                 onStartVerification={onStartVerification}
@@ -138,10 +137,9 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
                 waitingForOtherParty={requested && initiatedByMe}
                 waitingForNetwork={requested && !initiatedByMe}
                 inDialog={layout === "dialog"} />
-        </React.Fragment>);
+        );
     } else {
-        return (<React.Fragment>
-            {cancelButton}
+        return (
             <VerificationPanel
                 isRoomEncrypted={isRoomEncrypted}
                 layout={layout}
@@ -152,7 +150,7 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
                 inDialog={layout === "dialog"}
                 phase={phase}
             />
-        </React.Fragment>);
+        );
     }
 };
 
diff --git a/src/components/views/right_panel/UserInfo.js b/src/components/views/right_panel/UserInfo.tsx
similarity index 85%
rename from src/components/views/right_panel/UserInfo.js
rename to src/components/views/right_panel/UserInfo.tsx
index 8440532b9d..807bd27796 100644
--- a/src/components/views/right_panel/UserInfo.js
+++ b/src/components/views/right_panel/UserInfo.tsx
@@ -17,20 +17,22 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React, {useCallback, useMemo, useState, useEffect, useContext} from 'react';
-import PropTypes from 'prop-types';
+import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react';
 import classNames from 'classnames';
-import {Group, RoomMember, User, Room} from 'matrix-js-sdk';
+import {MatrixClient} from 'matrix-js-sdk/src/client';
+import {RoomMember} from 'matrix-js-sdk/src/models/room-member';
+import {User} from 'matrix-js-sdk/src/models/user';
+import {Room} from 'matrix-js-sdk/src/models/room';
+import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline';
+
 import dis from '../../../dispatcher/dispatcher';
 import Modal from '../../../Modal';
-import * as sdk from '../../../index';
-import { _t } from '../../../languageHandler';
+import {_t} from '../../../languageHandler';
 import createRoom, {privateShouldBeEncrypted} from '../../../createRoom';
 import DMRoomMap from '../../../utils/DMRoomMap';
 import AccessibleButton from '../elements/AccessibleButton';
 import SdkConfig from '../../../SdkConfig';
 import SettingsStore from "../../../settings/SettingsStore";
-import {EventTimeline} from "matrix-js-sdk";
 import RoomViewStore from "../../../stores/RoomViewStore";
 import MultiInviter from "../../../utils/MultiInviter";
 import GroupStore from "../../../stores/GroupStore";
@@ -41,13 +43,31 @@ import {textualPowerLevel} from '../../../Roles';
 import MatrixClientContext from "../../../contexts/MatrixClientContext";
 import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
 import EncryptionPanel from "./EncryptionPanel";
-import { useAsyncMemo } from '../../../hooks/useAsyncMemo';
-import { verifyUser, legacyVerifyUser, verifyDevice } from '../../../verification';
+import {useAsyncMemo} from '../../../hooks/useAsyncMemo';
+import {legacyVerifyUser, verifyDevice, verifyUser} from '../../../verification';
 import {Action} from "../../../dispatcher/actions";
 import {useIsEncrypted} from "../../../hooks/useIsEncrypted";
 import BaseCard from "./BaseCard";
+import {E2EStatus} from "../../../utils/ShieldUtils";
+import ImageView from "../elements/ImageView";
+import Spinner from "../elements/Spinner";
+import IconButton from "../elements/IconButton";
+import PowerSelector from "../elements/PowerSelector";
+import MemberAvatar from "../avatars/MemberAvatar";
+import PresenceLabel from "../rooms/PresenceLabel";
+import ShareDialog from "../dialogs/ShareDialog";
+import ErrorDialog from "../dialogs/ErrorDialog";
+import QuestionDialog from "../dialogs/QuestionDialog";
+import ConfirmUserActionDialog from "../dialogs/ConfirmUserActionDialog";
+import InfoDialog from "../dialogs/InfoDialog";
 
-const _disambiguateDevices = (devices) => {
+interface IDevice {
+    deviceId: string;
+    ambiguous?: boolean;
+    getDisplayName(): string;
+}
+
+const disambiguateDevices = (devices: IDevice[]) => {
     const names = Object.create(null);
     for (let i = 0; i < devices.length; i++) {
         const name = devices[i].getDisplayName();
@@ -64,11 +84,11 @@ const _disambiguateDevices = (devices) => {
     }
 };
 
-export const getE2EStatus = (cli, userId, devices) => {
+export const getE2EStatus = (cli: MatrixClient, userId: string, devices: IDevice[]): E2EStatus => {
     const isMe = userId === cli.getUserId();
     const userTrust = cli.checkUserTrust(userId);
     if (!userTrust.isCrossSigningVerified()) {
-        return userTrust.wasCrossSigningVerified() ? "warning" : "normal";
+        return userTrust.wasCrossSigningVerified() ? E2EStatus.Warning : E2EStatus.Normal;
     }
 
     const anyDeviceUnverified = devices.some(device => {
@@ -81,10 +101,10 @@ export const getE2EStatus = (cli, userId, devices) => {
         const deviceTrust = cli.checkDeviceTrust(userId, deviceId);
         return isMe ? !deviceTrust.isCrossSigningVerified() : !deviceTrust.isVerified();
     });
-    return anyDeviceUnverified ? "warning" : "verified";
+    return anyDeviceUnverified ? E2EStatus.Warning : E2EStatus.Verified;
 };
 
-async function openDMForUser(matrixClient, userId) {
+async function openDMForUser(matrixClient: MatrixClient, userId: string) {
     const dmRooms = DMRoomMap.shared().getDMRoomsForUserId(userId);
     const lastActiveRoom = dmRooms.reduce((lastActiveRoom, roomId) => {
         const room = matrixClient.getRoom(roomId);
@@ -107,6 +127,7 @@ async function openDMForUser(matrixClient, userId) {
 
     const createRoomOptions = {
         dmUserId: userId,
+        encryption: undefined,
     };
 
     if (privateShouldBeEncrypted()) {
@@ -122,10 +143,12 @@ async function openDMForUser(matrixClient, userId) {
         }
     }
 
-    createRoom(createRoomOptions);
+    return createRoom(createRoomOptions);
 }
 
-function useHasCrossSigningKeys(cli, member, canVerify, setUpdating) {
+type SetUpdating = (updating: boolean) => void;
+
+function useHasCrossSigningKeys(cli: MatrixClient, member: RoomMember, canVerify: boolean, setUpdating: SetUpdating) {
     return useAsyncMemo(async () => {
         if (!canVerify) {
             return undefined;
@@ -142,7 +165,7 @@ function useHasCrossSigningKeys(cli, member, canVerify, setUpdating) {
     }, [cli, member, canVerify], undefined);
 }
 
-function DeviceItem({userId, device}) {
+function DeviceItem({userId, device}: {userId: string, device: IDevice}) {
     const cli = useContext(MatrixClientContext);
     const isMe = userId === cli.getUserId();
     const deviceTrust = cli.checkDeviceTrust(userId, device.deviceId);
@@ -169,8 +192,8 @@ function DeviceItem({userId, device}) {
     };
 
     const deviceName = device.ambiguous ?
-            (device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" :
-            device.getDisplayName();
+        (device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" :
+        device.getDisplayName();
     let trustedLabel = null;
     if (userTrust.isVerified()) trustedLabel = isVerified ? _t("Trusted") : _t("Not trusted");
 
@@ -198,8 +221,7 @@ function DeviceItem({userId, device}) {
     }
 }
 
-function DevicesSection({devices, userId, loading}) {
-    const Spinner = sdk.getComponent("elements.Spinner");
+function DevicesSection({devices, userId, loading}: {devices: IDevice[], userId: string, loading: boolean}) {
     const cli = useContext(MatrixClientContext);
     const userTrust = cli.checkUserTrust(userId);
 
@@ -210,7 +232,7 @@ function DevicesSection({devices, userId, loading}) {
         return <Spinner />;
     }
     if (devices === null) {
-        return _t("Unable to load session list");
+        return <>{_t("Unable to load session list")}</>;
     }
     const isMe = userId === cli.getUserId();
     const deviceTrusts = devices.map(d => cli.checkDeviceTrust(userId, d.deviceId));
@@ -285,7 +307,11 @@ function DevicesSection({devices, userId, loading}) {
     );
 }
 
-const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
+const UserOptionsSection: React.FC<{
+    member: RoomMember;
+    isIgnored: boolean;
+    canInvite: boolean;
+}> = ({member, isIgnored, canInvite}) => {
     const cli = useContext(MatrixClientContext);
 
     let ignoreButton = null;
@@ -296,7 +322,6 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
     const isMe = member.userId === cli.getUserId();
 
     const onShareUserClick = () => {
-        const ShareDialog = sdk.getComponent("dialogs.ShareDialog");
         Modal.createTrackedDialog('share room member dialog', '', ShareDialog, {
             target: member,
         });
@@ -318,7 +343,10 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
         };
 
         ignoreButton = (
-            <AccessibleButton onClick={onIgnoreToggle} className={classNames("mx_UserInfo_field", {mx_UserInfo_destructive: !isIgnored})}>
+            <AccessibleButton
+                onClick={onIgnoreToggle}
+                className={classNames("mx_UserInfo_field", {mx_UserInfo_destructive: !isIgnored})}
+            >
                 { isIgnored ? _t("Unignore") : _t("Ignore") }
             </AccessibleButton>
         );
@@ -341,11 +369,14 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
                 });
             };
 
-            readReceiptButton = (
-                <AccessibleButton onClick={onReadReceiptButton} className="mx_UserInfo_field">
-                    { _t('Jump to read receipt') }
-                </AccessibleButton>
-            );
+            const room = cli.getRoom(member.roomId);
+            if (room?.getEventReadUpTo(member.userId)) {
+                readReceiptButton = (
+                    <AccessibleButton onClick={onReadReceiptButton} className="mx_UserInfo_field">
+                        { _t('Jump to read receipt') }
+                    </AccessibleButton>
+                );
+            }
 
             insertPillButton = (
                 <AccessibleButton onClick={onInsertPillButton} className={"mx_UserInfo_field"}>
@@ -367,7 +398,6 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
                         }
                     });
                 } catch (err) {
-                    const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
                     Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, {
                         title: _t('Failed to invite'),
                         description: ((err && err.message) ? err.message : _t("Operation failed")),
@@ -413,8 +443,7 @@ const UserOptionsSection = ({member, isIgnored, canInvite, devices}) => {
     );
 };
 
-const _warnSelfDemote = async () => {
-    const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
+const warnSelfDemote = async () => {
     const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, {
         title: _t("Demote yourself?"),
         description:
@@ -430,7 +459,7 @@ const _warnSelfDemote = async () => {
     return confirmed;
 };
 
-const GenericAdminToolsContainer = ({children}) => {
+const GenericAdminToolsContainer: React.FC<{}> = ({children}) => {
     return (
         <div className="mx_UserInfo_container">
             <h3>{ _t("Admin Tools") }</h3>
@@ -441,7 +470,20 @@ const GenericAdminToolsContainer = ({children}) => {
     );
 };
 
-const _isMuted = (member, powerLevelContent) => {
+interface IPowerLevelsContent {
+    events?: Record<string, number>;
+    // eslint-disable-next-line camelcase
+    users_default?: number;
+    // eslint-disable-next-line camelcase
+    events_default?: number;
+    // eslint-disable-next-line camelcase
+    state_default?: number;
+    ban?: number;
+    kick?: number;
+    redact?: number;
+}
+
+const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent) => {
     if (!powerLevelContent || !member) return false;
 
     const levelToSend = (
@@ -451,8 +493,8 @@ const _isMuted = (member, powerLevelContent) => {
     return member.powerLevel < levelToSend;
 };
 
-export const useRoomPowerLevels = (cli, room) => {
-    const [powerLevels, setPowerLevels] = useState({});
+export const useRoomPowerLevels = (cli: MatrixClient, room: Room) => {
+    const [powerLevels, setPowerLevels] = useState<IPowerLevelsContent>({});
 
     const update = useCallback(() => {
         if (!room) {
@@ -479,14 +521,19 @@ export const useRoomPowerLevels = (cli, room) => {
     return powerLevels;
 };
 
-const RoomKickButton = ({member, startUpdating, stopUpdating}) => {
+interface IBaseProps {
+    member: RoomMember;
+    startUpdating(): void;
+    stopUpdating(): void;
+}
+
+const RoomKickButton: React.FC<IBaseProps> = ({member, startUpdating, stopUpdating}) => {
     const cli = useContext(MatrixClientContext);
 
     // check if user can be kicked/disinvited
     if (member.membership !== "invite" && member.membership !== "join") return null;
 
     const onKick = async () => {
-        const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog");
         const {finished} = Modal.createTrackedDialog(
             'Confirm User Action Dialog',
             'onKick',
@@ -509,7 +556,6 @@ const RoomKickButton = ({member, startUpdating, stopUpdating}) => {
             // get out of sync if we force setState here!
             console.log("Kick success");
         }, function(err) {
-            const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
             console.error("Kick error: " + err);
             Modal.createTrackedDialog('Failed to kick', '', ErrorDialog, {
                 title: _t("Failed to kick"),
@@ -526,7 +572,7 @@ const RoomKickButton = ({member, startUpdating, stopUpdating}) => {
     </AccessibleButton>;
 };
 
-const RedactMessagesButton = ({member}) => {
+const RedactMessagesButton: React.FC<IBaseProps> = ({member}) => {
     const cli = useContext(MatrixClientContext);
 
     const onRedactAllMessages = async () => {
@@ -554,7 +600,6 @@ const RedactMessagesButton = ({member}) => {
         const user = member.name;
 
         if (count === 0) {
-            const InfoDialog = sdk.getComponent("dialogs.InfoDialog");
             Modal.createTrackedDialog('No user messages found to remove', '', InfoDialog, {
                 title: _t("No recent messages by %(user)s found", {user}),
                 description:
@@ -563,14 +608,14 @@ const RedactMessagesButton = ({member}) => {
                     </div>,
             });
         } else {
-            const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
-
             const {finished} = Modal.createTrackedDialog('Remove recent messages by user', '', QuestionDialog, {
                 title: _t("Remove recent messages by %(user)s", {user}),
                 description:
                     <div>
-                        <p>{ _t("You are about to remove %(count)s messages by %(user)s. This cannot be undone. Do you wish to continue?", {count, user}) }</p>
-                        <p>{ _t("For a large amount of messages, this might take some time. Please don't refresh your client in the meantime.") }</p>
+                        <p>{ _t("You are about to remove %(count)s messages by %(user)s. " +
+                            "This cannot be undone. Do you wish to continue?", {count, user}) }</p>
+                        <p>{ _t("For a large amount of messages, this might take some time. " +
+                            "Please don't refresh your client in the meantime.") }</p>
                     </div>,
                 button: _t("Remove %(count)s messages", {count}),
             });
@@ -603,11 +648,10 @@ const RedactMessagesButton = ({member}) => {
     </AccessibleButton>;
 };
 
-const BanToggleButton = ({member, startUpdating, stopUpdating}) => {
+const BanToggleButton: React.FC<IBaseProps> = ({member, startUpdating, stopUpdating}) => {
     const cli = useContext(MatrixClientContext);
 
     const onBanOrUnban = async () => {
-        const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog");
         const {finished} = Modal.createTrackedDialog(
             'Confirm User Action Dialog',
             'onBanOrUnban',
@@ -636,7 +680,6 @@ const BanToggleButton = ({member, startUpdating, stopUpdating}) => {
             // get out of sync if we force setState here!
             console.log("Ban success");
         }, function(err) {
-            const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
             console.error("Ban error: " + err);
             Modal.createTrackedDialog('Failed to ban user', '', ErrorDialog, {
                 title: _t("Error"),
@@ -661,22 +704,26 @@ const BanToggleButton = ({member, startUpdating, stopUpdating}) => {
     </AccessibleButton>;
 };
 
-const MuteToggleButton = ({member, room, powerLevels, startUpdating, stopUpdating}) => {
+interface IBaseRoomProps extends IBaseProps {
+    room: Room;
+    powerLevels: IPowerLevelsContent;
+}
+
+const MuteToggleButton: React.FC<IBaseRoomProps> = ({member, room, powerLevels, startUpdating, stopUpdating}) => {
     const cli = useContext(MatrixClientContext);
 
     // Don't show the mute/unmute option if the user is not in the room
     if (member.membership !== "join") return null;
 
-    const isMuted = _isMuted(member, powerLevels);
+    const muted = isMuted(member, powerLevels);
     const onMuteToggle = async () => {
-        const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
         const roomId = member.roomId;
         const target = member.userId;
 
         // if muting self, warn as it may be irreversible
         if (target === cli.getUserId()) {
             try {
-                if (!(await _warnSelfDemote())) return;
+                if (!(await warnSelfDemote())) return;
             } catch (e) {
                 console.error("Failed to warn about self demotion: ", e);
                 return;
@@ -692,7 +739,7 @@ const MuteToggleButton = ({member, room, powerLevels, startUpdating, stopUpdatin
             powerLevels.events_default
         );
         let level;
-        if (isMuted) { // unmute
+        if (muted) { // unmute
             level = levelToSend;
         } else { // mute
             level = levelToSend - 1;
@@ -718,16 +765,23 @@ const MuteToggleButton = ({member, room, powerLevels, startUpdating, stopUpdatin
     };
 
     const classes = classNames("mx_UserInfo_field", {
-        mx_UserInfo_destructive: !isMuted,
+        mx_UserInfo_destructive: !muted,
     });
 
-    const muteLabel = isMuted ? _t("Unmute") : _t("Mute");
+    const muteLabel = muted ? _t("Unmute") : _t("Mute");
     return <AccessibleButton className={classes} onClick={onMuteToggle}>
         { muteLabel }
     </AccessibleButton>;
 };
 
-const RoomAdminToolsContainer = ({room, children, member, startUpdating, stopUpdating, powerLevels}) => {
+const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
+    room,
+    children,
+    member,
+    startUpdating,
+    stopUpdating,
+    powerLevels,
+}) => {
     const cli = useContext(MatrixClientContext);
     let kickButton;
     let banButton;
@@ -786,7 +840,18 @@ const RoomAdminToolsContainer = ({room, children, member, startUpdating, stopUpd
     return <div />;
 };
 
-const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating, stopUpdating}) => {
+interface GroupMember {
+    userId: string;
+    displayname?: string; // XXX: GroupMember objects are inconsistent :((
+    avatarUrl?: string;
+}
+
+const GroupAdminToolsSection: React.FC<{
+    groupId: string;
+    groupMember: GroupMember;
+    startUpdating(): void;
+    stopUpdating(): void;
+}> = ({children, groupId, groupMember, startUpdating, stopUpdating}) => {
     const cli = useContext(MatrixClientContext);
 
     const [isPrivileged, setIsPrivileged] = useState(false);
@@ -814,8 +879,7 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating,
     }, [groupId, groupMember.userId]);
 
     if (isPrivileged) {
-        const _onKick = async () => {
-            const ConfirmUserActionDialog = sdk.getComponent("dialogs.ConfirmUserActionDialog");
+        const onKick = async () => {
             const {finished} = Modal.createDialog(ConfirmUserActionDialog, {
                 matrixClient: cli,
                 groupMember,
@@ -836,7 +900,6 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating,
                     member: null,
                 });
             }).catch((e) => {
-                const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
                 Modal.createTrackedDialog('Failed to remove user from group', '', ErrorDialog, {
                     title: _t('Error'),
                     description: isInvited ?
@@ -850,7 +913,7 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating,
         };
 
         const kickButton = (
-            <AccessibleButton className="mx_UserInfo_field mx_UserInfo_destructive" onClick={_onKick}>
+            <AccessibleButton className="mx_UserInfo_field mx_UserInfo_destructive" onClick={onKick}>
                 { isInvited ? _t('Disinvite') : _t('Remove from community') }
             </AccessibleButton>
         );
@@ -870,13 +933,7 @@ const GroupAdminToolsSection = ({children, groupId, groupMember, startUpdating,
     return <div />;
 };
 
-const GroupMember = PropTypes.shape({
-    userId: PropTypes.string.isRequired,
-    displayname: PropTypes.string, // XXX: GroupMember objects are inconsistent :((
-    avatarUrl: PropTypes.string,
-});
-
-const useIsSynapseAdmin = (cli) => {
+const useIsSynapseAdmin = (cli: MatrixClient) => {
     const [isAdmin, setIsAdmin] = useState(false);
     useEffect(() => {
         cli.isSynapseAdministrator().then((isAdmin) => {
@@ -888,14 +945,20 @@ const useIsSynapseAdmin = (cli) => {
     return isAdmin;
 };
 
-const useHomeserverSupportsCrossSigning = (cli) => {
-    return useAsyncMemo(async () => {
+const useHomeserverSupportsCrossSigning = (cli: MatrixClient) => {
+    return useAsyncMemo<boolean>(async () => {
         return cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
     }, [cli], false);
 };
 
-function useRoomPermissions(cli, room, user) {
-    const [roomPermissions, setRoomPermissions] = useState({
+interface IRoomPermissions {
+    modifyLevelMax: number;
+    canEdit: boolean;
+    canInvite: boolean;
+}
+
+function useRoomPermissions(cli: MatrixClient, room: Room, user: User): IRoomPermissions {
+    const [roomPermissions, setRoomPermissions] = useState<IRoomPermissions>({
         // modifyLevelMax is the max PL we can set this user to, typically min(their PL, our PL) && canSetPL
         modifyLevelMax: -1,
         canEdit: false,
@@ -940,7 +1003,7 @@ function useRoomPermissions(cli, room, user) {
         updateRoomPermissions();
         return () => {
             setRoomPermissions({
-                maximalPowerLevel: -1,
+                modifyLevelMax: -1,
                 canEdit: false,
                 canInvite: false,
             });
@@ -950,14 +1013,18 @@ function useRoomPermissions(cli, room, user) {
     return roomPermissions;
 }
 
-const PowerLevelSection = ({user, room, roomPermissions, powerLevels}) => {
+const PowerLevelSection: React.FC<{
+    user: User;
+    room: Room;
+    roomPermissions: IRoomPermissions;
+    powerLevels: IPowerLevelsContent;
+}> = ({user, room, roomPermissions, powerLevels}) => {
     const [isEditing, setEditing] = useState(false);
     if (isEditing) {
         return (<PowerLevelEditor
             user={user} room={room} roomPermissions={roomPermissions}
             onFinished={() => setEditing(false)} />);
     } else {
-        const IconButton = sdk.getComponent('elements.IconButton');
         const powerLevelUsersDefault = powerLevels.users_default || 0;
         const powerLevel = parseInt(user.powerLevel, 10);
         const modifyButton = roomPermissions.canEdit ?
@@ -975,7 +1042,12 @@ const PowerLevelSection = ({user, room, roomPermissions, powerLevels}) => {
     }
 };
 
-const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
+const PowerLevelEditor: React.FC<{
+    user: User;
+    room: Room;
+    roomPermissions: IRoomPermissions;
+    onFinished(): void;
+}> = ({user, room, roomPermissions, onFinished}) => {
     const cli = useContext(MatrixClientContext);
 
     const [isUpdating, setIsUpdating] = useState(false);
@@ -994,7 +1066,6 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
                     // get out of sync if we force setState here!
                     console.log("Power change success");
                 }, function(err) {
-                    const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
                     console.error("Failed to change power level " + err);
                     Modal.createTrackedDialog('Failed to change power level', '', ErrorDialog, {
                         title: _t("Error"),
@@ -1025,12 +1096,10 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
             }
 
             const myUserId = cli.getUserId();
-            const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
-
             // If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
             if (myUserId === target) {
                 try {
-                    if (!(await _warnSelfDemote())) return;
+                    if (!(await warnSelfDemote())) return;
                 } catch (e) {
                     console.error("Failed to warn about self demotion: ", e);
                 }
@@ -1039,7 +1108,7 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
             }
 
             const myPower = powerLevelEvent.getContent().users[myUserId];
-            if (parseInt(myPower) === parseInt(powerLevel)) {
+            if (parseInt(myPower) === powerLevel) {
                 const {finished} = Modal.createTrackedDialog('Promote to PL100 Warning', '', QuestionDialog, {
                     title: _t("Warning!"),
                     description:
@@ -1062,12 +1131,9 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
 
     const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
     const powerLevelUsersDefault = powerLevelEvent ? powerLevelEvent.getContent().users_default : 0;
-    const IconButton = sdk.getComponent('elements.IconButton');
-    const Spinner = sdk.getComponent("elements.Spinner");
     const buttonOrSpinner = isUpdating ? <Spinner w={16} h={16} /> :
         <IconButton icon="check" onClick={changePowerLevel} />;
 
-    const PowerSelector = sdk.getComponent('elements.PowerSelector');
     return (
         <div className="mx_UserInfo_profileField">
             <PowerSelector
@@ -1083,7 +1149,7 @@ const PowerLevelEditor = ({user, room, roomPermissions, onFinished}) => {
     );
 };
 
-export const useDevices = (userId) => {
+export const useDevices = (userId: string) => {
     const cli = useContext(MatrixClientContext);
 
     // undefined means yet to be loaded, null means failed to load, otherwise list of devices
@@ -1094,7 +1160,7 @@ export const useDevices = (userId) => {
 
         let cancelled = false;
 
-        async function _downloadDeviceList() {
+        async function downloadDeviceList() {
             try {
                 await cli.downloadKeys([userId], true);
                 const devices = cli.getStoredDevicesForUser(userId);
@@ -1104,13 +1170,13 @@ export const useDevices = (userId) => {
                     return;
                 }
 
-                _disambiguateDevices(devices);
+                disambiguateDevices(devices);
                 setDevices(devices);
             } catch (err) {
                 setDevices(null);
             }
         }
-        _downloadDeviceList();
+        downloadDeviceList();
 
         // Handle being unmounted
         return () => {
@@ -1153,7 +1219,13 @@ export const useDevices = (userId) => {
     return devices;
 };
 
-const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
+const BasicUserInfo: React.FC<{
+    room: Room;
+    member: User | RoomMember;
+    groupId: string;
+    devices: IDevice[];
+    isRoomEncrypted: boolean;
+}> = ({room, member, groupId, devices, isRoomEncrypted}) => {
     const cli = useContext(MatrixClientContext);
 
     const powerLevels = useRoomPowerLevels(cli, room);
@@ -1186,7 +1258,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
     const roomPermissions = useRoomPermissions(cli, room, member);
 
     const onSynapseDeactivate = useCallback(async () => {
-        const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog');
         const {finished} = Modal.createTrackedDialog('Synapse User Deactivation', '', QuestionDialog, {
             title: _t("Deactivate user?"),
             description:
@@ -1207,7 +1278,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
             console.error("Failed to deactivate user");
             console.error(err);
 
-            const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
             Modal.createTrackedDialog('Failed to deactivate Synapse user', '', ErrorDialog, {
                 title: _t('Failed to deactivate user'),
                 description: ((err && err.message) ? err.message : _t("Operation failed")),
@@ -1260,8 +1330,7 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
     }
 
     if (pendingUpdateCount > 0) {
-        const Loader = sdk.getComponent("elements.Spinner");
-        spinner = <Loader imgClassName="mx_ContextualMenu_spinner" />;
+        spinner = <Spinner imgClassName="mx_ContextualMenu_spinner" />;
     }
 
     let memberDetails;
@@ -1324,7 +1393,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
             // HACK: only show a spinner if the device section spinner is not shown,
             // to avoid showing a double spinner
             // We should ask for a design that includes all the different loading states here
-            const Spinner = sdk.getComponent('elements.Spinner');
             verifyButton = <Spinner />;
         }
     }
@@ -1351,7 +1419,6 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
 
         { securitySection }
         <UserOptionsSection
-            devices={devices}
             canInvite={roomPermissions.canInvite}
             isIgnored={isIgnored}
             member={member} />
@@ -1362,7 +1429,12 @@ const BasicUserInfo = ({room, member, groupId, devices, isRoomEncrypted}) => {
     </React.Fragment>;
 };
 
-const UserInfoHeader = ({member, e2eStatus}) => {
+type Member = User | RoomMember | GroupMember;
+
+const UserInfoHeader: React.FC<{
+    member: Member;
+    e2eStatus: E2EStatus;
+}> = ({member, e2eStatus}) => {
     const cli = useContext(MatrixClientContext);
 
     const onMemberAvatarClick = useCallback(() => {
@@ -1370,7 +1442,6 @@ const UserInfoHeader = ({member, e2eStatus}) => {
         if (!avatarUrl) return;
 
         const httpUrl = cli.mxcUrlToHttp(avatarUrl);
-        const ImageView = sdk.getComponent("elements.ImageView");
         const params = {
             src: httpUrl,
             name: member.name,
@@ -1379,7 +1450,6 @@ const UserInfoHeader = ({member, e2eStatus}) => {
         Modal.createDialog(ImageView, params, "mx_Dialog_lightbox");
     }, [cli, member]);
 
-    const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
     const avatarElement = (
         <div className="mx_UserInfo_avatar">
             <div>
@@ -1421,10 +1491,13 @@ const UserInfoHeader = ({member, e2eStatus}) => {
 
     let presenceLabel = null;
     if (showPresence) {
-        const PresenceLabel = sdk.getComponent('rooms.PresenceLabel');
-        presenceLabel = <PresenceLabel activeAgo={presenceLastActiveAgo}
-                                       currentlyActive={presenceCurrentlyActive}
-                                       presenceState={presenceState} />;
+        presenceLabel = (
+            <PresenceLabel
+                activeAgo={presenceLastActiveAgo}
+                currentlyActive={presenceCurrentlyActive}
+                presenceState={presenceState}
+            />
+        );
     }
 
     let statusLabel = null;
@@ -1461,7 +1534,32 @@ const UserInfoHeader = ({member, e2eStatus}) => {
     </React.Fragment>;
 };
 
-const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemberInfo, ...props}) => {
+interface IProps {
+    user: Member;
+    groupId?: string;
+    room?: Room;
+    phase: RightPanelPhases.RoomMemberInfo | RightPanelPhases.GroupMemberInfo;
+    onClose(): void;
+}
+
+interface IPropsWithEncryptionPanel extends React.ComponentProps<typeof EncryptionPanel> {
+    user: Member;
+    groupId: void;
+    room: Room;
+    phase: RightPanelPhases.EncryptionPanel;
+    onClose(): void;
+}
+
+type Props = IProps | IPropsWithEncryptionPanel;
+
+const UserInfo: React.FC<Props> = ({
+    user,
+    groupId,
+    room,
+    onClose,
+    phase = RightPanelPhases.RoomMemberInfo,
+    ...props
+}) => {
     const cli = useContext(MatrixClientContext);
 
     // fetch latest room member if we have a room, so we don't show historical information, falling back to user
@@ -1485,7 +1583,7 @@ const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemb
                 <BasicUserInfo
                     room={room}
                     member={member}
-                    groupId={groupId}
+                    groupId={groupId as string}
                     devices={devices}
                     isRoomEncrypted={isRoomEncrypted} />
             );
@@ -1493,7 +1591,12 @@ const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemb
         case RightPanelPhases.EncryptionPanel:
             classes.push("mx_UserInfo_smallAvatar");
             content = (
-                <EncryptionPanel {...props} member={member} onClose={onClose} isRoomEncrypted={isRoomEncrypted} />
+                <EncryptionPanel
+                    {...props as React.ComponentProps<typeof EncryptionPanel>}
+                    member={member}
+                    onClose={onClose}
+                    isRoomEncrypted={isRoomEncrypted}
+                />
             );
             break;
     }
@@ -1504,23 +1607,24 @@ const UserInfo = ({user, groupId, room, onClose, phase=RightPanelPhases.RoomMemb
         previousPhase = RightPanelPhases.RoomMemberList;
     }
 
-    const header = <UserInfoHeader member={member} e2eStatus={e2eStatus} onClose={onClose} />;
-    return <BaseCard className={classes.join(" ")} header={header} onClose={onClose} previousPhase={previousPhase}>
+    let closeLabel = undefined;
+    if (phase === RightPanelPhases.EncryptionPanel) {
+        const verificationRequest = (props as React.ComponentProps<typeof EncryptionPanel>).verificationRequest;
+        if (verificationRequest && verificationRequest.pending) {
+            closeLabel = _t("Cancel");
+        }
+    }
+
+    const header = <UserInfoHeader member={member} e2eStatus={e2eStatus} />;
+    return <BaseCard
+        className={classes.join(" ")}
+        header={header}
+        onClose={onClose}
+        closeLabel={closeLabel}
+        previousPhase={previousPhase}
+    >
         { content }
     </BaseCard>;
 };
 
-UserInfo.propTypes = {
-    user: PropTypes.oneOfType([
-        PropTypes.instanceOf(User),
-        PropTypes.instanceOf(RoomMember),
-        GroupMember,
-    ]).isRequired,
-    group: PropTypes.instanceOf(Group),
-    groupId: PropTypes.string,
-    room: PropTypes.instanceOf(Room),
-
-    onClose: PropTypes.func,
-};
-
 export default UserInfo;
diff --git a/src/components/views/right_panel/WidgetCard.tsx b/src/components/views/right_panel/WidgetCard.tsx
index 230e71c000..104a9b9878 100644
--- a/src/components/views/right_panel/WidgetCard.tsx
+++ b/src/components/views/right_panel/WidgetCard.tsx
@@ -32,6 +32,9 @@ import WidgetStore from "../../../stores/WidgetStore";
 import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu";
 import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
 import classNames from "classnames";
+import dis from "../../../dispatcher/dispatcher";
+import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
+import { MatrixCapabilities } from "matrix-widget-api";
 import RoomWidgetContextMenu from "../context_menus/RoomWidgetContextMenu";
 
 interface IProps {
diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx
index 7c2eb83a94..d9b34b93ef 100644
--- a/src/components/views/rooms/BasicMessageComposer.tsx
+++ b/src/components/views/rooms/BasicMessageComposer.tsx
@@ -619,13 +619,14 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
     }
 
     private onFormatAction = (action: Formatting) => {
-        const range = getRangeForSelection(
-            this.editorRef.current,
-            this.props.model,
-            document.getSelection());
+        const range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection());
+        // trim the range as we want it to exclude leading/trailing spaces
+        range.trim();
+
         if (range.length === 0) {
             return;
         }
+
         this.historyManager.ensureLastChangesPushed(this.props.model);
         this.modifiedFlag = true;
         switch (action) {
diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js
index a1cc681a4c..c2b1af2ddc 100644
--- a/src/components/views/rooms/EventTile.js
+++ b/src/components/views/rooms/EventTile.js
@@ -34,6 +34,7 @@ import * as ObjectUtils from "../../../ObjectUtils";
 import MatrixClientContext from "../../../contexts/MatrixClientContext";
 import {E2E_STATE} from "./E2EIcon";
 import {toRem} from "../../../utils/units";
+import {WidgetType} from "../../../widgets/WidgetType";
 import RoomAvatar from "../avatars/RoomAvatar";
 
 const eventTileTypes = {
@@ -111,6 +112,19 @@ export function getHandlerTile(ev) {
         }
     }
 
+    // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
+    if (type === "im.vector.modular.widgets") {
+        let type = ev.getContent()['type'];
+        if (!type) {
+            // deleted/invalid widget - try the past widget type
+            type = ev.getPrevContent()['type'];
+        }
+
+        if (WidgetType.JITSI.matches(type)) {
+            return "messages.MJitsiWidgetEvent";
+        }
+    }
+
     return ev.isState() ? stateEventTileTypes[type] : eventTileTypes[type];
 }
 
@@ -627,16 +641,18 @@ export default class EventTile extends React.Component {
         const msgtype = content.msgtype;
         const eventType = this.props.mxEvent.getType();
 
+        let tileHandler = getHandlerTile(this.props.mxEvent);
+
         // Info messages are basically information about commands processed on a room
         const isBubbleMessage = eventType.startsWith("m.key.verification") ||
             (eventType === "m.room.message" && msgtype && msgtype.startsWith("m.key.verification")) ||
-            (eventType === "m.room.encryption");
+            (eventType === "m.room.encryption") ||
+            (tileHandler === "messages.MJitsiWidgetEvent");
         let isInfoMessage = (
             !isBubbleMessage && eventType !== 'm.room.message' &&
             eventType !== 'm.sticker' && eventType !== 'm.room.create'
         );
 
-        let tileHandler = getHandlerTile(this.props.mxEvent);
         // If we're showing hidden events in the timeline, we should use the
         // source tile when there's no regular tile for an event and also for
         // replace relations (which otherwise would display as a confusing
@@ -902,6 +918,7 @@ export default class EventTile extends React.Component {
                                            highlights={this.props.highlights}
                                            highlightLink={this.props.highlightLink}
                                            onHeightChanged={this.props.onHeightChanged}
+                                           replacingEventId={this.props.replacingEventId}
                                            showUrlPreview={false} />
                         </div>
                     </div>
diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js
index ae122a3783..9da6e22847 100644
--- a/src/components/views/rooms/MemberList.js
+++ b/src/components/views/rooms/MemberList.js
@@ -121,8 +121,8 @@ export default class MemberList extends React.Component {
                     this.setState(this._getMembersState(this.roomMembers()));
                     this._listenForMembersChanges();
                 }
-            } else if (membership === "invite") {
-                // show the members we've got when invited
+            } else {
+                // show the members we already have loaded
                 this.setState(this._getMembersState(this.roomMembers()));
             }
         }
diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js
index e6cd686e3c..71999fb04f 100644
--- a/src/components/views/rooms/MessageComposer.js
+++ b/src/components/views/rooms/MessageComposer.js
@@ -1,6 +1,7 @@
 /*
 Copyright 2015, 2016 OpenMarket Ltd
 Copyright 2017, 2018 New Vector Ltd
+Copyright 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -32,6 +33,10 @@ import {aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu} from
 import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
 import ReplyPreview from "./ReplyPreview";
 import {UIFeature} from "../../../settings/UIFeature";
+import WidgetStore from "../../../stores/WidgetStore";
+import WidgetUtils from "../../../utils/WidgetUtils";
+import {UPDATE_EVENT} from "../../../stores/AsyncStore";
+import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
 
 function ComposerAvatar(props) {
     const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
@@ -85,8 +90,15 @@ VideoCallButton.propTypes = {
 };
 
 function HangupButton(props) {
-    const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
     const onHangupClick = () => {
+        if (props.isConference) {
+            dis.dispatch({
+                action: props.canEndConference ? 'end_conference' : 'hangup_conference',
+                room_id: props.roomId,
+            });
+            return;
+        }
+
         const call = CallHandler.sharedInstance().getCallForRoom(props.roomId);
         if (!call) {
             return;
@@ -98,14 +110,28 @@ function HangupButton(props) {
             room_id: call.roomId,
         });
     };
-    return (<AccessibleButton className="mx_MessageComposer_button mx_MessageComposer_hangup"
+
+    let tooltip = _t("Hangup");
+    if (props.isConference && props.canEndConference) {
+        tooltip = _t("End conference");
+    }
+
+    const canLeaveConference = !props.isConference ? true : props.isInConference;
+    return (
+        <AccessibleTooltipButton
+            className="mx_MessageComposer_button mx_MessageComposer_hangup"
             onClick={onHangupClick}
-            title={_t('Hangup')}
-        />);
+            title={tooltip}
+            disabled={!canLeaveConference}
+        />
+    );
 }
 
 HangupButton.propTypes = {
     roomId: PropTypes.string.isRequired,
+    isConference: PropTypes.bool.isRequired,
+    canEndConference: PropTypes.bool,
+    isInConference: PropTypes.bool,
 };
 
 const EmojiButton = ({addEmoji}) => {
@@ -226,12 +252,17 @@ export default class MessageComposer extends React.Component {
         this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
         this._onTombstoneClick = this._onTombstoneClick.bind(this);
         this.renderPlaceholderText = this.renderPlaceholderText.bind(this);
+        WidgetStore.instance.on(UPDATE_EVENT, this._onWidgetUpdate);
+        ActiveWidgetStore.on('update', this._onActiveWidgetUpdate);
         this._dispatcherRef = null;
+
         this.state = {
             isQuoting: Boolean(RoomViewStore.getQuotingEvent()),
             tombstone: this._getRoomTombstone(),
             canSendMessages: this.props.room.maySendMessage(),
             showCallButtons: SettingsStore.getValue("showCallButtonsInComposer"),
+            hasConference: WidgetStore.instance.doesRoomHaveConference(this.props.room),
+            joinedConference: WidgetStore.instance.isJoinedToConferenceIn(this.props.room),
         };
     }
 
@@ -247,6 +278,14 @@ export default class MessageComposer extends React.Component {
         }
     };
 
+    _onWidgetUpdate = () => {
+        this.setState({hasConference: WidgetStore.instance.doesRoomHaveConference(this.props.room)});
+    };
+
+    _onActiveWidgetUpdate = () => {
+        this.setState({joinedConference: WidgetStore.instance.isJoinedToConferenceIn(this.props.room)});
+    };
+
     componentDidMount() {
         this.dispatcherRef = dis.register(this.onAction);
         MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents);
@@ -277,6 +316,8 @@ export default class MessageComposer extends React.Component {
         if (this._roomStoreToken) {
             this._roomStoreToken.remove();
         }
+        WidgetStore.instance.removeListener(UPDATE_EVENT, this._onWidgetUpdate);
+        ActiveWidgetStore.removeListener('update', this._onActiveWidgetUpdate);
         dis.unregister(this.dispatcherRef);
     }
 
@@ -392,9 +433,19 @@ export default class MessageComposer extends React.Component {
             }
 
             if (this.state.showCallButtons) {
-                if (callInProgress) {
+                if (this.state.hasConference) {
+                    const canEndConf = WidgetUtils.canUserModifyWidgets(this.props.room.roomId);
                     controls.push(
-                        <HangupButton key="controls_hangup" roomId={this.props.room.roomId} />,
+                        <HangupButton
+                            roomId={this.props.room.roomId}
+                            isConference={true}
+                            canEndConference={canEndConf}
+                            isInConference={this.state.joinedConference}
+                        />,
+                    );
+                } else if (callInProgress) {
+                    controls.push(
+                        <HangupButton key="controls_hangup" roomId={this.props.room.roomId} isConference={false} />,
                     );
                 } else {
                     controls.push(
diff --git a/src/components/views/rooms/Stickerpicker.js b/src/components/views/rooms/Stickerpicker.js
index dba25a94cf..2faa0fea27 100644
--- a/src/components/views/rooms/Stickerpicker.js
+++ b/src/components/views/rooms/Stickerpicker.js
@@ -22,7 +22,6 @@ import * as sdk from '../../../index';
 import dis from '../../../dispatcher/dispatcher';
 import AccessibleButton from '../elements/AccessibleButton';
 import WidgetUtils from '../../../utils/WidgetUtils';
-import ActiveWidgetStore from '../../../stores/ActiveWidgetStore';
 import PersistedElement from "../elements/PersistedElement";
 import {IntegrationManagers} from "../../../integrations/IntegrationManagers";
 import SettingsStore from "../../../settings/SettingsStore";
@@ -30,6 +29,7 @@ import {ContextMenu} from "../../structures/ContextMenu";
 import {WidgetType} from "../../../widgets/WidgetType";
 import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
 import {Action} from "../../../dispatcher/actions";
+import {WidgetMessagingStore} from "../../../stores/widgets/WidgetMessagingStore";
 
 // This should be below the dialog level (4000), but above the rest of the UI (1000-2000).
 // We sit in a context menu, so this should be given to the context menu.
@@ -212,9 +212,11 @@ export default class Stickerpicker extends React.Component {
 
     _sendVisibilityToWidget(visible) {
         if (!this.state.stickerpickerWidget) return;
-        const widgetMessaging = ActiveWidgetStore.getWidgetMessaging(this.state.stickerpickerWidget.id);
-        if (widgetMessaging && visible !== this._prevSentVisibility) {
-            widgetMessaging.sendVisibility(visible);
+        const messaging = WidgetMessagingStore.instance.getMessagingForId(this.state.stickerpickerWidget.id);
+        if (messaging && visible !== this._prevSentVisibility) {
+            messaging.updateVisibility(visible).catch(err => {
+                console.error("Error updating widget visibility: ", err);
+            });
             this._prevSentVisibility = visible;
         }
     }
diff --git a/src/components/views/settings/ChangePassword.js b/src/components/views/settings/ChangePassword.js
index 0b62f1fa81..8ae000f087 100644
--- a/src/components/views/settings/ChangePassword.js
+++ b/src/components/views/settings/ChangePassword.js
@@ -35,6 +35,7 @@ export default class ChangePassword extends React.Component {
         rowClassName: PropTypes.string,
         buttonClassName: PropTypes.string,
         buttonKind: PropTypes.string,
+        buttonLabel: PropTypes.string,
         confirm: PropTypes.bool,
         // Whether to autoFocus the new password input
         autoFocusNewPasswordInput: PropTypes.bool,
@@ -271,7 +272,7 @@ export default class ChangePassword extends React.Component {
                             />
                         </div>
                         <AccessibleButton className={buttonClassName} kind={this.props.buttonKind} onClick={this.onClickChange}>
-                            { _t('Change Password') }
+                            { this.props.buttonLabel || _t('Change Password') }
                         </AccessibleButton>
                     </form>
                 );
diff --git a/src/components/views/settings/ProfileSettings.js b/src/components/views/settings/ProfileSettings.js
index 651aa9f48d..92851ccaa0 100644
--- a/src/components/views/settings/ProfileSettings.js
+++ b/src/components/views/settings/ProfileSettings.js
@@ -18,30 +18,23 @@ import React, {createRef} from 'react';
 import {_t} from "../../../languageHandler";
 import {MatrixClientPeg} from "../../../MatrixClientPeg";
 import Field from "../elements/Field";
-import {User} from "matrix-js-sdk";
 import { getHostingLink } from '../../../utils/HostingLink';
 import * as sdk from "../../../index";
+import {OwnProfileStore} from "../../../stores/OwnProfileStore";
+import Modal from "../../../Modal";
+import ErrorDialog from "../dialogs/ErrorDialog";
 
 export default class ProfileSettings extends React.Component {
     constructor() {
         super();
 
         const client = MatrixClientPeg.get();
-        let user = client.getUser(client.getUserId());
-        if (!user) {
-            // XXX: We shouldn't have to do this.
-            // There seems to be a condition where the User object won't exist until a room
-            // exists on the account. To work around this, we'll just create a temporary User
-            // and use that.
-            console.warn("User object not found - creating one for ProfileSettings");
-            user = new User(client.getUserId());
-        }
-        let avatarUrl = user.avatarUrl;
+        let avatarUrl = OwnProfileStore.instance.avatarMxc;
         if (avatarUrl) avatarUrl = client.mxcUrlToHttp(avatarUrl, 96, 96, 'crop', false);
         this.state = {
-            userId: user.userId,
-            originalDisplayName: user.rawDisplayName,
-            displayName: user.rawDisplayName,
+            userId: client.getUserId(),
+            originalDisplayName: OwnProfileStore.instance.displayName,
+            displayName: OwnProfileStore.instance.displayName,
             originalAvatarUrl: avatarUrl,
             avatarUrl: avatarUrl,
             avatarFile: null,
@@ -84,21 +77,26 @@ export default class ProfileSettings extends React.Component {
         const client = MatrixClientPeg.get();
         const newState = {};
 
-        // TODO: What do we do about errors?
+        try {
+            if (this.state.originalDisplayName !== this.state.displayName) {
+                await client.setDisplayName(this.state.displayName);
+                newState.originalDisplayName = this.state.displayName;
+            }
 
-        if (this.state.originalDisplayName !== this.state.displayName) {
-            await client.setDisplayName(this.state.displayName);
-            newState.originalDisplayName = this.state.displayName;
-        }
-
-        if (this.state.avatarFile) {
-            const uri = await client.uploadContent(this.state.avatarFile);
-            await client.setAvatarUrl(uri);
-            newState.avatarUrl = client.mxcUrlToHttp(uri, 96, 96, 'crop', false);
-            newState.originalAvatarUrl = newState.avatarUrl;
-            newState.avatarFile = null;
-        } else if (this.state.originalAvatarUrl !== this.state.avatarUrl) {
-            await client.setAvatarUrl(""); // use empty string as Synapse 500s on undefined
+            if (this.state.avatarFile) {
+                const uri = await client.uploadContent(this.state.avatarFile);
+                await client.setAvatarUrl(uri);
+                newState.avatarUrl = client.mxcUrlToHttp(uri, 96, 96, 'crop', false);
+                newState.originalAvatarUrl = newState.avatarUrl;
+                newState.avatarFile = null;
+            } else if (this.state.originalAvatarUrl !== this.state.avatarUrl) {
+                await client.setAvatarUrl(""); // use empty string as Synapse 500s on undefined
+            }
+        } catch (err) {
+            Modal.createTrackedDialog('Failed to save profile', '', ErrorDialog, {
+                title: _t("Failed to save your profile"),
+                description: ((err && err.message) ? err.message : _t("The operation could not be completed")),
+            });
         }
 
         this.setState(newState);
diff --git a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.js b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.js
index 99352a452e..49d683c42a 100644
--- a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.js
+++ b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.js
@@ -239,7 +239,7 @@ export default class RolesRoomSettingsTab extends React.Component {
                 defaultValue: 50,
             },
             "redact": {
-                desc: _t('Remove messages'),
+                desc: _t('Remove messages sent by others'),
                 defaultValue: 50,
             },
             "notifications.room": {
diff --git a/src/createRoom.ts b/src/createRoom.ts
index 09de265ebc..34eb65df4e 100644
--- a/src/createRoom.ts
+++ b/src/createRoom.ts
@@ -275,12 +275,17 @@ export async function _waitForMember(client: MatrixClient, roomId: string, userI
  * can encrypt to.
  */
 export async function canEncryptToAllUsers(client: MatrixClient, userIds: string[]) {
-    const usersDeviceMap = await client.downloadKeys(userIds);
-    // { "@user:host": { "DEVICE": {...}, ... }, ... }
-    return Object.values(usersDeviceMap).every((userDevices) =>
-        // { "DEVICE": {...}, ... }
-        Object.keys(userDevices).length > 0,
-    );
+    try {
+        const usersDeviceMap = await client.downloadKeys(userIds);
+        // { "@user:host": { "DEVICE": {...}, ... }, ... }
+        return Object.values(usersDeviceMap).every((userDevices) =>
+            // { "DEVICE": {...}, ... }
+            Object.keys(userDevices).length > 0,
+        );
+    } catch (e) {
+        console.error("Error determining if it's possible to encrypt to all users: ", e);
+        return false; // assume not
+    }
 }
 
 export async function ensureDMExists(client: MatrixClient, userId: string): Promise<string> {
@@ -289,9 +294,9 @@ export async function ensureDMExists(client: MatrixClient, userId: string): Prom
     if (existingDMRoom) {
         roomId = existingDMRoom.roomId;
     } else {
-        let encryption;
+        let encryption: boolean = undefined;
         if (privateShouldBeEncrypted()) {
-            encryption = canEncryptToAllUsers(client, [userId]);
+            encryption = await canEncryptToAllUsers(client, [userId]);
         }
         roomId = await createRoom({encryption, dmUserId: userId, spinner: false, andView: false});
         await _waitForMember(client, roomId, userId);
diff --git a/src/editor/range.ts b/src/editor/range.ts
index 27f59f34a9..838dfd8b98 100644
--- a/src/editor/range.ts
+++ b/src/editor/range.ts
@@ -18,6 +18,10 @@ import EditorModel from "./model";
 import DocumentPosition, {Predicate} from "./position";
 import {Part} from "./parts";
 
+const whitespacePredicate: Predicate = (index, offset, part) => {
+    return part.text[offset].trim() === "";
+};
+
 export default class Range {
     private _start: DocumentPosition;
     private _end: DocumentPosition;
@@ -35,6 +39,11 @@ export default class Range {
         });
     }
 
+    trim() {
+        this._start = this._start.forwardsWhile(this.model, whitespacePredicate);
+        this._end = this._end.backwardsWhile(this.model, whitespacePredicate);
+    }
+
     expandBackwardsWhile(predicate: Predicate) {
         this._start = this._start.backwardsWhile(this.model, predicate);
     }
diff --git a/src/hooks/useAsyncMemo.ts b/src/hooks/useAsyncMemo.ts
index 11c7aca7f1..38c70de259 100644
--- a/src/hooks/useAsyncMemo.ts
+++ b/src/hooks/useAsyncMemo.ts
@@ -18,8 +18,8 @@ import {useState, useEffect, DependencyList} from 'react';
 
 type Fn<T> = () => Promise<T>;
 
-export const useAsyncMemo = <T>(fn: Fn<T>, deps: DependencyList, initialValue?: T) => {
-    const [value, setValue] = useState(initialValue);
+export const useAsyncMemo = <T>(fn: Fn<T>, deps: DependencyList, initialValue?: T): T => {
+    const [value, setValue] = useState<T>(initialValue);
     useEffect(() => {
         fn().then(setValue);
     }, deps); // eslint-disable-line react-hooks/exhaustive-deps
diff --git a/src/hooks/useStateToggle.ts b/src/hooks/useStateToggle.ts
index 85441df328..b50a923234 100644
--- a/src/hooks/useStateToggle.ts
+++ b/src/hooks/useStateToggle.ts
@@ -14,11 +14,11 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import {useState} from "react";
+import {Dispatch, SetStateAction, useState} from "react";
 
 // Hook to simplify toggling of a boolean state value
 // Returns value, method to toggle boolean value and method to set the boolean value
-export const useStateToggle = (initialValue: boolean) => {
+export const useStateToggle = (initialValue: boolean): [boolean, () => void, Dispatch<SetStateAction<boolean>>] => {
     const [value, setValue] = useState(initialValue);
     const toggleValue = () => {
         setValue(!value);
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index f1c8317c3c..44b410d9a9 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -50,12 +50,10 @@
     "You cannot place a call with yourself.": "You cannot place a call with yourself.",
     "Call in Progress": "Call in Progress",
     "A call is currently being placed!": "A call is currently being placed!",
-    "End Call": "End Call",
-    "Remove the group call from the room?": "Remove the group call from the room?",
-    "Cancel": "Cancel",
-    "You don't have permission to remove the call from the room": "You don't have permission to remove the call from the room",
     "Permission Required": "Permission Required",
     "You do not have permission to start a conference call in this room": "You do not have permission to start a conference call in this room",
+    "End conference": "End conference",
+    "This will end the conference for everyone. Continue?": "This will end the conference for everyone. Continue?",
     "Replying With Files": "Replying With Files",
     "At this time it is not possible to reply with a file. Would you like to upload this file without replying?": "At this time it is not possible to reply with a file. Would you like to upload this file without replying?",
     "Continue": "Continue",
@@ -143,6 +141,7 @@
     "Cancel entering passphrase?": "Cancel entering passphrase?",
     "Are you sure you want to cancel entering passphrase?": "Are you sure you want to cancel entering passphrase?",
     "Go Back": "Go Back",
+    "Cancel": "Cancel",
     "Setting up keys": "Setting up keys",
     "Messages": "Messages",
     "Actions": "Actions",
@@ -277,9 +276,6 @@
     "%(widgetName)s widget modified by %(senderName)s": "%(widgetName)s widget modified by %(senderName)s",
     "%(widgetName)s widget added by %(senderName)s": "%(widgetName)s widget added by %(senderName)s",
     "%(widgetName)s widget removed by %(senderName)s": "%(widgetName)s widget removed by %(senderName)s",
-    "Group call modified by %(senderName)s": "Group call modified by %(senderName)s",
-    "Group call started by %(senderName)s": "Group call started by %(senderName)s",
-    "Group call ended by %(senderName)s": "Group call ended by %(senderName)s",
     "%(senderName)s removed the rule banning users matching %(glob)s": "%(senderName)s removed the rule banning users matching %(glob)s",
     "%(senderName)s removed the rule banning rooms matching %(glob)s": "%(senderName)s removed the rule banning rooms matching %(glob)s",
     "%(senderName)s removed the rule banning servers matching %(glob)s": "%(senderName)s removed the rule banning servers matching %(glob)s",
@@ -456,6 +452,7 @@
     "Support adding custom themes": "Support adding custom themes",
     "Show message previews for reactions in DMs": "Show message previews for reactions in DMs",
     "Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms",
+    "Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices",
     "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",
     "Font size": "Font size",
@@ -718,6 +715,8 @@
     "Off": "Off",
     "On": "On",
     "Noisy": "Noisy",
+    "Failed to save your profile": "Failed to save your profile",
+    "The operation could not be completed": "The operation could not be completed",
     "<a>Upgrade</a> to your own domain": "<a>Upgrade</a> to your own domain",
     "Profile": "Profile",
     "Display Name": "Display Name",
@@ -971,7 +970,7 @@
     "Change settings": "Change settings",
     "Kick users": "Kick users",
     "Ban users": "Ban users",
-    "Remove messages": "Remove messages",
+    "Remove messages sent by others": "Remove messages sent by others",
     "Notify everyone": "Notify everyone",
     "No users have specific privileges in this room": "No users have specific privileges in this room",
     "Privileged Users": "Privileged Users",
@@ -1378,6 +1377,7 @@
     "View Source": "View Source",
     "Encryption enabled": "Encryption enabled",
     "Messages in this room are end-to-end encrypted. Learn more & verify this user in their user profile.": "Messages in this room are end-to-end encrypted. Learn more & verify this user in their user profile.",
+    "Ignored attempt to disable encryption": "Ignored attempt to disable encryption",
     "Encryption not enabled": "Encryption not enabled",
     "The encryption used by this room isn't supported.": "The encryption used by this room isn't supported.",
     "Error decrypting audio": "Error decrypting audio",
@@ -1391,6 +1391,11 @@
     "Invalid file%(extra)s": "Invalid file%(extra)s",
     "Error decrypting image": "Error decrypting image",
     "Show image": "Show image",
+    "Join the conference at the top of this room": "Join the conference at the top of this room",
+    "Join the conference from the room information card on the right": "Join the conference from the room information card on the right",
+    "Video conference ended by %(senderName)s": "Video conference ended by %(senderName)s",
+    "Video conference updated by %(senderName)s": "Video conference updated by %(senderName)s",
+    "Video conference started by %(senderName)s": "Video conference started by %(senderName)s",
     "You have ignored this user, so their message is hidden. <a>Show anyways.</a>": "You have ignored this user, so their message is hidden. <a>Show anyways.</a>",
     "You verified %(name)s": "You verified %(name)s",
     "You cancelled verifying %(name)s": "You cancelled verifying %(name)s",
diff --git a/src/settings/Settings.ts b/src/settings/Settings.ts
index 737c882919..8b96a2e819 100644
--- a/src/settings/Settings.ts
+++ b/src/settings/Settings.ts
@@ -186,6 +186,12 @@ export const SETTINGS: {[setting: string]: ISetting} = {
         supportedLevels: LEVELS_FEATURE,
         default: false,
     },
+    "feature_dehydration": {
+        isFeature: true,
+        displayName: _td("Offline encrypted messaging using dehydrated devices"),
+        supportedLevels: LEVELS_FEATURE,
+        default: false,
+    },
     "advancedRoomListLogging": {
         // TODO: Remove flag before launch: https://github.com/vector-im/element-web/issues/14231
         displayName: _td("Enable advanced debugging for the room list"),
diff --git a/src/stores/ActiveWidgetStore.js b/src/stores/ActiveWidgetStore.js
index bf9ae3586c..4ae8dfeddb 100644
--- a/src/stores/ActiveWidgetStore.js
+++ b/src/stores/ActiveWidgetStore.js
@@ -17,6 +17,7 @@ limitations under the License.
 import EventEmitter from 'events';
 
 import {MatrixClientPeg} from '../MatrixClientPeg';
+import {WidgetMessagingStore} from "./widgets/WidgetMessagingStore";
 
 /**
  * Stores information about the widgets active in the app right now:
@@ -29,15 +30,6 @@ class ActiveWidgetStore extends EventEmitter {
         super();
         this._persistentWidgetId = null;
 
-        // A list of negotiated capabilities for each widget, by ID
-        // {
-        //     widgetId: [caps...],
-        // }
-        this._capsByWidgetId = {};
-
-        // A WidgetMessaging instance for each widget ID
-        this._widgetMessagingByWidgetId = {};
-
         // What room ID each widget is associated with (if it's a room widget)
         this._roomIdByWidgetId = {};
 
@@ -54,8 +46,6 @@ class ActiveWidgetStore extends EventEmitter {
         if (MatrixClientPeg.get()) {
             MatrixClientPeg.get().removeListener('RoomState.events', this.onRoomStateEvents);
         }
-        this._capsByWidgetId = {};
-        this._widgetMessagingByWidgetId = {};
         this._roomIdByWidgetId = {};
     }
 
@@ -76,9 +66,9 @@ class ActiveWidgetStore extends EventEmitter {
         if (id !== this._persistentWidgetId) return;
         const toDeleteId = this._persistentWidgetId;
 
+        WidgetMessagingStore.instance.stopMessagingById(id);
+
         this.setWidgetPersistence(toDeleteId, false);
-        this.delWidgetMessaging(toDeleteId);
-        this.delWidgetCapabilities(toDeleteId);
         this.delRoomId(toDeleteId);
     }
 
@@ -99,43 +89,6 @@ class ActiveWidgetStore extends EventEmitter {
         return this._persistentWidgetId;
     }
 
-    setWidgetCapabilities(widgetId, caps) {
-        this._capsByWidgetId[widgetId] = caps;
-        this.emit('update');
-    }
-
-    widgetHasCapability(widgetId, cap) {
-        return this._capsByWidgetId[widgetId] && this._capsByWidgetId[widgetId].includes(cap);
-    }
-
-    delWidgetCapabilities(widgetId) {
-        delete this._capsByWidgetId[widgetId];
-        this.emit('update');
-    }
-
-    setWidgetMessaging(widgetId, wm) {
-        // Stop any existing widget messaging first
-        this.delWidgetMessaging(widgetId);
-        this._widgetMessagingByWidgetId[widgetId] = wm;
-        this.emit('update');
-    }
-
-    getWidgetMessaging(widgetId) {
-        return this._widgetMessagingByWidgetId[widgetId];
-    }
-
-    delWidgetMessaging(widgetId) {
-        if (this._widgetMessagingByWidgetId[widgetId]) {
-            try {
-                this._widgetMessagingByWidgetId[widgetId].stop();
-            } catch (e) {
-                console.error('Failed to stop listening for widgetMessaging events', e.message);
-            }
-            delete this._widgetMessagingByWidgetId[widgetId];
-            this.emit('update');
-        }
-    }
-
     getRoomId(widgetId) {
         return this._roomIdByWidgetId[widgetId];
     }
diff --git a/src/stores/OwnProfileStore.ts b/src/stores/OwnProfileStore.ts
index 1aa761e1c4..8983380fec 100644
--- a/src/stores/OwnProfileStore.ts
+++ b/src/stores/OwnProfileStore.ts
@@ -66,12 +66,14 @@ export class OwnProfileStore extends AsyncStoreWithClient<IState> {
     /**
      * Gets the user's avatar as an HTTP URL of the given size. If the user's
      * avatar is not present, this returns null.
-     * @param size The size of the avatar
+     * @param size The size of the avatar. If zero, a full res copy of the avatar
+     * will be returned as an HTTP URL.
      * @returns The HTTP URL of the user's avatar
      */
-    public getHttpAvatarUrl(size: number): string {
+    public getHttpAvatarUrl(size = 0): string {
         if (!this.avatarMxc) return null;
-        return this.matrixClient.mxcUrlToHttp(this.avatarMxc, size, size);
+        const adjustedSize = size > 1 ? size : undefined; // don't let negatives or zero through
+        return this.matrixClient.mxcUrlToHttp(this.avatarMxc, adjustedSize, adjustedSize);
     }
 
     protected async onNotReady() {
diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts
index 26e3f70b57..a6b7889243 100644
--- a/src/stores/WidgetStore.ts
+++ b/src/stores/WidgetStore.ts
@@ -22,6 +22,7 @@ import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
 import defaultDispatcher from "../dispatcher/dispatcher";
 import SettingsStore from "../settings/SettingsStore";
 import WidgetEchoStore from "../stores/WidgetEchoStore";
+import ActiveWidgetStore from "../stores/ActiveWidgetStore";
 import WidgetUtils from "../utils/WidgetUtils";
 import {SettingLevel} from "../settings/SettingLevel";
 import {WidgetType} from "../widgets/WidgetType";
@@ -120,6 +121,7 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
     }
 
     private loadRoomWidgets(room: Room) {
+        if (!room) return;
         const roomInfo = this.roomMap.get(room.roomId);
         roomInfo.widgets = [];
         this.generateApps(room).forEach(app => {
@@ -160,17 +162,16 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
 
         let pinned = roomInfo && roomInfo.pinned[widgetId];
         // Jitsi widgets should be pinned by default
-        if (pinned === undefined && WidgetType.JITSI.matches(this.widgetMap.get(widgetId).type)) pinned = true;
+        const widget = this.widgetMap.get(widgetId);
+        if (pinned === undefined && WidgetType.JITSI.matches(widget?.type)) pinned = true;
         return pinned;
     }
 
     public canPin(widgetId: string) {
-        // only allow pinning up to a max of two as we do not yet have grid splits
-        // the only case it will go to three is if you have two and then a Jitsi gets added
         const roomId = this.getRoomId(widgetId);
         const roomInfo = this.getRoom(roomId);
         return roomInfo && Object.keys(roomInfo.pinned).filter(k => {
-            return roomInfo.widgets.some(app => app.id === k);
+            return roomInfo.pinned[k] && roomInfo.widgets.some(app => app.id === k);
         }).length < MAX_PINNED;
     }
 
@@ -208,6 +209,24 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
         }
         return roomInfo.widgets;
     }
+
+    public doesRoomHaveConference(room: Room): boolean {
+        const roomInfo = this.getRoom(room.roomId);
+        if (!roomInfo) return false;
+
+        const currentWidgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type));
+        const hasPendingWidgets = WidgetEchoStore.roomHasPendingWidgetsOfType(room.roomId, [], WidgetType.JITSI);
+        return currentWidgets.length > 0 || hasPendingWidgets;
+    }
+
+    public isJoinedToConferenceIn(room: Room): boolean {
+        const roomInfo = this.getRoom(room.roomId);
+        if (!roomInfo) return false;
+
+        // A persistent conference widget indicates that we're participating
+        const widgets = roomInfo.widgets.filter(w => WidgetType.JITSI.matches(w.type));
+        return widgets.some(w => ActiveWidgetStore.getWidgetPersistence(w.id));
+    }
 }
 
 window.mxWidgetStore = WidgetStore.instance;
diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts
new file mode 100644
index 0000000000..b101a119a4
--- /dev/null
+++ b/src/stores/widgets/ElementWidgetActions.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2020 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *         http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export enum ElementWidgetActions {
+    ClientReady = "im.vector.ready",
+    HangupCall = "im.vector.hangup",
+    OpenIntegrationManager = "integration_manager_open",
+}
diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts
new file mode 100644
index 0000000000..1c24f70d0d
--- /dev/null
+++ b/src/stores/widgets/StopGapWidget.ts
@@ -0,0 +1,266 @@
+/*
+ * Copyright 2020 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *         http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Room } from "matrix-js-sdk/src/models/room";
+import {
+    ClientWidgetApi,
+    IStickerActionRequest,
+    IStickyActionRequest,
+    IWidget,
+    IWidgetApiRequest,
+    IWidgetApiRequestEmptyData,
+    IWidgetData,
+    MatrixCapabilities,
+    Widget,
+    WidgetApiFromWidgetAction,
+} from "matrix-widget-api";
+import { StopGapWidgetDriver } from "./StopGapWidgetDriver";
+import { EventEmitter } from "events";
+import { WidgetMessagingStore } from "./WidgetMessagingStore";
+import RoomViewStore from "../RoomViewStore";
+import { MatrixClientPeg } from "../../MatrixClientPeg";
+import { OwnProfileStore } from "../OwnProfileStore";
+import WidgetUtils from '../../utils/WidgetUtils';
+import { IntegrationManagers } from "../../integrations/IntegrationManagers";
+import SettingsStore from "../../settings/SettingsStore";
+import { WidgetType } from "../../widgets/WidgetType";
+import ActiveWidgetStore from "../ActiveWidgetStore";
+import { objectShallowClone } from "../../utils/objects";
+import defaultDispatcher from "../../dispatcher/dispatcher";
+import { ElementWidgetActions } from "./ElementWidgetActions";
+
+// TODO: Destroy all of this code
+
+interface IAppTileProps {
+    // Note: these are only the props we care about
+
+    app: IWidget;
+    room: Room;
+    userId: string;
+    creatorUserId: string;
+    waitForIframeLoad: boolean;
+    whitelistCapabilities: string[];
+    userWidget: boolean;
+}
+
+// TODO: Don't use this because it's wrong
+class ElementWidget extends Widget {
+    constructor(w) {
+        super(w);
+    }
+
+    public get templateUrl(): string {
+        if (WidgetType.JITSI.matches(this.type)) {
+            return WidgetUtils.getLocalJitsiWrapperUrl({
+                forLocalRender: true,
+                auth: this.rawData?.auth,
+            });
+        }
+        return super.templateUrl;
+    }
+
+    public get rawData(): IWidgetData {
+        let conferenceId = super.rawData['conferenceId'];
+        if (conferenceId === undefined) {
+            // we'll need to parse the conference ID out of the URL for v1 Jitsi widgets
+            const parsedUrl = new URL(this.templateUrl);
+            conferenceId = parsedUrl.searchParams.get("confId");
+        }
+        return {
+            ...super.rawData,
+            theme: SettingsStore.getValue("theme"),
+            conferenceId,
+        };
+    }
+}
+
+export class StopGapWidget extends EventEmitter {
+    private messaging: ClientWidgetApi;
+    private mockWidget: Widget;
+    private scalarToken: string;
+
+    constructor(private appTileProps: IAppTileProps) {
+        super();
+        let app = appTileProps.app;
+
+        // Backwards compatibility: not all old widgets have a creatorUserId
+        if (!app.creatorUserId) {
+            app = objectShallowClone(app); // clone to prevent accidental mutation
+            app.creatorUserId = MatrixClientPeg.get().getUserId();
+        }
+
+        this.mockWidget = new ElementWidget(app);
+    }
+
+    public get widgetApi(): ClientWidgetApi {
+        return this.messaging;
+    }
+
+    /**
+     * The URL to use in the iframe
+     */
+    public get embedUrl(): string {
+        const templated = this.mockWidget.getCompleteUrl({
+            currentRoomId: RoomViewStore.getRoomId(),
+            currentUserId: MatrixClientPeg.get().getUserId(),
+            userDisplayName: OwnProfileStore.instance.displayName,
+            userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(),
+        });
+
+        // Add in some legacy support sprinkles
+        // TODO: Replace these with proper widget params
+        // See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833
+        const parsed = new URL(templated);
+        parsed.searchParams.set('widgetId', this.mockWidget.id);
+        parsed.searchParams.set('parentUrl', window.location.href.split('#', 2)[0]);
+
+        // Give the widget a scalar token if we're supposed to (more legacy)
+        // TODO: Stop doing this
+        if (this.scalarToken) {
+            parsed.searchParams.set('scalar_token', this.scalarToken);
+        }
+
+        // Replace the encoded dollar signs back to dollar signs. They have no special meaning
+        // in HTTP, but URL parsers encode them anyways.
+        return parsed.toString().replace(/%24/g, '$');
+    }
+
+    /**
+     * The URL to use in the popout
+     */
+    public get popoutUrl(): string {
+        if (WidgetType.JITSI.matches(this.mockWidget.type)) {
+            return WidgetUtils.getLocalJitsiWrapperUrl({
+                forLocalRender: false,
+                auth: this.mockWidget.rawData?.auth,
+            });
+        }
+        return this.embedUrl;
+    }
+
+    public get isManagedByManager(): boolean {
+        return !!this.scalarToken;
+    }
+
+    public get started(): boolean {
+        return !!this.messaging;
+    }
+
+    public start(iframe: HTMLIFrameElement) {
+        if (this.started) return;
+        const driver = new StopGapWidgetDriver( this.appTileProps.whitelistCapabilities || []);
+        this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
+        this.messaging.addEventListener("ready", () => this.emit("ready"));
+        WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.messaging);
+
+        if (!this.appTileProps.userWidget && this.appTileProps.room) {
+            ActiveWidgetStore.setRoomId(this.mockWidget.id, this.appTileProps.room.roomId);
+        }
+
+        if (WidgetType.JITSI.matches(this.mockWidget.type)) {
+            this.messaging.addEventListener("action:set_always_on_screen",
+                (ev: CustomEvent<IStickyActionRequest>) => {
+                    if (this.messaging.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
+                        ActiveWidgetStore.setWidgetPersistence(this.mockWidget.id, ev.detail.data.value);
+                        ev.preventDefault();
+                        this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); // ack
+                    }
+                },
+            );
+        } else if (WidgetType.STICKERPICKER.matches(this.mockWidget.type)) {
+            this.messaging.addEventListener(`action:${ElementWidgetActions.OpenIntegrationManager}`,
+                (ev: CustomEvent<IWidgetApiRequest>) => {
+                    // Acknowledge first
+                    ev.preventDefault();
+                    this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
+
+                    // First close the stickerpicker
+                    defaultDispatcher.dispatch({action: "stickerpicker_close"});
+
+                    // Now open the integration manager
+                    // TODO: Spec this interaction.
+                    const data = ev.detail.data;
+                    const integType = data?.integType
+                    const integId = <string>data?.integId;
+
+                    // TODO: Open the right integration manager for the widget
+                    if (SettingsStore.getValue("feature_many_integration_managers")) {
+                        IntegrationManagers.sharedInstance().openAll(
+                            MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
+                            `type_${integType}`,
+                            integId,
+                        );
+                    } else {
+                        IntegrationManagers.sharedInstance().getPrimaryManager().open(
+                            MatrixClientPeg.get().getRoom(RoomViewStore.getRoomId()),
+                            `type_${integType}`,
+                            integId,
+                        );
+                    }
+                },
+            );
+
+            // TODO: Replace this event listener with appropriate driver functionality once the API
+            // establishes a sane way to send events back and forth.
+            this.messaging.addEventListener(`action:${WidgetApiFromWidgetAction.SendSticker}`,
+                (ev: CustomEvent<IStickerActionRequest>) => {
+                    // Acknowledge first
+                    ev.preventDefault();
+                    this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
+
+                    // Send the sticker
+                    defaultDispatcher.dispatch({
+                        action: 'm.sticker',
+                        data: ev.detail.data,
+                        widgetId: this.mockWidget.id,
+                    });
+                },
+            );
+        }
+    }
+
+    public async prepare(): Promise<void> {
+        if (this.scalarToken) return;
+        const existingMessaging = WidgetMessagingStore.instance.getMessaging(this.mockWidget);
+        if (existingMessaging) this.messaging = existingMessaging;
+        try {
+            if (WidgetUtils.isScalarUrl(this.mockWidget.templateUrl)) {
+                const managers = IntegrationManagers.sharedInstance();
+                if (managers.hasManager()) {
+                    // TODO: Pick the right manager for the widget
+                    const defaultManager = managers.getPrimaryManager();
+                    if (WidgetUtils.isScalarUrl(defaultManager.apiUrl)) {
+                        const scalar = defaultManager.getScalarClient();
+                        this.scalarToken = await scalar.getScalarToken();
+                    }
+                }
+            }
+        } catch (e) {
+            // All errors are non-fatal
+            console.error("Error preparing widget communications: ", e);
+        }
+    }
+
+    public stop() {
+        if (ActiveWidgetStore.getPersistentWidgetId() === this.mockWidget.id) {
+            console.log("Skipping destroy - persistent widget");
+            return;
+        }
+        if (!this.started) return;
+        WidgetMessagingStore.instance.stopMessaging(this.mockWidget);
+        ActiveWidgetStore.delRoomId(this.mockWidget.id);
+    }
+}
diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts
new file mode 100644
index 0000000000..b54e4a5f7d
--- /dev/null
+++ b/src/stores/widgets/StopGapWidgetDriver.ts
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2020 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *         http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Capability, WidgetDriver } from "matrix-widget-api";
+import { iterableUnion } from "../../utils/iterables";
+
+// TODO: Purge this from the universe
+
+export class StopGapWidgetDriver extends WidgetDriver {
+    constructor(private allowedCapabilities: Capability[]) {
+        super();
+    }
+
+    public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
+        return new Set(iterableUnion(requested, this.allowedCapabilities));
+    }
+}
diff --git a/src/stores/widgets/WidgetMessagingStore.ts b/src/stores/widgets/WidgetMessagingStore.ts
new file mode 100644
index 0000000000..83d3ac7df8
--- /dev/null
+++ b/src/stores/widgets/WidgetMessagingStore.ts
@@ -0,0 +1,82 @@
+/*
+ * Copyright 2020 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *         http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ClientWidgetApi, Widget } from "matrix-widget-api";
+import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
+import defaultDispatcher from "../../dispatcher/dispatcher";
+import { ActionPayload } from "../../dispatcher/payloads";
+import { EnhancedMap } from "../../utils/maps";
+
+/**
+ * Temporary holding store for widget messaging instances. This is eventually
+ * going to be merged with a more complete WidgetStore, but for now it's
+ * easiest to split this into a single place.
+ */
+export class WidgetMessagingStore extends AsyncStoreWithClient<unknown> {
+    private static internalInstance = new WidgetMessagingStore();
+
+    // TODO: Fix uniqueness problem (widget IDs are not unique across the whole app)
+    private widgetMap = new EnhancedMap<string, ClientWidgetApi>(); // <widget ID, ClientWidgetAPi>
+
+    public constructor() {
+        super(defaultDispatcher);
+    }
+
+    public static get instance(): WidgetMessagingStore {
+        return WidgetMessagingStore.internalInstance;
+    }
+
+    protected async onAction(payload: ActionPayload): Promise<any> {
+        // nothing to do
+    }
+
+    protected async onReady(): Promise<any> {
+        // just in case
+        this.widgetMap.clear();
+    }
+
+    public storeMessaging(widget: Widget, widgetApi: ClientWidgetApi) {
+        this.stopMessaging(widget);
+        this.widgetMap.set(widget.id, widgetApi);
+    }
+
+    public stopMessaging(widget: Widget) {
+        this.widgetMap.remove(widget.id)?.stop();
+    }
+
+    public getMessaging(widget: Widget): ClientWidgetApi {
+        return this.widgetMap.get(widget.id);
+    }
+
+    /**
+     * Stops the widget messaging instance for a given widget ID.
+     * @param {string} widgetId The widget ID.
+     * @deprecated Widget IDs are not globally unique.
+     */
+    public stopMessagingById(widgetId: string) {
+        this.widgetMap.remove(widgetId)?.stop();
+    }
+
+    /**
+     * Gets the widget messaging class for a given widget ID.
+     * @param {string} widgetId The widget ID.
+     * @returns {ClientWidgetApi} The widget API, or a falsey value if not found.
+     * @deprecated Widget IDs are not globally unique.
+     */
+    public getMessagingForId(widgetId: string): ClientWidgetApi {
+        return this.widgetMap.get(widgetId);
+    }
+}
diff --git a/src/utils/WidgetUtils.js b/src/utils/WidgetUtils.js
index d1daba7ca5..6cc95efb25 100644
--- a/src/utils/WidgetUtils.js
+++ b/src/utils/WidgetUtils.js
@@ -28,11 +28,11 @@ const WIDGET_WAIT_TIME = 20000;
 import SettingsStore from "../settings/SettingsStore";
 import ActiveWidgetStore from "../stores/ActiveWidgetStore";
 import {IntegrationManagers} from "../integrations/IntegrationManagers";
-import {Capability} from "../widgets/WidgetApi";
 import {Room} from "matrix-js-sdk/src/models/room";
 import {WidgetType} from "../widgets/WidgetType";
 import {objectClone} from "./objects";
 import {_t} from "../languageHandler";
+import {MatrixCapabilities} from "matrix-widget-api";
 
 export default class WidgetUtils {
     /* Returns true if user is able to send state events to modify widgets in this room
@@ -416,15 +416,14 @@ export default class WidgetUtils {
     static getCapWhitelistForAppTypeInRoomId(appType, roomId) {
         const enableScreenshots = SettingsStore.getValue("enableWidgetScreenshots", roomId);
 
-        const capWhitelist = enableScreenshots ? [Capability.Screenshot] : [];
+        const capWhitelist = enableScreenshots ? [MatrixCapabilities.Screenshots] : [];
 
         // Obviously anyone that can add a widget can claim it's a jitsi widget,
         // so this doesn't really offer much over the set of domains we load
         // widgets from at all, but it probably makes sense for sanity.
         if (WidgetType.JITSI.matches(appType)) {
-            capWhitelist.push(Capability.AlwaysOnScreen);
+            capWhitelist.push(MatrixCapabilities.AlwaysOnScreen);
         }
-        capWhitelist.push(Capability.ReceiveTerminate);
 
         return capWhitelist;
     }
@@ -495,16 +494,4 @@ export default class WidgetUtils {
             IntegrationManagers.sharedInstance().getPrimaryManager().open(room, 'type_' + app.type, app.id);
         }
     }
-
-    static snapshotWidget(app) {
-        console.log("Requesting widget snapshot");
-        ActiveWidgetStore.getWidgetMessaging(app.id).getScreenshot().catch((err) => {
-            console.error("Failed to get screenshot", err);
-        }).then((screenshot) => {
-            dis.dispatch({
-                action: 'picture_snapshot',
-                file: screenshot,
-            }, true);
-        });
-    }
 }
diff --git a/src/utils/iterables.ts b/src/utils/iterables.ts
new file mode 100644
index 0000000000..56e0bca1b7
--- /dev/null
+++ b/src/utils/iterables.ts
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2020 The Matrix.org Foundation C.I.C.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *         http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { arrayUnion } from "./arrays";
+
+export function iterableUnion<T>(a: Iterable<T>, b: Iterable<T>): Iterable<T> {
+    return arrayUnion(Array.from(a), Array.from(b));
+}
diff --git a/src/utils/maps.ts b/src/utils/maps.ts
index 96832094f0..57d84bd33f 100644
--- a/src/utils/maps.ts
+++ b/src/utils/maps.ts
@@ -44,3 +44,26 @@ export function mapKeyChanges<K, V>(a: Map<K, V>, b: Map<K, V>): K[] {
     const diff = mapDiff(a, b);
     return arrayMerge(diff.removed, diff.added, diff.changed);
 }
+
+/**
+ * A Map<K, V> with added utility.
+ */
+export class EnhancedMap<K, V> extends Map<K, V> {
+    public constructor(entries?: Iterable<[K, V]>) {
+        super(entries);
+    }
+
+    public getOrCreate(key: K, def: V): V {
+        if (this.has(key)) {
+            return this.get(key);
+        }
+        this.set(key, def);
+        return def;
+    }
+
+    public remove(key: K): V {
+        const v = this.get(key);
+        this.delete(key);
+        return v;
+    }
+}
diff --git a/src/widgets/WidgetApi.ts b/src/widgets/WidgetApi.ts
deleted file mode 100644
index 672cbf2a56..0000000000
--- a/src/widgets/WidgetApi.ts
+++ /dev/null
@@ -1,220 +0,0 @@
-/*
-Copyright 2020 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-// Dev note: This is largely inspired by Dimension. Used with permission.
-// https://github.com/turt2live/matrix-dimension/blob/4f92d560266635e5a3c824606215b84e8c0b19f5/web/app/shared/services/scalar/scalar-widget.api.ts
-
-import { randomString } from "matrix-js-sdk/src/randomstring";
-import { EventEmitter } from "events";
-import { objectClone } from "../utils/objects";
-
-export enum Capability {
-    Screenshot = "m.capability.screenshot",
-    Sticker = "m.sticker",
-    AlwaysOnScreen = "m.always_on_screen",
-    ReceiveTerminate = "im.vector.receive_terminate",
-}
-
-export enum KnownWidgetActions {
-    GetSupportedApiVersions = "supported_api_versions",
-    TakeScreenshot = "screenshot",
-    GetCapabilities = "capabilities",
-    SendEvent = "send_event",
-    UpdateVisibility = "visibility",
-    GetOpenIDCredentials = "get_openid",
-    ReceiveOpenIDCredentials = "openid_credentials",
-    SetAlwaysOnScreen = "set_always_on_screen",
-    ClientReady = "im.vector.ready",
-    Terminate = "im.vector.terminate",
-}
-
-export type WidgetAction = KnownWidgetActions | string;
-
-export enum WidgetApiType {
-    ToWidget = "toWidget",
-    FromWidget = "fromWidget",
-}
-
-export interface WidgetRequest {
-    api: WidgetApiType;
-    widgetId: string;
-    requestId: string;
-    data: any;
-    action: WidgetAction;
-}
-
-export interface ToWidgetRequest extends WidgetRequest {
-    api: WidgetApiType.ToWidget;
-}
-
-export interface FromWidgetRequest extends WidgetRequest {
-    api: WidgetApiType.FromWidget;
-    response: any;
-}
-
-export interface OpenIDCredentials {
-    accessToken: string;
-    tokenType: string;
-    matrixServerName: string;
-    expiresIn: number;
-}
-
-/**
- * Handles Element <--> Widget interactions for embedded/standalone widgets.
- *
- * Emitted events:
- * - terminate(wait): client requested the widget to terminate.
- *   Call the argument 'wait(promise)' to postpone the finalization until
- *   the given promise resolves.
- */
-export class WidgetApi extends EventEmitter {
-    private readonly origin: string;
-    private inFlightRequests: { [requestId: string]: (reply: FromWidgetRequest) => void } = {};
-    private readonly readyPromise: Promise<any>;
-    private readyPromiseResolve: () => void;
-    private openIDCredentialsCallback: () => void;
-    public openIDCredentials: OpenIDCredentials;
-
-    /**
-     * Set this to true if your widget is expecting a ready message from the client. False otherwise (default).
-     */
-    public expectingExplicitReady = false;
-
-    constructor(currentUrl: string, private widgetId: string, private requestedCapabilities: string[]) {
-        super();
-
-        this.origin = new URL(currentUrl).origin;
-
-        this.readyPromise = new Promise<any>(resolve => this.readyPromiseResolve = resolve);
-
-        window.addEventListener("message", event => {
-            if (event.origin !== this.origin) return; // ignore: invalid origin
-            if (!event.data) return; // invalid schema
-            if (event.data.widgetId !== this.widgetId) return; // not for us
-
-            const payload = <WidgetRequest>event.data;
-            if (payload.api === WidgetApiType.ToWidget && payload.action) {
-                console.log(`[WidgetAPI] Got request: ${JSON.stringify(payload)}`);
-
-                if (payload.action === KnownWidgetActions.GetCapabilities) {
-                    this.onCapabilitiesRequest(<ToWidgetRequest>payload);
-                    if (!this.expectingExplicitReady) {
-                        this.readyPromiseResolve();
-                    }
-                } else if (payload.action === KnownWidgetActions.ClientReady) {
-                    this.readyPromiseResolve();
-
-                    // Automatically acknowledge so we can move on
-                    this.replyToRequest(<ToWidgetRequest>payload, {});
-                } else if (payload.action === KnownWidgetActions.Terminate) {
-                    // Finalization needs to be async, so postpone with a promise
-                    let finalizePromise = Promise.resolve();
-                    const wait = (promise) => {
-                        finalizePromise = finalizePromise.then(() => promise);
-                    };
-                    this.emit('terminate', wait);
-                    Promise.resolve(finalizePromise).then(() => {
-                        // Acknowledge that we're shut down now
-                        this.replyToRequest(<ToWidgetRequest>payload, {});
-                    });
-                } else if (payload.action === KnownWidgetActions.ReceiveOpenIDCredentials) {
-                    // Save OpenID credentials
-                    this.setOpenIDCredentials(<ToWidgetRequest>payload);
-                    this.replyToRequest(<ToWidgetRequest>payload, {});
-                } else {
-                    console.warn(`[WidgetAPI] Got unexpected action: ${payload.action}`);
-                }
-            } else if (payload.api === WidgetApiType.FromWidget && this.inFlightRequests[payload.requestId]) {
-                console.log(`[WidgetAPI] Got reply: ${JSON.stringify(payload)}`);
-                const handler = this.inFlightRequests[payload.requestId];
-                delete this.inFlightRequests[payload.requestId];
-                handler(<FromWidgetRequest>payload);
-            } else {
-                console.warn(`[WidgetAPI] Unhandled payload: ${JSON.stringify(payload)}`);
-            }
-        });
-    }
-
-    public setOpenIDCredentials(value: WidgetRequest) {
-        const data = value.data;
-        if (data.state === 'allowed') {
-            this.openIDCredentials = {
-                accessToken: data.access_token,
-                tokenType: data.token_type,
-                matrixServerName: data.matrix_server_name,
-                expiresIn: data.expires_in,
-            }
-        } else if (data.state === 'blocked') {
-            this.openIDCredentials = null;
-        }
-        if (['allowed', 'blocked'].includes(data.state) && this.openIDCredentialsCallback) {
-            this.openIDCredentialsCallback()
-        }
-    }
-
-    public requestOpenIDCredentials(credentialsResponseCallback: () => void) {
-        this.openIDCredentialsCallback = credentialsResponseCallback;
-        this.callAction(
-            KnownWidgetActions.GetOpenIDCredentials,
-            {},
-            this.setOpenIDCredentials,
-        );
-    }
-
-    public waitReady(): Promise<any> {
-        return this.readyPromise;
-    }
-
-    private replyToRequest(payload: ToWidgetRequest, reply: any) {
-        if (!window.parent) return;
-
-        const request: ToWidgetRequest & {response?: any} = objectClone(payload);
-        request.response = reply;
-
-        window.parent.postMessage(request, this.origin);
-    }
-
-    private onCapabilitiesRequest(payload: ToWidgetRequest) {
-        return this.replyToRequest(payload, {capabilities: this.requestedCapabilities});
-    }
-
-    public callAction(action: WidgetAction, payload: any, callback: (reply: FromWidgetRequest) => void) {
-        if (!window.parent) return;
-
-        const request: FromWidgetRequest = {
-            api: WidgetApiType.FromWidget,
-            widgetId: this.widgetId,
-            action: action,
-            requestId: randomString(160),
-            data: payload,
-            response: {}, // Not used at this layer - it's used when the client responds
-        };
-
-        if (callback) {
-            this.inFlightRequests[request.requestId] = callback;
-        }
-
-        console.log(`[WidgetAPI] Sending request: `, request);
-        window.parent.postMessage(request, "*");
-    }
-
-    public setAlwaysOnScreen(onScreen: boolean): Promise<any> {
-        return new Promise<any>(resolve => {
-            this.callAction(KnownWidgetActions.SetAlwaysOnScreen, {value: onScreen}, null);
-            resolve(); // SetAlwaysOnScreen is currently fire-and-forget, but that could change.
-        });
-    }
-}
diff --git a/src/widgets/WidgetType.ts b/src/widgets/WidgetType.ts
index e4b37e639c..e42f3ffa9b 100644
--- a/src/widgets/WidgetType.ts
+++ b/src/widgets/WidgetType.ts
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+// TODO: Move to matrix-widget-api
 export class WidgetType {
     public static readonly JITSI = new WidgetType("m.jitsi", "jitsi");
     public static readonly STICKERPICKER = new WidgetType("m.stickerpicker", "m.stickerpicker");
diff --git a/test/editor/range-test.js b/test/editor/range-test.js
index b69ed9eb53..60055af824 100644
--- a/test/editor/range-test.js
+++ b/test/editor/range-test.js
@@ -88,4 +88,19 @@ describe('editor/range', function() {
         expect(model.parts[1].text).toBe("man");
         expect(model.parts.length).toBe(2);
     });
+    it('range trim spaces off both ends', () => {
+        const renderer = createRenderer();
+        const pc = createPartCreator();
+        const model = new EditorModel([
+            pc.plain("abc abc abc"),
+        ], pc, renderer);
+        const range = model.startRange(
+            model.positionForOffset(3, false), // at end of first `abc`
+            model.positionForOffset(8, false), // at start of last `abc`
+        );
+
+        expect(range.parts[0].text).toBe(" abc ");
+        range.trim();
+        expect(range.parts[0].text).toBe("abc");
+    });
 });
diff --git a/yarn.lock b/yarn.lock
index 9ecf43d7a4..51ff681783 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5953,6 +5953,11 @@ matrix-react-test-utils@^0.2.2:
   resolved "https://registry.yarnpkg.com/matrix-react-test-utils/-/matrix-react-test-utils-0.2.2.tgz#c87144d3b910c7edc544a6699d13c7c2bf02f853"
   integrity sha512-49+7gfV6smvBIVbeloql+37IeWMTD+fiywalwCqk8Dnz53zAFjKSltB3rmWHso1uecLtQEcPtCijfhzcLXAxTQ==
 
+matrix-widget-api@^0.1.0-beta.2:
+  version "0.1.0-beta.2"
+  resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.2.tgz#367da1ccd26b711f73fc5b6e02edf55ac2ea2692"
+  integrity sha512-q5g5RZN+RRjM4HmcJ+LYoQAYrB1wzyERmoQ+LvKbTV/+9Ov36Kp0QEP8CleSXEd5WLp6bkRlt60axDaY6pWGmg==
+
 mdast-util-compact@^1.0.0:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/mdast-util-compact/-/mdast-util-compact-1.0.4.tgz#d531bb7667b5123abf20859be086c4d06c894593"