diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts
index 75ac1ea31e..ec382d90eb 100644
--- a/src/@types/global.d.ts
+++ b/src/@types/global.d.ts
@@ -30,6 +30,7 @@ import {Notifier} from "../Notifier";
 import type {Renderer} from "react-dom";
 import RightPanelStore from "../stores/RightPanelStore";
 import WidgetStore from "../stores/WidgetStore";
+import CallHandler from "../CallHandler";
 import {ModalWidgetStore} from "../stores/ModalWidgetStore";
 
 declare global {
@@ -54,6 +55,7 @@ declare global {
         mxNotifier: typeof Notifier;
         mxRightPanelStore: RightPanelStore;
         mxWidgetStore: WidgetStore;
+        mxCallHandler: CallHandler;
         mxModalWidgetStore: ModalWidgetStore;
     }
 
@@ -64,6 +66,9 @@ declare global {
 
     interface Navigator {
         userLanguage?: string;
+        // https://github.com/Microsoft/TypeScript/issues/19473
+        // https://developer.mozilla.org/en-US/docs/Web/API/MediaSession
+        mediaSession: any;
     }
 
     interface StorageEstimate {
diff --git a/src/CallHandler.js b/src/CallHandler.js
deleted file mode 100644
index ad40332af5..0000000000
--- a/src/CallHandler.js
+++ /dev/null
@@ -1,526 +0,0 @@
-/*
-Copyright 2015, 2016 OpenMarket Ltd
-Copyright 2017, 2018 New Vector Ltd
-Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-/*
- * Manages a list of all the currently active calls.
- *
- * This handler dispatches when voip calls are added/updated/removed from this list:
- * {
- *   action: 'call_state'
- *   room_id: <room ID of the call>
- * }
- *
- * To know the state of the call, this handler exposes a getter to
- * obtain the call for a room:
- *   var call = CallHandler.getCall(roomId)
- *   var state = call.call_state; // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing
- *
- * This handler listens for and handles the following actions:
- * {
- *   action: 'place_call',
- *   type: 'voice|video',
- *   room_id: <room that the place call button was pressed in>
- * }
- *
- * {
- *   action: 'incoming_call'
- *   call: MatrixCall
- * }
- *
- * {
- *   action: 'hangup'
- *   room_id: <room that the hangup button was pressed in>
- * }
- *
- * {
- *   action: 'answer'
- *   room_id: <room that the answer button was pressed in>
- * }
- */
-
-import {MatrixClientPeg} from './MatrixClientPeg';
-import PlatformPeg from './PlatformPeg';
-import Modal from './Modal';
-import { _t } from './languageHandler';
-import Matrix from 'matrix-js-sdk';
-import dis from './dispatcher/dispatcher';
-import WidgetUtils from './utils/WidgetUtils';
-import WidgetEchoStore from './stores/WidgetEchoStore';
-import SettingsStore from './settings/SettingsStore';
-import {generateHumanReadableId} from "./utils/NamingUtils";
-import {Jitsi} from "./widgets/Jitsi";
-import {WidgetType} from "./widgets/WidgetType";
-import {SettingLevel} from "./settings/SettingLevel";
-import {base32} from "rfc4648";
-
-import QuestionDialog from "./components/views/dialogs/QuestionDialog";
-import ErrorDialog from "./components/views/dialogs/ErrorDialog";
-
-global.mxCalls = {
-    //room_id: MatrixCall
-};
-const calls = global.mxCalls;
-let ConferenceHandler = null;
-
-const audioPromises = {};
-
-function play(audioId) {
-    // TODO: Attach an invisible element for this instead
-    // which listens?
-    const audio = document.getElementById(audioId);
-    if (audio) {
-        const playAudio = async () => {
-            try {
-                // This still causes the chrome debugger to break on promise rejection if
-                // the promise is rejected, even though we're catching the exception.
-                await audio.play();
-            } catch (e) {
-                // This is usually because the user hasn't interacted with the document,
-                // or chrome doesn't think so and is denying the request. Not sure what
-                // we can really do here...
-                // https://github.com/vector-im/element-web/issues/7657
-                console.log("Unable to play audio clip", e);
-            }
-        };
-        if (audioPromises[audioId]) {
-            audioPromises[audioId] = audioPromises[audioId].then(()=>{
-                audio.load();
-                return playAudio();
-            });
-        } else {
-            audioPromises[audioId] = playAudio();
-        }
-    }
-}
-
-function pause(audioId) {
-    // TODO: Attach an invisible element for this instead
-    // which listens?
-    const audio = document.getElementById(audioId);
-    if (audio) {
-        if (audioPromises[audioId]) {
-            audioPromises[audioId] = audioPromises[audioId].then(()=>audio.pause());
-        } else {
-            // pause doesn't actually return a promise, but might as well do this for symmetry with play();
-            audioPromises[audioId] = audio.pause();
-        }
-    }
-}
-
-function _setCallListeners(call) {
-    call.on("error", function(err) {
-        console.error("Call error:", err);
-        if (
-            MatrixClientPeg.get().getTurnServers().length === 0 &&
-            SettingsStore.getValue("fallbackICEServerAllowed") === null
-        ) {
-            _showICEFallbackPrompt();
-            return;
-        }
-
-        Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
-            title: _t('Call Failed'),
-            description: err.message,
-        });
-    });
-    call.on("hangup", function() {
-        _setCallState(undefined, call.roomId, "ended");
-    });
-    // map web rtc states to dummy UI state
-    // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing
-    call.on("state", function(newState, oldState) {
-        if (newState === "ringing") {
-            _setCallState(call, call.roomId, "ringing");
-            pause("ringbackAudio");
-        } else if (newState === "invite_sent") {
-            _setCallState(call, call.roomId, "ringback");
-            play("ringbackAudio");
-        } else if (newState === "ended" && oldState === "connected") {
-            _setCallState(undefined, call.roomId, "ended");
-            pause("ringbackAudio");
-            play("callendAudio");
-        } else if (newState === "ended" && oldState === "invite_sent" &&
-                (call.hangupParty === "remote" ||
-                (call.hangupParty === "local" && call.hangupReason === "invite_timeout")
-                )) {
-            _setCallState(call, call.roomId, "busy");
-            pause("ringbackAudio");
-            play("busyAudio");
-            Modal.createTrackedDialog('Call Handler', 'Call Timeout', ErrorDialog, {
-                title: _t('Call Timeout'),
-                description: _t('The remote side failed to pick up') + '.',
-            });
-        } else if (oldState === "invite_sent") {
-            _setCallState(call, call.roomId, "stop_ringback");
-            pause("ringbackAudio");
-        } else if (oldState === "ringing") {
-            _setCallState(call, call.roomId, "stop_ringing");
-            pause("ringbackAudio");
-        } else if (newState === "connected") {
-            _setCallState(call, call.roomId, "connected");
-            pause("ringbackAudio");
-        }
-    });
-}
-
-function _setCallState(call, roomId, status) {
-    console.log(
-        `Call state in ${roomId} changed to ${status} (${call ? call.call_state : "-"})`,
-    );
-    calls[roomId] = call;
-
-    if (status === "ringing") {
-        play("ringAudio");
-    } else if (call && call.call_state === "ringing") {
-        pause("ringAudio");
-    }
-
-    if (call) {
-        call.call_state = status;
-    }
-    dis.dispatch({
-        action: 'call_state',
-        room_id: roomId,
-        state: status,
-    });
-}
-
-function _showICEFallbackPrompt() {
-    const cli = MatrixClientPeg.get();
-    const code = sub => <code>{sub}</code>;
-    Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, {
-        title: _t("Call failed due to misconfigured server"),
-        description: <div>
-            <p>{_t(
-                "Please ask the administrator of your homeserver " +
-                "(<code>%(homeserverDomain)s</code>) to configure a TURN server in " +
-                "order for calls to work reliably.",
-                { homeserverDomain: cli.getDomain() }, { code },
-            )}</p>
-            <p>{_t(
-                "Alternatively, you can try to use the public server at " +
-                "<code>turn.matrix.org</code>, but this will not be as reliable, and " +
-                "it will share your IP address with that server. You can also manage " +
-                "this in Settings.",
-                null, { code },
-            )}</p>
-        </div>,
-        button: _t('Try using turn.matrix.org'),
-        cancelButton: _t('OK'),
-        onFinished: (allow) => {
-            SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow);
-            cli.setFallbackICEServerAllowed(allow);
-        },
-    }, null, true);
-}
-
-function _onAction(payload) {
-    function placeCall(newCall) {
-        _setCallListeners(newCall);
-        if (payload.type === 'voice') {
-            newCall.placeVoiceCall();
-        } else if (payload.type === 'video') {
-            newCall.placeVideoCall(
-                payload.remote_element,
-                payload.local_element,
-            );
-        } else if (payload.type === 'screensharing') {
-            const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
-            if (screenCapErrorString) {
-                _setCallState(undefined, newCall.roomId, "ended");
-                console.log("Can't capture screen: " + screenCapErrorString);
-                Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, {
-                    title: _t('Unable to capture screen'),
-                    description: screenCapErrorString,
-                });
-                return;
-            }
-            newCall.placeScreenSharingCall(
-                payload.remote_element,
-                payload.local_element,
-            );
-        } else {
-            console.error("Unknown conf call type: %s", payload.type);
-        }
-    }
-
-    switch (payload.action) {
-        case 'place_call':
-            {
-                if (callHandler.getAnyActiveCall()) {
-                    Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
-                        title: _t('Existing Call'),
-                        description: _t('You are already in a call.'),
-                    });
-                    return; // don't allow >1 call to be placed.
-                }
-
-                // if the runtime env doesn't do VoIP, whine.
-                if (!MatrixClientPeg.get().supportsVoip()) {
-                    Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
-                        title: _t('VoIP is unsupported'),
-                        description: _t('You cannot place VoIP calls in this browser.'),
-                    });
-                    return;
-                }
-
-                const room = MatrixClientPeg.get().getRoom(payload.room_id);
-                if (!room) {
-                    console.error("Room %s does not exist.", payload.room_id);
-                    return;
-                }
-
-                const members = room.getJoinedMembers();
-                if (members.length <= 1) {
-                    Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, {
-                        description: _t('You cannot place a call with yourself.'),
-                    });
-                    return;
-                } else if (members.length === 2) {
-                    console.info("Place %s call in %s", payload.type, payload.room_id);
-                    const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id);
-                    placeCall(call);
-                } else { // > 2
-                    dis.dispatch({
-                        action: "place_conference_call",
-                        room_id: payload.room_id,
-                        type: payload.type,
-                        remote_element: payload.remote_element,
-                        local_element: payload.local_element,
-                    });
-                }
-            }
-            break;
-        case 'place_conference_call':
-            console.info("Place conference call in %s", payload.room_id);
-            _startCallApp(payload.room_id, payload.type);
-            break;
-        case 'incoming_call':
-            {
-                if (callHandler.getAnyActiveCall()) {
-                    // ignore multiple incoming calls. in future, we may want a line-1/line-2 setup.
-                    // we avoid rejecting with "busy" in case the user wants to answer it on a different device.
-                    // in future we could signal a "local busy" as a warning to the caller.
-                    // see https://github.com/vector-im/vector-web/issues/1964
-                    return;
-                }
-
-                // if the runtime env doesn't do VoIP, stop here.
-                if (!MatrixClientPeg.get().supportsVoip()) {
-                    return;
-                }
-
-                const call = payload.call;
-                _setCallListeners(call);
-                _setCallState(call, call.roomId, "ringing");
-            }
-            break;
-        case 'hangup':
-            if (!calls[payload.room_id]) {
-                return; // no call to hangup
-            }
-            calls[payload.room_id].hangup();
-            _setCallState(null, payload.room_id, "ended");
-            break;
-        case 'answer':
-            if (!calls[payload.room_id]) {
-                return; // no call to answer
-            }
-            calls[payload.room_id].answer();
-            _setCallState(calls[payload.room_id], payload.room_id, "connected");
-            dis.dispatch({
-                action: "view_room",
-                room_id: payload.room_id,
-            });
-            break;
-    }
-}
-
-async function _startCallApp(roomId, type) {
-    dis.dispatch({
-        action: 'appsDrawer',
-        show: true,
-    });
-
-    const room = MatrixClientPeg.get().getRoom(roomId);
-    const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI);
-
-    if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI)) {
-        Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, {
-            title: _t('Call in Progress'),
-            description: _t('A call is currently being placed!'),
-        });
-        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;
-    if (jitsiAuth === 'openidtoken-jwt') {
-        // Create conference ID from room ID
-        // For compatibility with Jitsi, use base32 without padding.
-        // More details here:
-        // https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification
-        confId = base32.stringify(Buffer.from(roomId), { pad: false });
-    } else {
-        // Create a random human readable conference ID
-        confId = `JitsiConference${generateHumanReadableId()}`;
-    }
-
-    let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({auth: jitsiAuth});
-
-    // TODO: Remove URL hacks when the mobile clients eventually support v2 widgets
-    const parsedUrl = new URL(widgetUrl);
-    parsedUrl.search = ''; // set to empty string to make the URL class use searchParams instead
-    parsedUrl.searchParams.set('confId', confId);
-    widgetUrl = parsedUrl.toString();
-
-    const widgetData = {
-        conferenceId: confId,
-        isAudioOnly: type === 'voice',
-        domain: jitsiDomain,
-        auth: jitsiAuth,
-    };
-
-    const widgetId = (
-        'jitsi_' +
-        MatrixClientPeg.get().credentials.userId +
-        '_' +
-        Date.now()
-    );
-
-    WidgetUtils.setRoomWidget(roomId, widgetId, WidgetType.JITSI, widgetUrl, 'Jitsi', widgetData).then(() => {
-        console.log('Jitsi widget added');
-    }).catch((e) => {
-        if (e.errcode === 'M_FORBIDDEN') {
-            Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
-                title: _t('Permission Required'),
-                description: _t("You do not have permission to start a conference call in this room"),
-            });
-        }
-        console.error(e);
-    });
-}
-
-// FIXME: Nasty way of making sure we only register
-// with the dispatcher once
-if (!global.mxCallHandler) {
-    dis.register(_onAction);
-    // add empty handlers for media actions, otherwise the media keys
-    // end up causing the audio elements with our ring/ringback etc
-    // audio clips in to play.
-    if (navigator.mediaSession) {
-        navigator.mediaSession.setActionHandler('play', function() {});
-        navigator.mediaSession.setActionHandler('pause', function() {});
-        navigator.mediaSession.setActionHandler('seekbackward', function() {});
-        navigator.mediaSession.setActionHandler('seekforward', function() {});
-        navigator.mediaSession.setActionHandler('previoustrack', function() {});
-        navigator.mediaSession.setActionHandler('nexttrack', function() {});
-    }
-}
-
-const callHandler = {
-    getCallForRoom: function(roomId) {
-        let call = callHandler.getCall(roomId);
-        if (call) return call;
-
-        if (ConferenceHandler) {
-            call = ConferenceHandler.getConferenceCallForRoom(roomId);
-        }
-        if (call) return call;
-
-        return null;
-    },
-
-    getCall: function(roomId) {
-        return calls[roomId] || null;
-    },
-
-    getAnyActiveCall: function() {
-        const roomsWithCalls = Object.keys(calls);
-        for (let i = 0; i < roomsWithCalls.length; i++) {
-            if (calls[roomsWithCalls[i]] &&
-                    calls[roomsWithCalls[i]].call_state !== "ended") {
-                return calls[roomsWithCalls[i]];
-            }
-        }
-        return null;
-    },
-
-    /**
-     * The conference handler is a module that deals with implementation-specific
-     * multi-party calling implementations. Element passes in its own which creates
-     * a one-to-one call with a freeswitch conference bridge. As of July 2018,
-     * the de-facto way of conference calling is a Jitsi widget, so this is
-     * deprecated. It reamins here for two reasons:
-     *  1. So Element still supports joining existing freeswitch conference calls
-     *     (but doesn't support creating them). After a transition period, we can
-     *     remove support for joining them too.
-     *  2. To hide the one-to-one rooms that old-style conferencing creates. This
-     *     is much harder to remove: probably either we make Element leave & forget these
-     *     rooms after we remove support for joining freeswitch conferences, or we
-     *     accept that random rooms with cryptic users will suddently appear for
-     *     anyone who's ever used conference calling, or we are stuck with this
-     *     code forever.
-     *
-     * @param {object} confHandler The conference handler object
-     */
-    setConferenceHandler: function(confHandler) {
-        ConferenceHandler = confHandler;
-    },
-
-    getConferenceHandler: function() {
-        return ConferenceHandler;
-    },
-};
-// Only things in here which actually need to be global are the
-// calls list (done separately) and making sure we only register
-// with the dispatcher once (which uses this mechanism but checks
-// separately). This could be tidied up.
-if (global.mxCallHandler === undefined) {
-    global.mxCallHandler = callHandler;
-}
-
-export default global.mxCallHandler;
diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx
new file mode 100644
index 0000000000..62b91f938b
--- /dev/null
+++ b/src/CallHandler.tsx
@@ -0,0 +1,487 @@
+/*
+Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2017, 2018 New Vector Ltd
+Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+/*
+ * Manages a list of all the currently active calls.
+ *
+ * This handler dispatches when voip calls are added/updated/removed from this list:
+ * {
+ *   action: 'call_state'
+ *   room_id: <room ID of the call>
+ * }
+ *
+ * To know the state of the call, this handler exposes a getter to
+ * obtain the call for a room:
+ *   var call = CallHandler.getCall(roomId)
+ *   var state = call.call_state; // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing
+ *
+ * This handler listens for and handles the following actions:
+ * {
+ *   action: 'place_call',
+ *   type: 'voice|video',
+ *   room_id: <room that the place call button was pressed in>
+ * }
+ *
+ * {
+ *   action: 'incoming_call'
+ *   call: MatrixCall
+ * }
+ *
+ * {
+ *   action: 'hangup'
+ *   room_id: <room that the hangup button was pressed in>
+ * }
+ *
+ * {
+ *   action: 'answer'
+ *   room_id: <room that the answer button was pressed in>
+ * }
+ */
+
+import React from 'react';
+
+import {MatrixClientPeg} from './MatrixClientPeg';
+import PlatformPeg from './PlatformPeg';
+import Modal from './Modal';
+import { _t } from './languageHandler';
+// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising
+import Matrix from 'matrix-js-sdk';
+import dis from './dispatcher/dispatcher';
+import WidgetUtils from './utils/WidgetUtils';
+import WidgetEchoStore from './stores/WidgetEchoStore';
+import SettingsStore from './settings/SettingsStore';
+import {generateHumanReadableId} from "./utils/NamingUtils";
+import {Jitsi} from "./widgets/Jitsi";
+import {WidgetType} from "./widgets/WidgetType";
+import {SettingLevel} from "./settings/SettingLevel";
+import { ActionPayload } from "./dispatcher/payloads";
+import {base32} from "rfc4648";
+
+import QuestionDialog from "./components/views/dialogs/QuestionDialog";
+import ErrorDialog from "./components/views/dialogs/ErrorDialog";
+
+// until we ts-ify the js-sdk voip code
+type Call = any;
+
+export default class CallHandler {
+    private calls = new Map<string, Call>();
+    private audioPromises = new Map<string, Promise<void>>();
+
+    static sharedInstance() {
+        if (!window.mxCallHandler) {
+            window.mxCallHandler = new CallHandler()
+        }
+
+        return window.mxCallHandler;
+    }
+
+    constructor() {
+        dis.register(this.onAction);
+        // add empty handlers for media actions, otherwise the media keys
+        // end up causing the audio elements with our ring/ringback etc
+        // audio clips in to play.
+        if (navigator.mediaSession) {
+            navigator.mediaSession.setActionHandler('play', function() {});
+            navigator.mediaSession.setActionHandler('pause', function() {});
+            navigator.mediaSession.setActionHandler('seekbackward', function() {});
+            navigator.mediaSession.setActionHandler('seekforward', function() {});
+            navigator.mediaSession.setActionHandler('previoustrack', function() {});
+            navigator.mediaSession.setActionHandler('nexttrack', function() {});
+        }
+    }
+
+    getCallForRoom(roomId: string): Call {
+        return this.calls.get(roomId) || null;
+    }
+
+    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]);
+            }
+        }
+        return null;
+    }
+
+    play(audioId: string) {
+        // TODO: Attach an invisible element for this instead
+        // which listens?
+        const audio = document.getElementById(audioId) as HTMLMediaElement;
+        if (audio) {
+            const playAudio = async () => {
+                try {
+                    // This still causes the chrome debugger to break on promise rejection if
+                    // the promise is rejected, even though we're catching the exception.
+                    await audio.play();
+                } catch (e) {
+                    // This is usually because the user hasn't interacted with the document,
+                    // or chrome doesn't think so and is denying the request. Not sure what
+                    // we can really do here...
+                    // https://github.com/vector-im/element-web/issues/7657
+                    console.log("Unable to play audio clip", e);
+                }
+            };
+            if (this.audioPromises.has(audioId)) {
+                this.audioPromises.set(audioId, this.audioPromises.get(audioId).then(() => {
+                    audio.load();
+                    return playAudio();
+                }));
+            } else {
+                this.audioPromises.set(audioId, playAudio());
+            }
+        }
+    }
+
+    pause(audioId: string) {
+        // TODO: Attach an invisible element for this instead
+        // which listens?
+        const audio = document.getElementById(audioId) as HTMLMediaElement;
+        if (audio) {
+            if (this.audioPromises.has(audioId)) {
+                this.audioPromises.set(audioId, this.audioPromises.get(audioId).then(() => audio.pause()));
+            } else {
+                // pause doesn't return a promise, so just do it
+                audio.pause();
+            }
+        }
+    }
+
+    private setCallListeners(call: Call) {
+        call.on("error", (err) => {
+            console.error("Call error:", err);
+            if (
+                MatrixClientPeg.get().getTurnServers().length === 0 &&
+                SettingsStore.getValue("fallbackICEServerAllowed") === null
+            ) {
+                this.showICEFallbackPrompt();
+                return;
+            }
+
+            Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
+                title: _t('Call Failed'),
+                description: err.message,
+            });
+        });
+        call.on("hangup", () => {
+            this.setCallState(undefined, call.roomId, "ended");
+        });
+        // map web rtc states to dummy UI state
+        // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing
+        call.on("state", (newState, oldState) => {
+            if (newState === "ringing") {
+                this.setCallState(call, call.roomId, "ringing");
+                this.pause("ringbackAudio");
+            } else if (newState === "invite_sent") {
+                this.setCallState(call, call.roomId, "ringback");
+                this.play("ringbackAudio");
+            } else if (newState === "ended" && oldState === "connected") {
+                this.setCallState(undefined, call.roomId, "ended");
+                this.pause("ringbackAudio");
+                this.play("callendAudio");
+            } else if (newState === "ended" && oldState === "invite_sent" &&
+                    (call.hangupParty === "remote" ||
+                    (call.hangupParty === "local" && call.hangupReason === "invite_timeout")
+                    )) {
+                this.setCallState(call, call.roomId, "busy");
+                this.pause("ringbackAudio");
+                this.play("busyAudio");
+                Modal.createTrackedDialog('Call Handler', 'Call Timeout', ErrorDialog, {
+                    title: _t('Call Timeout'),
+                    description: _t('The remote side failed to pick up') + '.',
+                });
+            } else if (oldState === "invite_sent") {
+                this.setCallState(call, call.roomId, "stop_ringback");
+                this.pause("ringbackAudio");
+            } else if (oldState === "ringing") {
+                this.setCallState(call, call.roomId, "stop_ringing");
+                this.pause("ringbackAudio");
+            } else if (newState === "connected") {
+                this.setCallState(call, call.roomId, "connected");
+                this.pause("ringbackAudio");
+            }
+        });
+    }
+
+    private setCallState(call: Call, roomId: string, status: string) {
+        console.log(
+            `Call state in ${roomId} changed to ${status} (${call ? call.call_state : "-"})`,
+        );
+        this.calls.set(roomId, call);
+
+        if (status === "ringing") {
+            this.play("ringAudio");
+        } else if (call && call.call_state === "ringing") {
+            this.pause("ringAudio");
+        }
+
+        if (call) {
+            call.call_state = status;
+        }
+        dis.dispatch({
+            action: 'call_state',
+            room_id: roomId,
+            state: status,
+        });
+    }
+
+    private showICEFallbackPrompt() {
+        const cli = MatrixClientPeg.get();
+        const code = sub => <code>{sub}</code>;
+        Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, {
+            title: _t("Call failed due to misconfigured server"),
+            description: <div>
+                <p>{_t(
+                    "Please ask the administrator of your homeserver " +
+                    "(<code>%(homeserverDomain)s</code>) to configure a TURN server in " +
+                    "order for calls to work reliably.",
+                    { homeserverDomain: cli.getDomain() }, { code },
+                )}</p>
+                <p>{_t(
+                    "Alternatively, you can try to use the public server at " +
+                    "<code>turn.matrix.org</code>, but this will not be as reliable, and " +
+                    "it will share your IP address with that server. You can also manage " +
+                    "this in Settings.",
+                    null, { code },
+                )}</p>
+            </div>,
+            button: _t('Try using turn.matrix.org'),
+            cancelButton: _t('OK'),
+            onFinished: (allow) => {
+                SettingsStore.setValue("fallbackICEServerAllowed", null, SettingLevel.DEVICE, allow);
+                cli.setFallbackICEServerAllowed(allow);
+            },
+        }, null, true);
+    }
+
+    private onAction = (payload: ActionPayload) => {
+        const placeCall = (newCall) => {
+            this.setCallListeners(newCall);
+            if (payload.type === 'voice') {
+                newCall.placeVoiceCall();
+            } else if (payload.type === 'video') {
+                newCall.placeVideoCall(
+                    payload.remote_element,
+                    payload.local_element,
+                );
+            } else if (payload.type === 'screensharing') {
+                const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
+                if (screenCapErrorString) {
+                    this.setCallState(undefined, newCall.roomId, "ended");
+                    console.log("Can't capture screen: " + screenCapErrorString);
+                    Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, {
+                        title: _t('Unable to capture screen'),
+                        description: screenCapErrorString,
+                    });
+                    return;
+                }
+                newCall.placeScreenSharingCall(
+                    payload.remote_element,
+                    payload.local_element,
+                );
+            } else {
+                console.error("Unknown conf call type: %s", payload.type);
+            }
+        }
+
+        switch (payload.action) {
+            case 'place_call':
+                {
+                    if (this.getAnyActiveCall()) {
+                        Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, {
+                            title: _t('Existing Call'),
+                            description: _t('You are already in a call.'),
+                        });
+                        return; // don't allow >1 call to be placed.
+                    }
+
+                    // if the runtime env doesn't do VoIP, whine.
+                    if (!MatrixClientPeg.get().supportsVoip()) {
+                        Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
+                            title: _t('VoIP is unsupported'),
+                            description: _t('You cannot place VoIP calls in this browser.'),
+                        });
+                        return;
+                    }
+
+                    const room = MatrixClientPeg.get().getRoom(payload.room_id);
+                    if (!room) {
+                        console.error("Room %s does not exist.", payload.room_id);
+                        return;
+                    }
+
+                    const members = room.getJoinedMembers();
+                    if (members.length <= 1) {
+                        Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, {
+                            description: _t('You cannot place a call with yourself.'),
+                        });
+                        return;
+                    } else if (members.length === 2) {
+                        console.info("Place %s call in %s", payload.type, payload.room_id);
+                        const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id);
+                        placeCall(call);
+                    } else { // > 2
+                        dis.dispatch({
+                            action: "place_conference_call",
+                            room_id: payload.room_id,
+                            type: payload.type,
+                            remote_element: payload.remote_element,
+                            local_element: payload.local_element,
+                        });
+                    }
+                }
+                break;
+            case 'place_conference_call':
+                console.info("Place conference call in %s", payload.room_id);
+                this.startCallApp(payload.room_id, payload.type);
+                break;
+            case 'incoming_call':
+                {
+                    if (this.getAnyActiveCall()) {
+                        // ignore multiple incoming calls. in future, we may want a line-1/line-2 setup.
+                        // we avoid rejecting with "busy" in case the user wants to answer it on a different device.
+                        // in future we could signal a "local busy" as a warning to the caller.
+                        // see https://github.com/vector-im/vector-web/issues/1964
+                        return;
+                    }
+
+                    // if the runtime env doesn't do VoIP, stop here.
+                    if (!MatrixClientPeg.get().supportsVoip()) {
+                        return;
+                    }
+
+                    const call = payload.call;
+                    this.setCallListeners(call);
+                    this.setCallState(call, call.roomId, "ringing");
+                }
+                break;
+            case 'hangup':
+                if (!this.calls.get(payload.room_id)) {
+                    return; // no call to hangup
+                }
+                this.calls.get(payload.room_id).hangup();
+                this.setCallState(null, payload.room_id, "ended");
+                break;
+            case 'answer':
+                if (!this.calls.get(payload.room_id)) {
+                    return; // no call to answer
+                }
+                this.calls.get(payload.room_id).answer();
+                this.setCallState(this.calls.get(payload.room_id), payload.room_id, "connected");
+                dis.dispatch({
+                    action: "view_room",
+                    room_id: payload.room_id,
+                });
+                break;
+        }
+    }
+
+    private async startCallApp(roomId: string, type: string) {
+        dis.dispatch({
+            action: 'appsDrawer',
+            show: true,
+        });
+
+        const room = MatrixClientPeg.get().getRoom(roomId);
+        const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI);
+
+        if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI)) {
+            Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, {
+                title: _t('Call in Progress'),
+                description: _t('A call is currently being placed!'),
+            });
+            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;
+        if (jitsiAuth === 'openidtoken-jwt') {
+            // Create conference ID from room ID
+            // For compatibility with Jitsi, use base32 without padding.
+            // More details here:
+            // https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification
+            confId = base32.stringify(Buffer.from(roomId), { pad: false });
+        } else {
+            // Create a random human readable conference ID
+            confId = `JitsiConference${generateHumanReadableId()}`;
+        }
+
+        let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({auth: jitsiAuth});
+
+        // TODO: Remove URL hacks when the mobile clients eventually support v2 widgets
+        const parsedUrl = new URL(widgetUrl);
+        parsedUrl.search = ''; // set to empty string to make the URL class use searchParams instead
+        parsedUrl.searchParams.set('confId', confId);
+        widgetUrl = parsedUrl.toString();
+
+        const widgetData = {
+            conferenceId: confId,
+            isAudioOnly: type === 'voice',
+            domain: jitsiDomain,
+            auth: jitsiAuth,
+        };
+
+        const widgetId = (
+            'jitsi_' +
+            MatrixClientPeg.get().credentials.userId +
+            '_' +
+            Date.now()
+        );
+
+        WidgetUtils.setRoomWidget(roomId, widgetId, WidgetType.JITSI, widgetUrl, 'Jitsi', widgetData).then(() => {
+            console.log('Jitsi widget added');
+        }).catch((e) => {
+            if (e.errcode === 'M_FORBIDDEN') {
+                Modal.createTrackedDialog('Call Failed', '', ErrorDialog, {
+                    title: _t('Permission Required'),
+                    description: _t("You do not have permission to start a conference call in this room"),
+                });
+            }
+            console.error(e);
+        });
+    }
+}
diff --git a/src/Rooms.js b/src/Rooms.js
index 218e970f35..3da2b9bc14 100644
--- a/src/Rooms.js
+++ b/src/Rooms.js
@@ -26,58 +26,6 @@ export function getDisplayAliasForRoom(room) {
     return room.getCanonicalAlias() || room.getAltAliases()[0];
 }
 
-/**
- * If the room contains only two members including the logged-in user,
- * return the other one. Otherwise, return null.
- */
-export function getOnlyOtherMember(room, myUserId) {
-    if (room.currentState.getJoinedMemberCount() === 2) {
-        return room.getJoinedMembers().filter(function(m) {
-            return m.userId !== myUserId;
-        })[0];
-    }
-
-    return null;
-}
-
-function _isConfCallRoom(room, myUserId, conferenceHandler) {
-    if (!conferenceHandler) return false;
-
-    const myMembership = room.getMyMembership();
-    if (myMembership != "join") {
-        return false;
-    }
-
-    const otherMember = getOnlyOtherMember(room, myUserId);
-    if (!otherMember) {
-        return false;
-    }
-
-    if (conferenceHandler.isConferenceUser(otherMember.userId)) {
-        return true;
-    }
-
-    return false;
-}
-
-// Cache whether a room is a conference call. Assumes that rooms will always
-// either will or will not be a conference call room.
-const isConfCallRoomCache = {
-    // $roomId: bool
-};
-
-export function isConfCallRoom(room, myUserId, conferenceHandler) {
-    if (isConfCallRoomCache[room.roomId] !== undefined) {
-        return isConfCallRoomCache[room.roomId];
-    }
-
-    const result = _isConfCallRoom(room, myUserId, conferenceHandler);
-
-    isConfCallRoomCache[room.roomId] = result;
-
-    return result;
-}
-
 export function looksLikeDirectMessageRoom(room, myUserId) {
     const myMembership = room.getMyMembership();
     const me = room.getMember(myUserId);
diff --git a/src/TextForEvent.js b/src/TextForEvent.js
index a76c1f59e6..f9cda23650 100644
--- a/src/TextForEvent.js
+++ b/src/TextForEvent.js
@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 import {MatrixClientPeg} from './MatrixClientPeg';
-import CallHandler from './CallHandler';
 import { _t } from './languageHandler';
 import * as Roles from './Roles';
 import {isValid3pidInvite} from "./RoomInvite";
@@ -29,7 +28,6 @@ function textForMemberEvent(ev) {
     const prevContent = ev.getPrevContent();
     const content = ev.getContent();
 
-    const ConferenceHandler = CallHandler.getConferenceHandler();
     const reason = content.reason ? (_t('Reason') + ': ' + content.reason) : '';
     switch (content.membership) {
         case 'invite': {
@@ -44,11 +42,7 @@ function textForMemberEvent(ev) {
                     return _t('%(targetName)s accepted an invitation.', {targetName});
                 }
             } else {
-                if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) {
-                    return _t('%(senderName)s requested a VoIP conference.', {senderName});
-                } else {
-                    return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName});
-                }
+                return _t('%(senderName)s invited %(targetName)s.', {senderName, targetName});
             }
         }
         case 'ban':
@@ -85,17 +79,11 @@ function textForMemberEvent(ev) {
                 }
             } else {
                 if (!ev.target) console.warn("Join message has no target! -- " + ev.getContent().state_key);
-                if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) {
-                    return _t('VoIP conference started.');
-                } else {
-                    return _t('%(targetName)s joined the room.', {targetName});
-                }
+                return _t('%(targetName)s joined the room.', {targetName});
             }
         case 'leave':
             if (ev.getSender() === ev.getStateKey()) {
-                if (ConferenceHandler && ConferenceHandler.isConferenceUser(ev.getStateKey())) {
-                    return _t('VoIP conference finished.');
-                } else if (prevContent.membership === "invite") {
+                if (prevContent.membership === "invite") {
                     return _t('%(targetName)s rejected the invitation.', {targetName});
                 } else {
                     return _t('%(targetName)s left the room.', {targetName});
diff --git a/src/VectorConferenceHandler.js b/src/VectorConferenceHandler.js
deleted file mode 100644
index c10bc659ae..0000000000
--- a/src/VectorConferenceHandler.js
+++ /dev/null
@@ -1,135 +0,0 @@
-/*
-Copyright 2015, 2016 OpenMarket Ltd
-Copyright 2019 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
-    http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import {createNewMatrixCall as jsCreateNewMatrixCall, Room} from "matrix-js-sdk";
-import CallHandler from './CallHandler';
-import {MatrixClientPeg} from "./MatrixClientPeg";
-
-// FIXME: this is Element specific code, but will be removed shortly when we
-// switch over to Jitsi entirely for video conferencing.
-
-// FIXME: This currently forces Element to try to hit the matrix.org AS for
-// conferencing. This is bad because it prevents people running their own ASes
-// from being used. This isn't permanent and will be customisable in the future:
-// see the proposal at docs/conferencing.md for more info.
-const USER_PREFIX = "fs_";
-const DOMAIN = "matrix.org";
-
-export function ConferenceCall(matrixClient, groupChatRoomId) {
-    this.client = matrixClient;
-    this.groupRoomId = groupChatRoomId;
-    this.confUserId = getConferenceUserIdForRoom(this.groupRoomId);
-}
-
-ConferenceCall.prototype.setup = function() {
-    const self = this;
-    return this._joinConferenceUser().then(function() {
-        return self._getConferenceUserRoom();
-    }).then(function(room) {
-        // return a call for *this* room to be placed. We also tack on
-        // confUserId to speed up lookups (else we'd need to loop every room
-        // looking for a 1:1 room with this conf user ID!)
-        const call = jsCreateNewMatrixCall(self.client, room.roomId);
-        call.confUserId = self.confUserId;
-        call.groupRoomId = self.groupRoomId;
-        return call;
-    });
-};
-
-ConferenceCall.prototype._joinConferenceUser = function() {
-    // Make sure the conference user is in the group chat room
-    const groupRoom = this.client.getRoom(this.groupRoomId);
-    if (!groupRoom) {
-        return Promise.reject("Bad group room ID");
-    }
-    const member = groupRoom.getMember(this.confUserId);
-    if (member && member.membership === "join") {
-        return Promise.resolve();
-    }
-    return this.client.invite(this.groupRoomId, this.confUserId);
-};
-
-ConferenceCall.prototype._getConferenceUserRoom = function() {
-    // Use an existing 1:1 with the conference user; else make one
-    const rooms = this.client.getRooms();
-    let confRoom = null;
-    for (let i = 0; i < rooms.length; i++) {
-        const confUser = rooms[i].getMember(this.confUserId);
-        if (confUser && confUser.membership === "join" &&
-                rooms[i].getJoinedMemberCount() === 2) {
-            confRoom = rooms[i];
-            break;
-        }
-    }
-    if (confRoom) {
-        return Promise.resolve(confRoom);
-    }
-    return this.client.createRoom({
-        preset: "private_chat",
-        invite: [this.confUserId],
-    }).then(function(res) {
-        return new Room(res.room_id, null, MatrixClientPeg.get().getUserId());
-    });
-};
-
-/**
- * Check if this user ID is in fact a conference bot.
- * @param {string} userId The user ID to check.
- * @return {boolean} True if it is a conference bot.
- */
-export function isConferenceUser(userId) {
-    if (userId.indexOf("@" + USER_PREFIX) !== 0) {
-        return false;
-    }
-    const base64part = userId.split(":")[0].substring(1 + USER_PREFIX.length);
-    if (base64part) {
-        const decoded = new Buffer(base64part, "base64").toString();
-        // ! $STUFF : $STUFF
-        return /^!.+:.+/.test(decoded);
-    }
-    return false;
-}
-
-export function getConferenceUserIdForRoom(roomId) {
-    // abuse browserify's core node Buffer support (strip padding ='s)
-    const base64RoomId = new Buffer(roomId).toString("base64").replace(/=/g, "");
-    return "@" + USER_PREFIX + base64RoomId + ":" + DOMAIN;
-}
-
-export function createNewMatrixCall(client, roomId) {
-    const confCall = new ConferenceCall(
-        client, roomId,
-    );
-    return confCall.setup();
-}
-
-export function getConferenceCallForRoom(roomId) {
-    // search for a conference 1:1 call for this group chat room ID
-    const activeCall = CallHandler.getAnyActiveCall();
-    if (activeCall && activeCall.confUserId) {
-        const thisRoomConfUserId = getConferenceUserIdForRoom(
-            roomId,
-        );
-        if (thisRoomConfUserId === activeCall.confUserId) {
-            return activeCall;
-        }
-    }
-    return null;
-}
-
-// TODO: Document this.
-export const slot = 'conference';
diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx
index 81b8da2cad..4dc2080895 100644
--- a/src/components/structures/LoggedInView.tsx
+++ b/src/components/structures/LoggedInView.tsx
@@ -85,7 +85,6 @@ interface IProps {
     threepidInvite?: IThreepidInvite;
     roomOobData?: object;
     currentRoomId: string;
-    ConferenceHandler?: object;
     collapseLhs: boolean;
     config: {
         piwik: {
@@ -637,7 +636,6 @@ class LoggedInView extends React.Component<IProps, IState> {
                     viaServers={this.props.viaServers}
                     key={this.props.currentRoomId || 'roomview'}
                     disabled={this.props.middleDisabled}
-                    ConferenceHandler={this.props.ConferenceHandler}
                     resizeNotifier={this.props.resizeNotifier}
                 />;
                 break;
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx
index 3f4b3115af..a638ad6de1 100644
--- a/src/components/structures/MatrixChat.tsx
+++ b/src/components/structures/MatrixChat.tsx
@@ -149,7 +149,6 @@ interface IRoomInfo {
 interface IProps { // TODO type things better
     config: Record<string, any>;
     serverConfig?: ValidatedServerConfig;
-    ConferenceHandler?: any;
     onNewScreen: (screen: string, replaceLast: boolean) => void;
     enableGuest?: boolean;
     // the queryParams extracted from the [real] query-string of the URI
@@ -1856,7 +1855,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
         } else {
             subtitle = `${this.subTitleStatus} ${subtitle}`;
         }
-        document.title = `${SdkConfig.get().brand} ${subtitle}`;
+
+        const title = `${SdkConfig.get().brand} ${subtitle}`;
+
+        if (document.title !== title) {
+            document.title = title;
+        }
     }
 
     updateStatusIndicator(state: string, prevState: string) {
diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx
index 8e9a7ab318..4927c6b712 100644
--- a/src/components/structures/RoomView.tsx
+++ b/src/components/structures/RoomView.tsx
@@ -69,7 +69,6 @@ import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel";
 import AuxPanel from "../views/rooms/AuxPanel";
 import RoomHeader from "../views/rooms/RoomHeader";
 import TintableSvg from "../views/elements/TintableSvg";
-import type * as ConferenceHandler from '../../VectorConferenceHandler';
 import {XOR} from "../../@types/common";
 import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
 
@@ -84,8 +83,6 @@ if (DEBUG) {
 }
 
 interface IProps {
-    ConferenceHandler?: ConferenceHandler;
-
     threepidInvite: IThreepidInvite,
 
     // Any data about the room that would normally come from the homeserver
@@ -181,7 +178,6 @@ export interface IState {
     matrixClientIsReady: boolean;
     showUrlPreview?: boolean;
     e2eStatus?: E2EStatus;
-    displayConfCallNotification?: boolean;
     rejecting?: boolean;
     rejectError?: Error;
 }
@@ -488,8 +484,6 @@ export default class RoomView extends React.Component<IProps, IState> {
             callState: callState,
         });
 
-        this.updateConfCallNotification();
-
         window.addEventListener('beforeunload', this.onPageUnload);
         if (this.props.resizeNotifier) {
             this.props.resizeNotifier.on("middlePanelResized", this.onResize);
@@ -724,10 +718,6 @@ export default class RoomView extends React.Component<IProps, IState> {
                     callState = call.call_state;
                 }
 
-                // possibly remove the conf call notification if we're now in
-                // the conf
-                this.updateConfCallNotification();
-
                 this.setState({
                     callState: callState,
                 });
@@ -1018,9 +1008,6 @@ export default class RoomView extends React.Component<IProps, IState> {
 
     // rate limited because a power level change will emit an event for every member in the room.
     private updateRoomMembers = rateLimitedFunc((dueToMember) => {
-        // a member state changed in this room
-        // refresh the conf call notification state
-        this.updateConfCallNotification();
         this.updateDMState();
 
         let memberCountInfluence = 0;
@@ -1049,30 +1036,6 @@ export default class RoomView extends React.Component<IProps, IState> {
         this.setState({isAlone: joinedOrInvitedMemberCount === 1});
     }
 
-    private updateConfCallNotification() {
-        const room = this.state.room;
-        if (!room || !this.props.ConferenceHandler) {
-            return;
-        }
-        const confMember = room.getMember(
-            this.props.ConferenceHandler.getConferenceUserIdForRoom(room.roomId),
-        );
-
-        if (!confMember) {
-            return;
-        }
-        const confCall = this.props.ConferenceHandler.getConferenceCallForRoom(confMember.roomId);
-
-        // A conf call notification should be displayed if there is an ongoing
-        // conf call but this cilent isn't a part of it.
-        this.setState({
-            displayConfCallNotification: (
-                (!confCall || confCall.call_state === "ended") &&
-                confMember.membership === "join"
-            ),
-        });
-    }
-
     private updateDMState() {
         const room = this.state.room;
         if (room.getMyMembership() != "join") {
@@ -1681,7 +1644,7 @@ export default class RoomView extends React.Component<IProps, IState> {
         if (!this.state.room) {
             return null;
         }
-        return CallHandler.getCallForRoom(this.state.room.roomId);
+        return CallHandler.sharedInstance().getCallForRoom(this.state.room.roomId);
     }
 
     // this has to be a proper method rather than an unnamed function,
@@ -1925,9 +1888,7 @@ export default class RoomView extends React.Component<IProps, IState> {
                 room={this.state.room}
                 fullHeight={false}
                 userId={this.context.credentials.userId}
-                conferenceHandler={this.props.ConferenceHandler}
                 draggingFile={this.state.draggingFile}
-                displayConfCallNotification={this.state.displayConfCallNotification}
                 maxHeight={this.state.auxPanelMaxHeight}
                 showApps={this.state.showApps}
                 hideAppsDrawer={false}
diff --git a/src/components/views/rooms/AuxPanel.js b/src/components/views/rooms/AuxPanel.js
index f2211dba5c..b7ed457a74 100644
--- a/src/components/views/rooms/AuxPanel.js
+++ b/src/components/views/rooms/AuxPanel.js
@@ -39,15 +39,9 @@ export default class AuxPanel extends React.Component {
         showApps: PropTypes.bool, // Render apps
         hideAppsDrawer: PropTypes.bool, // Do not display apps drawer and content (may still be rendered)
 
-        // Conference Handler implementation
-        conferenceHandler: PropTypes.object,
-
         // set to true to show the file drop target
         draggingFile: PropTypes.bool,
 
-        // set to true to show the 'active conf call' banner
-        displayConfCallNotification: PropTypes.bool,
-
         // maxHeight attribute for the aux panel and the video
         // therein
         maxHeight: PropTypes.number,
@@ -161,39 +155,9 @@ export default class AuxPanel extends React.Component {
             );
         }
 
-        let conferenceCallNotification = null;
-        if (this.props.displayConfCallNotification) {
-            let supportedText = '';
-            let joinNode;
-            if (!MatrixClientPeg.get().supportsVoip()) {
-                supportedText = _t(" (unsupported)");
-            } else {
-                joinNode = (<span>
-                    { _t(
-                        "Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.",
-                        {},
-                        {
-                            'voiceText': (sub) => <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'voice');}} href="#">{ sub }</a>,
-                            'videoText': (sub) => <a onClick={(event)=>{ this.onConferenceNotificationClick(event, 'video');}} href="#">{ sub }</a>,
-                        },
-                    ) }
-                </span>);
-            }
-            // XXX: the translation here isn't great: appending ' (unsupported)' is likely to not make sense in many languages,
-            // but there are translations for this in the languages we do have so I'm leaving it for now.
-            conferenceCallNotification = (
-                <div className="mx_RoomView_ongoingConfCallNotification">
-                    { _t("Ongoing conference call%(supportedText)s.", {supportedText: supportedText}) }
-                    &nbsp;
-                    { joinNode }
-                </div>
-            );
-        }
-
         const callView = (
             <CallView
                 room={this.props.room}
-                ConferenceHandler={this.props.conferenceHandler}
                 onResize={this.props.onResize}
                 maxVideoHeight={this.props.maxHeight}
             />
@@ -276,7 +240,6 @@ export default class AuxPanel extends React.Component {
                 { appsDrawer }
                 { fileDropTarget }
                 { callView }
-                { conferenceCallNotification }
                 { this.props.children }
             </AutoHideScrollbar>
         );
diff --git a/src/components/views/rooms/MemberList.js b/src/components/views/rooms/MemberList.js
index 40b3b042b1..ae122a3783 100644
--- a/src/components/views/rooms/MemberList.js
+++ b/src/components/views/rooms/MemberList.js
@@ -24,7 +24,6 @@ import {isValid3pidInvite} from "../../../RoomInvite";
 import rate_limited_func from "../../../ratelimitedfunc";
 import {MatrixClientPeg} from "../../../MatrixClientPeg";
 import * as sdk from "../../../index";
-import CallHandler from "../../../CallHandler";
 import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
 import BaseCard from "../right_panel/BaseCard";
 import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
@@ -233,15 +232,10 @@ export default class MemberList extends React.Component {
     }
 
     roomMembers() {
-        const ConferenceHandler = CallHandler.getConferenceHandler();
-
         const allMembers = this.getMembersWithUser();
         const filteredAndSortedMembers = allMembers.filter((m) => {
             return (
                 m.membership === 'join' || m.membership === 'invite'
-            ) && (
-                !ConferenceHandler ||
-                (ConferenceHandler && !ConferenceHandler.isConferenceUser(m.userId))
             );
         });
         filteredAndSortedMembers.sort(this.memberSort);
diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js
index 81c2ae7a33..e6cd686e3c 100644
--- a/src/components/views/rooms/MessageComposer.js
+++ b/src/components/views/rooms/MessageComposer.js
@@ -87,7 +87,7 @@ VideoCallButton.propTypes = {
 function HangupButton(props) {
     const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
     const onHangupClick = () => {
-        const call = CallHandler.getCallForRoom(props.roomId);
+        const call = CallHandler.sharedInstance().getCallForRoom(props.roomId);
         if (!call) {
             return;
         }
diff --git a/src/components/views/rooms/RoomUpgradeWarningBar.js b/src/components/views/rooms/RoomUpgradeWarningBar.js
index 531428198e..877cfb39d7 100644
--- a/src/components/views/rooms/RoomUpgradeWarningBar.js
+++ b/src/components/views/rooms/RoomUpgradeWarningBar.js
@@ -1,5 +1,5 @@
 /*
-Copyright 2018 New Vector Ltd
+Copyright 2018-2020 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.
@@ -28,6 +28,11 @@ export default class RoomUpgradeWarningBar extends React.Component {
         recommendation: PropTypes.object.isRequired,
     };
 
+    constructor(props) {
+        super(props);
+        this.state = {};
+    }
+
     componentDidMount() {
         const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", "");
         this.setState({upgraded: tombstone && tombstone.getContent().replacement_room});
@@ -35,6 +40,13 @@ export default class RoomUpgradeWarningBar extends React.Component {
         MatrixClientPeg.get().on("RoomState.events", this._onStateEvents);
     }
 
+    componentWillUnmount() {
+        const cli = MatrixClientPeg.get();
+        if (cli) {
+            cli.removeListener("RoomState.events", this._onStateEvents);
+        }
+    }
+
     _onStateEvents = (event, state) => {
         if (!this.props.room || event.getRoomId() !== this.props.room.roomId) {
             return;
diff --git a/src/components/views/voip/CallContainer.tsx b/src/components/views/voip/CallContainer.tsx
index 18a9c098d6..51925cb147 100644
--- a/src/components/views/voip/CallContainer.tsx
+++ b/src/components/views/voip/CallContainer.tsx
@@ -17,7 +17,6 @@ limitations under the License.
 import React from 'react';
 import IncomingCallBox from './IncomingCallBox';
 import CallPreview from './CallPreview';
-import * as VectorConferenceHandler from '../../../VectorConferenceHandler';
 
 interface IProps {
 
@@ -31,7 +30,7 @@ export default class CallContainer extends React.PureComponent<IProps, IState> {
     public render() {
         return <div className="mx_CallContainer">
             <IncomingCallBox />
-            <CallPreview ConferenceHandler={VectorConferenceHandler} />
+            <CallPreview />
         </div>;
     }
 }
diff --git a/src/components/views/voip/CallPreview.tsx b/src/components/views/voip/CallPreview.tsx
index 4352fc95e4..9acbece8b3 100644
--- a/src/components/views/voip/CallPreview.tsx
+++ b/src/components/views/voip/CallPreview.tsx
@@ -26,10 +26,6 @@ import PersistentApp from "../elements/PersistentApp";
 import SettingsStore from "../../../settings/SettingsStore";
 
 interface IProps {
-    // A Conference Handler implementation
-    // Must have a function signature:
-    //  getConferenceCallForRoom(roomId: string): MatrixCall
-    ConferenceHandler: any;
 }
 
 interface IState {
@@ -47,7 +43,7 @@ export default class CallPreview extends React.Component<IProps, IState> {
 
         this.state = {
             roomId: RoomViewStore.getRoomId(),
-            activeCall: CallHandler.getAnyActiveCall(),
+            activeCall: CallHandler.sharedInstance().getAnyActiveCall(),
         };
     }
 
@@ -77,14 +73,14 @@ export default class CallPreview extends React.Component<IProps, IState> {
             // may hide the global CallView if the call it is tracking is dead
             case 'call_state':
                 this.setState({
-                    activeCall: CallHandler.getAnyActiveCall(),
+                    activeCall: CallHandler.sharedInstance().getAnyActiveCall(),
                 });
                 break;
         }
     };
 
     private onCallViewClick = () => {
-        const call = CallHandler.getAnyActiveCall();
+        const call = CallHandler.sharedInstance().getAnyActiveCall();
         if (call) {
             dis.dispatch({
                 action: 'view_room',
@@ -94,7 +90,7 @@ export default class CallPreview extends React.Component<IProps, IState> {
     };
 
     public render() {
-        const callForRoom = CallHandler.getCallForRoom(this.state.roomId);
+        const callForRoom = CallHandler.sharedInstance().getCallForRoom(this.state.roomId);
         const showCall = (
             this.state.activeCall &&
             this.state.activeCall.call_state === 'connected' &&
@@ -106,7 +102,6 @@ export default class CallPreview extends React.Component<IProps, IState> {
                 <CallView
                     className="mx_CallPreview"
                     onClick={this.onCallViewClick}
-                    ConferenceHandler={this.props.ConferenceHandler}
                     showHangup={true}
                 />
             );
diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx
index 1d3a62984a..2ab291ae86 100644
--- a/src/components/views/voip/CallView.tsx
+++ b/src/components/views/voip/CallView.tsx
@@ -31,11 +31,6 @@ interface IProps {
         // room; if not, we will show any active call.
         room?: Room;
 
-        // A Conference Handler implementation
-        // Must have a function signature:
-        //  getConferenceCallForRoom(roomId: string): MatrixCall
-        ConferenceHandler?: any;
-
         // maxHeight style attribute for the video panel
         maxVideoHeight?: number;
 
@@ -96,14 +91,13 @@ export default class CallView extends React.Component<IProps, IState> {
 
         if (this.props.room) {
             const roomId = this.props.room.roomId;
-            call = CallHandler.getCallForRoom(roomId) ||
-                (this.props.ConferenceHandler ? this.props.ConferenceHandler.getConferenceCallForRoom(roomId) : null);
+            call = CallHandler.sharedInstance().getCallForRoom(roomId);
 
             if (this.call) {
                 this.setState({ call: call });
             }
         } else {
-            call = CallHandler.getAnyActiveCall();
+            call = CallHandler.sharedInstance().getAnyActiveCall();
             // Ignore calls if we can't get the room associated with them.
             // I think the underlying problem is that the js-sdk sends events
             // for calls before it has made the rooms available in the store,
@@ -115,20 +109,19 @@ export default class CallView extends React.Component<IProps, IState> {
         }
 
         if (call) {
-            call.setLocalVideoElement(this.getVideoView().getLocalVideoElement());
-            call.setRemoteVideoElement(this.getVideoView().getRemoteVideoElement());
-            // always use a separate element for audio stream playback.
-            // this is to let us move CallView around the DOM without interrupting remote audio
-            // during playback, by having the audio rendered by a top-level <audio/> element.
-            // rather than being rendered by the main remoteVideo <video/> element.
-            call.setRemoteAudioElement(this.getVideoView().getRemoteAudioElement());
+            if (this.getVideoView()) {
+                call.setLocalVideoElement(this.getVideoView().getLocalVideoElement());
+                call.setRemoteVideoElement(this.getVideoView().getRemoteVideoElement());
+
+                // always use a separate element for audio stream playback.
+                // this is to let us move CallView around the DOM without interrupting remote audio
+                // during playback, by having the audio rendered by a top-level <audio/> element.
+                // rather than being rendered by the main remoteVideo <video/> element.
+                call.setRemoteAudioElement(this.getVideoView().getRemoteAudioElement());
+            }
         }
         if (call && call.type === "video" && call.call_state !== "ended" && call.call_state !== "ringing") {
-            // if this call is a conf call, don't display local video as the
-            // conference will have us in it
-            this.getVideoView().getLocalVideoElement().style.display = (
-                call.confUserId ? "none" : "block"
-            );
+            this.getVideoView().getLocalVideoElement().style.display = "block";
             this.getVideoView().getRemoteVideoElement().style.display = "block";
         } else {
             this.getVideoView().getLocalVideoElement().style.display = "none";
diff --git a/src/components/views/voip/IncomingCallBox.tsx b/src/components/views/voip/IncomingCallBox.tsx
index b7cba7a70f..8e5d0f9e4a 100644
--- a/src/components/views/voip/IncomingCallBox.tsx
+++ b/src/components/views/voip/IncomingCallBox.tsx
@@ -52,7 +52,7 @@ export default class IncomingCallBox extends React.Component<IProps, IState> {
     private onAction = (payload: ActionPayload) => {
         switch (payload.action) {
             case 'call_state': {
-                const call = CallHandler.getCall(payload.room_id);
+                const call = CallHandler.sharedInstance().getCallForRoom(payload.room_id);
                 if (call && call.call_state === 'ringing') {
                     this.setState({
                         incomingCall: call,
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index bb55cdc355..07e1e3da54 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -214,7 +214,6 @@
     "Reason": "Reason",
     "%(targetName)s accepted the invitation for %(displayName)s.": "%(targetName)s accepted the invitation for %(displayName)s.",
     "%(targetName)s accepted an invitation.": "%(targetName)s accepted an invitation.",
-    "%(senderName)s requested a VoIP conference.": "%(senderName)s requested a VoIP conference.",
     "%(senderName)s invited %(targetName)s.": "%(senderName)s invited %(targetName)s.",
     "%(senderName)s banned %(targetName)s.": "%(senderName)s banned %(targetName)s.",
     "%(oldDisplayName)s changed their display name to %(displayName)s.": "%(oldDisplayName)s changed their display name to %(displayName)s.",
@@ -224,9 +223,7 @@
     "%(senderName)s changed their profile picture.": "%(senderName)s changed their profile picture.",
     "%(senderName)s set a profile picture.": "%(senderName)s set a profile picture.",
     "%(senderName)s made no change.": "%(senderName)s made no change.",
-    "VoIP conference started.": "VoIP conference started.",
     "%(targetName)s joined the room.": "%(targetName)s joined the room.",
-    "VoIP conference finished.": "VoIP conference finished.",
     "%(targetName)s rejected the invitation.": "%(targetName)s rejected the invitation.",
     "%(targetName)s left the room.": "%(targetName)s left the room.",
     "%(senderName)s unbanned %(targetName)s.": "%(senderName)s unbanned %(targetName)s.",
@@ -1033,9 +1030,6 @@
     "Add a widget": "Add a widget",
     "Drop File Here": "Drop File Here",
     "Drop file here to upload": "Drop file here to upload",
-    " (unsupported)": " (unsupported)",
-    "Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.": "Join as <voiceText>voice</voiceText> or <videoText>video</videoText>.",
-    "Ongoing conference call%(supportedText)s.": "Ongoing conference call%(supportedText)s.",
     "This user has not verified all of their sessions.": "This user has not verified all of their sessions.",
     "You have not verified this user.": "You have not verified this user.",
     "You have verified this user. This user has verified all of their sessions.": "You have verified this user. This user has verified all of their sessions.",