From cb735c9439a9cd381b25b46d2cee01445b6b6dfb Mon Sep 17 00:00:00 2001
From: Robin <robin@robin.town>
Date: Fri, 16 Sep 2022 11:12:27 -0400
Subject: [PATCH] Element Call video rooms (#9267)

* Add an element_call_url config option

* Add a labs flag for Element Call video rooms

* Add Element Call as another video rooms backend

* Consolidate event power level defaults

* Remember to clean up participantsExpirationTimer

* Fix a code smell

* Test the clean method

* Fix some strict mode errors

* Test that clean still works when there are no state events

* Test auto-approval of Element Call widget capabilities

* Deduplicate some code to placate SonarCloud

* Fix more strict mode errors

* Test that calls disconnect when leaving the room

* Test the get methods of JitsiCall and ElementCall more

* Test Call.ts even more

* Test creation of Element video rooms

* Test that createRoom works for non-video-rooms

* Test Call's get method rather than the methods of derived classes

* Ensure that the clean method is able to preserve devices

* Remove duplicate clean method

* Fix lints

* Fix some strict mode errors in RoomPreviewCard

* Test RoomPreviewCard changes

* Quick and dirty hotfix for the community testing session

* Revert "Quick and dirty hotfix for the community testing session"

This reverts commit 37056514fbc040aaf1bff2539da770a1c8ba72a2.

* Fix the event schema for org.matrix.msc3401.call.member devices

* Remove org.matrix.call_duplicate_session from Element Call capabilities

It's no longer used by Element Call when running as a widget.

* Replace element_call_url with a map

* Make PiPs work for virtual widgets

* Auto-approve room timeline capability

Because Element Call uses this now

* Create a reusable isVideoRoom util
---
 res/css/views/rooms/_RoomPreviewCard.pcss     |   13 +-
 src/IConfigOptions.ts                         |    3 +
 src/SdkConfig.ts                              |   17 +-
 src/components/structures/RoomView.tsx        |    7 +-
 src/components/structures/SpaceRoomView.tsx   |   10 +-
 .../views/context_menus/RoomContextMenu.tsx   |    8 +-
 .../views/context_menus/SpaceContextMenu.tsx  |   21 +-
 .../views/context_menus/WidgetContextMenu.tsx |    9 +-
 .../views/dialogs/ModalWidgetDialog.tsx       |    2 +-
 src/components/views/elements/AppTile.tsx     |    8 +-
 .../views/elements/PersistentApp.tsx          |   55 +-
 .../views/right_panel/RoomSummaryCard.tsx     |    6 +-
 src/components/views/rooms/RoomHeader.tsx     |    3 +-
 src/components/views/rooms/RoomInfoLine.tsx   |    6 +-
 src/components/views/rooms/RoomList.tsx       |   18 +-
 src/components/views/rooms/RoomListHeader.tsx |    8 +-
 .../views/rooms/RoomPreviewCard.tsx           |   20 +-
 src/createRoom.ts                             |   50 +-
 src/i18n/strings/en_EN.json                   |    1 +
 src/models/Call.ts                            |  467 ++++++--
 src/settings/Settings.tsx                     |    8 +
 src/stores/CallStore.ts                       |    3 +-
 src/stores/WidgetStore.ts                     |   39 +-
 src/stores/widgets/ElementWidgetActions.ts    |    2 +-
 src/stores/widgets/StopGapWidget.ts           |   15 +-
 src/stores/widgets/StopGapWidgetDriver.ts     |   46 +
 src/utils/GroupCallUtils.ts                   |  175 ---
 src/utils/WidgetUtils.ts                      |    4 +-
 src/utils/video-rooms.ts                      |   21 +
 .../views/rooms/RoomPreviewCard-test.tsx      |  120 ++
 test/createRoom-test.ts                       |   75 +-
 test/models/Call-test.ts                      | 1052 ++++++++++++-----
 test/stores/widgets/StopGapWidget-test.ts     |    1 +
 .../widgets/StopGapWidgetDriver-test.ts       |   79 +-
 test/test-utils/call.ts                       |   34 +-
 test/test-utils/test-utils.ts                 |    4 +-
 test/utils/GroupCallUtils-test.ts             |  673 -----------
 37 files changed, 1699 insertions(+), 1384 deletions(-)
 delete mode 100644 src/utils/GroupCallUtils.ts
 create mode 100644 src/utils/video-rooms.ts
 create mode 100644 test/components/views/rooms/RoomPreviewCard-test.tsx
 delete mode 100644 test/utils/GroupCallUtils-test.ts

diff --git a/res/css/views/rooms/_RoomPreviewCard.pcss b/res/css/views/rooms/_RoomPreviewCard.pcss
index 255a9633b2..5f93f73590 100644
--- a/res/css/views/rooms/_RoomPreviewCard.pcss
+++ b/res/css/views/rooms/_RoomPreviewCard.pcss
@@ -64,12 +64,6 @@ limitations under the License.
                 color: $secondary-content;
             }
         }
-
-        /* XXX Remove this when video rooms leave beta */
-        .mx_BetaCard_betaPill {
-            margin-inline-start: auto;
-            align-self: start;
-        }
     }
 
     .mx_RoomPreviewCard_avatar {
@@ -104,6 +98,13 @@ limitations under the License.
                 mask-image: url('$(res)/img/element-icons/call/video-call.svg');
             }
         }
+
+        /* XXX Remove this when video rooms leave beta */
+        .mx_BetaCard_betaPill {
+            position: absolute;
+            inset-block-start: $spacing-32;
+            inset-inline-end: $spacing-24;
+        }
     }
 
     h1.mx_RoomPreviewCard_name {
diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts
index 395a97d3d4..837d3050f3 100644
--- a/src/IConfigOptions.ts
+++ b/src/IConfigOptions.ts
@@ -116,6 +116,9 @@ export interface IConfigOptions {
     voip?: {
         obey_asserted_identity?: boolean; // MSC3086
     };
+    element_call: {
+        url: string;
+    };
 
     logout_redirect_url?: string;
 
diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts
index 93b89a92ea..d466a05074 100644
--- a/src/SdkConfig.ts
+++ b/src/SdkConfig.ts
@@ -30,6 +30,9 @@ export const DEFAULTS: IConfigOptions = {
     jitsi: {
         preferred_domain: "meet.element.io",
     },
+    element_call: {
+        url: "https://call.element.io",
+    },
 
     // @ts-ignore - we deliberately use the camelCase version here so we trigger
     // the fallback behaviour. If we used the snake_case version then we'd break
@@ -79,14 +82,8 @@ export default class SdkConfig {
         return val === undefined ? undefined : null;
     }
 
-    public static put(cfg: IConfigOptions) {
-        const defaultKeys = Object.keys(DEFAULTS);
-        for (let i = 0; i < defaultKeys.length; ++i) {
-            if (cfg[defaultKeys[i]] === undefined) {
-                cfg[defaultKeys[i]] = DEFAULTS[defaultKeys[i]];
-            }
-        }
-        SdkConfig.setInstance(cfg);
+    public static put(cfg: Partial<IConfigOptions>) {
+        SdkConfig.setInstance({ ...DEFAULTS, ...cfg });
     }
 
     /**
@@ -97,9 +94,7 @@ export default class SdkConfig {
     }
 
     public static add(cfg: Partial<IConfigOptions>) {
-        const liveConfig = SdkConfig.get();
-        const newConfig = Object.assign({}, liveConfig, cfg);
-        SdkConfig.put(newConfig);
+        SdkConfig.put({ ...SdkConfig.get(), ...cfg });
     }
 }
 
diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx
index 3ce235116b..780619c7e4 100644
--- a/src/components/structures/RoomView.tsx
+++ b/src/components/structures/RoomView.tsx
@@ -119,6 +119,7 @@ import { isLocalRoom } from '../../utils/localRoom/isLocalRoom';
 import { ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload";
 import { RoomStatusBarUnsentMessages } from './RoomStatusBarUnsentMessages';
 import { LargeLoader } from './LargeLoader';
+import { isVideoRoom } from '../../utils/video-rooms';
 
 const DEBUG = false;
 let debuglog = function(msg: string) {};
@@ -514,7 +515,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
     };
 
     private getMainSplitContentType = (room: Room) => {
-        if (SettingsStore.getValue("feature_video_rooms") && room.isElementVideoRoom()) {
+        if (SettingsStore.getValue("feature_video_rooms") && isVideoRoom(room)) {
             return MainSplitContentType.Video;
         }
         if (WidgetLayoutStore.instance.hasMaximisedWidget(room)) {
@@ -2015,8 +2016,8 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
 
         const myMembership = this.state.room.getMyMembership();
         if (
-            this.state.room.isElementVideoRoom() &&
-            !(SettingsStore.getValue("feature_video_rooms") && myMembership === "join")
+            isVideoRoom(this.state.room)
+            && !(SettingsStore.getValue("feature_video_rooms") && myMembership === "join")
         ) {
             return <ErrorBoundary>
                 <div className="mx_MainSplit">
diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx
index a1ee2d5094..b8c73ee0df 100644
--- a/src/components/structures/SpaceRoomView.tsx
+++ b/src/components/structures/SpaceRoomView.tsx
@@ -108,8 +108,9 @@ const SpaceLandingAddButton = ({ space }) => {
     const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms);
     const canCreateSpace = shouldShowComponent(UIComponent.CreateSpaces);
     const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
+    const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
 
-    let contextMenu;
+    let contextMenu: JSX.Element | null = null;
     if (menuDisplayed) {
         const rect = handle.current.getBoundingClientRect();
         contextMenu = <IconizedContextMenu
@@ -145,7 +146,12 @@ const SpaceLandingAddButton = ({ space }) => {
                                 e.stopPropagation();
                                 closeMenu();
 
-                                if (await showCreateNewRoom(space, RoomType.ElementVideo)) {
+                                if (
+                                    await showCreateNewRoom(
+                                        space,
+                                        elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
+                                    )
+                                ) {
                                     defaultDispatcher.fire(Action.UpdateSpaceHierarchy);
                                 }
                             }}
diff --git a/src/components/views/context_menus/RoomContextMenu.tsx b/src/components/views/context_menus/RoomContextMenu.tsx
index 3dfa9e4350..b9923d9278 100644
--- a/src/components/views/context_menus/RoomContextMenu.tsx
+++ b/src/components/views/context_menus/RoomContextMenu.tsx
@@ -105,10 +105,14 @@ const RoomContextMenu = ({ room, onFinished, ...props }: IProps) => {
     }
 
     const isDm = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
-    const isVideoRoom = useFeatureEnabled("feature_video_rooms") && room.isElementVideoRoom();
+    const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
+    const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
+    const isVideoRoom = videoRoomsEnabled && (
+        room.isElementVideoRoom() || (elementCallVideoRoomsEnabled && room.isCallRoom())
+    );
 
     let inviteOption: JSX.Element;
-    if (room.canInvite(cli.getUserId()) && !isDm) {
+    if (room.canInvite(cli.getUserId()!) && !isDm) {
         const onInviteClick = (ev: ButtonEvent) => {
             ev.preventDefault();
             ev.stopPropagation();
diff --git a/src/components/views/context_menus/SpaceContextMenu.tsx b/src/components/views/context_menus/SpaceContextMenu.tsx
index ce28ae3295..3f743d4e69 100644
--- a/src/components/views/context_menus/SpaceContextMenu.tsx
+++ b/src/components/views/context_menus/SpaceContextMenu.tsx
@@ -35,6 +35,7 @@ import { ButtonEvent } from "../elements/AccessibleButton";
 import defaultDispatcher from "../../../dispatcher/dispatcher";
 import { BetaPill } from "../beta/BetaCard";
 import SettingsStore from "../../../settings/SettingsStore";
+import { useFeatureEnabled } from "../../../hooks/useSettings";
 import { Action } from "../../../dispatcher/actions";
 import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
 import { UIComponent } from "../../../settings/UIFeature";
@@ -48,9 +49,9 @@ interface IProps extends IContextMenuProps {
 
 const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) => {
     const cli = useContext(MatrixClientContext);
-    const userId = cli.getUserId();
+    const userId = cli.getUserId()!;
 
-    let inviteOption;
+    let inviteOption: JSX.Element | null = null;
     if (space.getJoinRule() === "public" || space.canInvite(userId)) {
         const onInviteClick = (ev: ButtonEvent) => {
             ev.preventDefault();
@@ -71,8 +72,8 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) =
         );
     }
 
-    let settingsOption;
-    let leaveOption;
+    let settingsOption: JSX.Element | null = null;
+    let leaveOption: JSX.Element | null = null;
     if (shouldShowSpaceSettings(space)) {
         const onSettingsClick = (ev: ButtonEvent) => {
             ev.preventDefault();
@@ -110,7 +111,7 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) =
         );
     }
 
-    let devtoolsOption;
+    let devtoolsOption: JSX.Element | null = null;
     if (SettingsStore.getValue("developerMode")) {
         const onViewTimelineClick = (ev: ButtonEvent) => {
             ev.preventDefault();
@@ -134,12 +135,15 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) =
         );
     }
 
+    const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
+    const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
+
     const hasPermissionToAddSpaceChild = space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
     const canAddRooms = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateRooms);
-    const canAddVideoRooms = canAddRooms && SettingsStore.getValue("feature_video_rooms");
+    const canAddVideoRooms = canAddRooms && videoRoomsEnabled;
     const canAddSubSpaces = hasPermissionToAddSpaceChild && shouldShowComponent(UIComponent.CreateSpaces);
 
-    let newRoomSection: JSX.Element;
+    let newRoomSection: JSX.Element | null = null;
     if (canAddRooms || canAddSubSpaces) {
         const onNewRoomClick = (ev: ButtonEvent) => {
             ev.preventDefault();
@@ -154,7 +158,7 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) =
             ev.preventDefault();
             ev.stopPropagation();
 
-            showCreateNewRoom(space, RoomType.ElementVideo);
+            showCreateNewRoom(space, elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo);
             onFinished();
         };
 
@@ -266,4 +270,3 @@ const SpaceContextMenu = ({ space, hideHeader, onFinished, ...props }: IProps) =
 };
 
 export default SpaceContextMenu;
-
diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx
index 64640c1163..b03a29a8fd 100644
--- a/src/components/views/context_menus/WidgetContextMenu.tsx
+++ b/src/components/views/context_menus/WidgetContextMenu.tsx
@@ -146,10 +146,9 @@ const WidgetContextMenu: React.FC<IProps> = ({
         />;
     }
 
-    let isAllowedWidget = SettingsStore.getValue("allowedWidgets", roomId)[app.eventId];
-    if (isAllowedWidget === undefined) {
-        isAllowedWidget = app.creatorUserId === cli.getUserId();
-    }
+    const isAllowedWidget =
+        (app.eventId !== undefined && (SettingsStore.getValue("allowedWidgets", roomId)[app.eventId] ?? false))
+        || app.creatorUserId === cli.getUserId();
 
     const isLocalWidget = WidgetType.JITSI.matches(app.type);
     let revokeButton;
@@ -157,7 +156,7 @@ const WidgetContextMenu: React.FC<IProps> = ({
         const onRevokeClick = () => {
             logger.info("Revoking permission for widget to load: " + app.eventId);
             const current = SettingsStore.getValue("allowedWidgets", roomId);
-            current[app.eventId] = false;
+            if (app.eventId !== undefined) current[app.eventId] = false;
             const level = SettingsStore.firstSupportedLevel("allowedWidgets");
             SettingsStore.setValue("allowedWidgets", roomId, level, current).catch(err => {
                 logger.error(err);
diff --git a/src/components/views/dialogs/ModalWidgetDialog.tsx b/src/components/views/dialogs/ModalWidgetDialog.tsx
index 84564c22cf..faf37bd290 100644
--- a/src/components/views/dialogs/ModalWidgetDialog.tsx
+++ b/src/components/views/dialogs/ModalWidgetDialog.tsx
@@ -78,7 +78,7 @@ export default class ModalWidgetDialog extends React.PureComponent<IProps, IStat
     }
 
     public componentDidMount() {
-        const driver = new StopGapWidgetDriver([], this.widget, WidgetKind.Modal);
+        const driver = new StopGapWidgetDriver([], this.widget, WidgetKind.Modal, false);
         const messaging = new ClientWidgetApi(this.widget, this.appFrame.current, driver);
         this.setState({ messaging });
     }
diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx
index 552c77666e..ae37889180 100644
--- a/src/components/views/elements/AppTile.tsx
+++ b/src/components/views/elements/AppTile.tsx
@@ -165,10 +165,8 @@ export default class AppTile extends React.Component<IProps, IState> {
         if (!props.room) return true; // user widgets always have permissions
 
         const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", props.room.roomId);
-        if (currentlyAllowedWidgets[props.app.eventId] === undefined) {
-            return props.userId === props.creatorUserId;
-        }
-        return !!currentlyAllowedWidgets[props.app.eventId];
+        const allowed = props.app.eventId !== undefined && (currentlyAllowedWidgets[props.app.eventId] ?? false);
+        return allowed || props.userId === props.creatorUserId;
     };
 
     private onUserLeftRoom() {
@@ -442,7 +440,7 @@ export default class AppTile extends React.Component<IProps, IState> {
         const roomId = this.props.room?.roomId;
         logger.info("Granting permission for widget to load: " + this.props.app.eventId);
         const current = SettingsStore.getValue("allowedWidgets", roomId);
-        current[this.props.app.eventId] = true;
+        if (this.props.app.eventId !== undefined) current[this.props.app.eventId] = true;
         const level = SettingsStore.firstSupportedLevel("allowedWidgets");
         SettingsStore.setValue("allowedWidgets", roomId, level, current).then(() => {
             this.setState({ hasPermissionToLoad: true });
diff --git a/src/components/views/elements/PersistentApp.tsx b/src/components/views/elements/PersistentApp.tsx
index f0ad74f09e..8a7f21e4fe 100644
--- a/src/components/views/elements/PersistentApp.tsx
+++ b/src/components/views/elements/PersistentApp.tsx
@@ -20,7 +20,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
 
 import WidgetUtils from '../../../utils/WidgetUtils';
 import AppTile from "./AppTile";
-import { IApp } from '../../../stores/WidgetStore';
+import WidgetStore from '../../../stores/WidgetStore';
 import MatrixClientContext from "../../../contexts/MatrixClientContext";
 
 interface IProps {
@@ -37,44 +37,27 @@ export default class PersistentApp extends React.Component<IProps> {
 
     constructor(props: IProps, context: ContextType<typeof MatrixClientContext>) {
         super(props, context);
-        this.room = context.getRoom(this.props.persistentRoomId);
+        this.room = context.getRoom(this.props.persistentRoomId)!;
     }
 
-    private get app(): IApp | null {
-        // get the widget data
-        const appEvent = WidgetUtils.getRoomWidgets(this.room).find(ev =>
-            ev.getStateKey() === this.props.persistentWidgetId,
-        );
+    public render(): JSX.Element | null {
+        const app = WidgetStore.instance.get(this.props.persistentWidgetId, this.props.persistentRoomId);
+        if (!app) return null;
 
-        if (appEvent) {
-            return WidgetUtils.makeAppConfig(
-                appEvent.getStateKey(), appEvent.getContent(), appEvent.getSender(),
-                this.room.roomId, appEvent.getId(),
-            );
-        } else {
-            return null;
-        }
-    }
-
-    public render(): JSX.Element {
-        const app = this.app;
-        if (app) {
-            return <AppTile
-                key={app.id}
-                app={app}
-                fullWidth={true}
-                room={this.room}
-                userId={this.context.credentials.userId}
-                creatorUserId={app.creatorUserId}
-                widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
-                waitForIframeLoad={app.waitForIframeLoad}
-                miniMode={true}
-                showMenubar={false}
-                pointerEvents={this.props.pointerEvents}
-                movePersistedElement={this.props.movePersistedElement}
-            />;
-        }
-        return null;
+        return <AppTile
+            key={app.id}
+            app={app}
+            fullWidth={true}
+            room={this.room}
+            userId={this.context.credentials.userId}
+            creatorUserId={app.creatorUserId}
+            widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
+            waitForIframeLoad={app.waitForIframeLoad}
+            miniMode={true}
+            showMenubar={false}
+            pointerEvents={this.props.pointerEvents}
+            movePersistedElement={this.props.movePersistedElement}
+        />;
     }
 }
 
diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx
index f0ff1d9d44..d2429f1a7b 100644
--- a/src/components/views/right_panel/RoomSummaryCard.tsx
+++ b/src/components/views/right_panel/RoomSummaryCard.tsx
@@ -262,7 +262,11 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
     const isRoomEncrypted = useIsEncrypted(cli, room);
     const roomContext = useContext(RoomContext);
     const e2eStatus = roomContext.e2eStatus;
-    const isVideoRoom = useFeatureEnabled("feature_video_rooms") && room.isElementVideoRoom();
+    const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
+    const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
+    const isVideoRoom = videoRoomsEnabled && (
+        room.isElementVideoRoom() || (elementCallVideoRoomsEnabled && room.isCallRoom())
+    );
 
     const alias = room.getCanonicalAlias() || room.getAltAliases()[0] || "";
     const header = <React.Fragment>
diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx
index 22a0a8043a..d64d3d7e32 100644
--- a/src/components/views/rooms/RoomHeader.tsx
+++ b/src/components/views/rooms/RoomHeader.tsx
@@ -47,6 +47,7 @@ import RoomLiveShareWarning from '../beacon/RoomLiveShareWarning';
 import { BetaPill } from "../beta/BetaCard";
 import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
 import { UPDATE_EVENT } from "../../../stores/AsyncStore";
+import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
 
 export interface ISearchInfo {
     searchTerm: string;
@@ -312,7 +313,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
 
         const e2eIcon = this.props.e2eStatus ? <E2EIcon status={this.props.e2eStatus} /> : undefined;
 
-        const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && this.props.room.isElementVideoRoom();
+        const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && calcIsVideoRoom(this.props.room);
         const viewLabs = () => defaultDispatcher.dispatch({
             action: Action.ViewUserSettings,
             initialTabId: UserTab.Labs,
diff --git a/src/components/views/rooms/RoomInfoLine.tsx b/src/components/views/rooms/RoomInfoLine.tsx
index 09214043d6..6ebea854a1 100644
--- a/src/components/views/rooms/RoomInfoLine.tsx
+++ b/src/components/views/rooms/RoomInfoLine.tsx
@@ -23,6 +23,7 @@ import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
 import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
 import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
 import { useRoomState } from "../../../hooks/useRoomState";
+import { useFeatureEnabled } from "../../../hooks/useSettings";
 import { useRoomMemberCount, useMyRoomMembership } from "../../../hooks/useRoomMembers";
 import AccessibleButton from "../elements/AccessibleButton";
 
@@ -44,9 +45,12 @@ const RoomInfoLine: FC<IProps> = ({ room }) => {
     const membership = useMyRoomMembership(room);
     const memberCount = useRoomMemberCount(room);
 
+    const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
+    const isVideoRoom = room.isElementVideoRoom() || (elementCallVideoRoomsEnabled && room.isCallRoom());
+
     let iconClass: string;
     let roomType: string;
-    if (room.isElementVideoRoom()) {
+    if (isVideoRoom) {
         iconClass = "mx_RoomInfoLine_video";
         roomType = _t("Video room");
     } else if (joinRule === JoinRule.Public) {
diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx
index 1353f8f5c8..0d6756a7e1 100644
--- a/src/components/views/rooms/RoomList.tsx
+++ b/src/components/views/rooms/RoomList.tsx
@@ -32,6 +32,7 @@ import { _t, _td } from "../../../languageHandler";
 import { MatrixClientPeg } from "../../../MatrixClientPeg";
 import PosthogTrackers from "../../../PosthogTrackers";
 import SettingsStore from "../../../settings/SettingsStore";
+import { useFeatureEnabled } from "../../../hooks/useSettings";
 import { UIComponent } from "../../../settings/UIFeature";
 import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
 import { ITagMap } from "../../../stores/room-list/algorithms/models";
@@ -200,8 +201,10 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
     });
 
     const showCreateRoom = shouldShowComponent(UIComponent.CreateRooms);
+    const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
+    const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
 
-    let contextMenuContent: JSX.Element;
+    let contextMenuContent: JSX.Element | null = null;
     if (menuDisplayed && activeSpace) {
         const canAddRooms = activeSpace.currentState.maySendStateEvent(EventType.SpaceChild,
             MatrixClientPeg.get().getUserId());
@@ -239,7 +242,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
                             tooltip={canAddRooms ? undefined
                                 : _t("You do not have permissions to create new rooms in this space")}
                         />
-                        { SettingsStore.getValue("feature_video_rooms") && (
+                        { videoRoomsEnabled && (
                             <IconizedContextMenuOption
                                 label={_t("New video room")}
                                 iconClassName="mx_RoomList_iconNewVideoRoom"
@@ -247,7 +250,10 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
                                     e.preventDefault();
                                     e.stopPropagation();
                                     closeMenu();
-                                    showCreateNewRoom(activeSpace, RoomType.ElementVideo);
+                                    showCreateNewRoom(
+                                        activeSpace,
+                                        elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
+                                    );
                                 }}
                                 disabled={!canAddRooms}
                                 tooltip={canAddRooms ? undefined
@@ -287,7 +293,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
                         PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e);
                     }}
                 />
-                { SettingsStore.getValue("feature_video_rooms") && (
+                { videoRoomsEnabled && (
                     <IconizedContextMenuOption
                         label={_t("New video room")}
                         iconClassName="mx_RoomList_iconNewVideoRoom"
@@ -297,7 +303,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
                             closeMenu();
                             defaultDispatcher.dispatch({
                                 action: "view_create_room",
-                                type: RoomType.ElementVideo,
+                                type: elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
                             });
                         }}
                     >
@@ -319,7 +325,7 @@ const UntaggedAuxButton = ({ tabIndex }: IAuxButtonProps) => {
         </IconizedContextMenuOptionList>;
     }
 
-    let contextMenu: JSX.Element;
+    let contextMenu: JSX.Element | null = null;
     if (menuDisplayed) {
         contextMenu = <IconizedContextMenu {...auxButtonContextMenuPosition(handle)} onFinished={closeMenu} compact>
             { contextMenuContent }
diff --git a/src/components/views/rooms/RoomListHeader.tsx b/src/components/views/rooms/RoomListHeader.tsx
index 3f8986bf67..f783e628f3 100644
--- a/src/components/views/rooms/RoomListHeader.tsx
+++ b/src/components/views/rooms/RoomListHeader.tsx
@@ -127,6 +127,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => {
         return SpaceStore.instance.allRoomsInHome;
     });
     const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
+    const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
     const pendingActions = usePendingActions();
 
     const canShowMainMenu = activeSpace || spaceKey === MetaSpace.Home;
@@ -211,7 +212,10 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => {
                         onClick={(e) => {
                             e.preventDefault();
                             e.stopPropagation();
-                            showCreateNewRoom(activeSpace, RoomType.ElementVideo);
+                            showCreateNewRoom(
+                                activeSpace,
+                                elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
+                            );
                             closePlusMenu();
                         }}
                     >
@@ -310,7 +314,7 @@ const RoomListHeader = ({ onVisibilityChange }: IProps) => {
                             e.stopPropagation();
                             defaultDispatcher.dispatch({
                                 action: "view_create_room",
-                                type: RoomType.ElementVideo,
+                                type: elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
                             });
                             closePlusMenu();
                         }}
diff --git a/src/components/views/rooms/RoomPreviewCard.tsx b/src/components/views/rooms/RoomPreviewCard.tsx
index b2d9710a7c..8b105af630 100644
--- a/src/components/views/rooms/RoomPreviewCard.tsx
+++ b/src/components/views/rooms/RoomPreviewCard.tsx
@@ -51,6 +51,8 @@ interface IProps {
 const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButtonClicked }) => {
     const cli = useContext(MatrixClientContext);
     const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
+    const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
+    const isVideoRoom = room.isElementVideoRoom() || (elementCallVideoRoomsEnabled && room.isCallRoom());
     const myMembership = useMyRoomMembership(room);
     useDispatcher(defaultDispatcher, payload => {
         if (payload.action === Action.JoinRoomError && payload.roomId === room.roomId) {
@@ -69,7 +71,7 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
         initialTabId: UserTab.Labs,
     });
 
-    let inviterSection: JSX.Element;
+    let inviterSection: JSX.Element | null = null;
     let joinButtons: JSX.Element;
     if (myMembership === "join") {
         joinButtons = (
@@ -86,10 +88,11 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
             </AccessibleButton>
         );
     } else if (myMembership === "invite") {
-        const inviteSender = room.getMember(cli.getUserId())?.events.member?.getSender();
-        const inviter = inviteSender && room.getMember(inviteSender);
+        const inviteSender = room.getMember(cli.getUserId()!)?.events.member?.getSender();
 
         if (inviteSender) {
+            const inviter = room.getMember(inviteSender);
+
             inviterSection = <div className="mx_RoomPreviewCard_inviter">
                 <MemberAvatar member={inviter} fallbackUserId={inviteSender} width={32} height={32} />
                 <div>
@@ -102,10 +105,6 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
                         { inviteSender }
                     </div> : null }
                 </div>
-                { room.isElementVideoRoom()
-                    ? <BetaPill onClick={viewLabs} tooltipTitle={_t("Video rooms are a beta feature")} />
-                    : null
-                }
             </div>;
         }
 
@@ -152,10 +151,11 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
     }
 
     let avatarRow: JSX.Element;
-    if (room.isElementVideoRoom()) {
+    if (isVideoRoom) {
         avatarRow = <>
             <RoomAvatar room={room} height={50} width={50} viewAvatarOnClick />
             <div className="mx_RoomPreviewCard_video" />
+            <BetaPill onClick={viewLabs} tooltipTitle={_t("Video rooms are a beta feature")} />
         </>;
     } else if (room.isSpaceRoom()) {
         avatarRow = <RoomAvatar room={room} height={80} width={80} viewAvatarOnClick />;
@@ -163,12 +163,12 @@ const RoomPreviewCard: FC<IProps> = ({ room, onJoinButtonClicked, onRejectButton
         avatarRow = <RoomAvatar room={room} height={50} width={50} viewAvatarOnClick />;
     }
 
-    let notice: string;
+    let notice: string | null = null;
     if (cannotJoin) {
         notice = _t("To view %(roomName)s, you need an invite", {
             roomName: room.name,
         });
-    } else if (room.isElementVideoRoom() && !videoRoomsEnabled) {
+    } else if (isVideoRoom && !videoRoomsEnabled) {
         notice = myMembership === "join"
             ? _t("To view, please enable video rooms in Labs first")
             : _t("To join, please enable video rooms in Labs first");
diff --git a/src/createRoom.ts b/src/createRoom.ts
index df7361c8e5..c1bcc122ca 100644
--- a/src/createRoom.ts
+++ b/src/createRoom.ts
@@ -37,7 +37,7 @@ import { getAddressType } from "./UserAddress";
 import { VIRTUAL_ROOM_EVENT_TYPE } from "./call-types";
 import SpaceStore from "./stores/spaces/SpaceStore";
 import { makeSpaceParentEvent } from "./utils/space";
-import { JitsiCall } from "./models/Call";
+import { JitsiCall, ElementCall } from "./models/Call";
 import { Action } from "./dispatcher/actions";
 import ErrorDialog from "./components/views/dialogs/ErrorDialog";
 import Spinner from "./components/views/elements/Spinner";
@@ -67,6 +67,17 @@ export interface IOpts {
     joinRule?: JoinRule;
 }
 
+const DEFAULT_EVENT_POWER_LEVELS = {
+    [EventType.RoomName]: 50,
+    [EventType.RoomAvatar]: 50,
+    [EventType.RoomPowerLevels]: 100,
+    [EventType.RoomHistoryVisibility]: 100,
+    [EventType.RoomCanonicalAlias]: 50,
+    [EventType.RoomTombstone]: 100,
+    [EventType.RoomServerAcl]: 100,
+    [EventType.RoomEncryption]: 100,
+};
+
 /**
  * Create a new room, and switch to it.
  *
@@ -131,23 +142,29 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
         if (opts.roomType === RoomType.ElementVideo) {
             createOpts.power_level_content_override = {
                 events: {
+                    ...DEFAULT_EVENT_POWER_LEVELS,
                     // Allow all users to send call membership updates
                     [JitsiCall.MEMBER_EVENT_TYPE]: 0,
                     // Make widgets immutable, even to admins
                     "im.vector.modular.widgets": 200,
-                    // Annoyingly, we have to reiterate all the defaults here
-                    [EventType.RoomName]: 50,
-                    [EventType.RoomAvatar]: 50,
-                    [EventType.RoomPowerLevels]: 100,
-                    [EventType.RoomHistoryVisibility]: 100,
-                    [EventType.RoomCanonicalAlias]: 50,
-                    [EventType.RoomTombstone]: 100,
-                    [EventType.RoomServerAcl]: 100,
-                    [EventType.RoomEncryption]: 100,
                 },
                 users: {
                     // Temporarily give ourselves the power to set up a widget
-                    [client.getUserId()]: 200,
+                    [client.getUserId()!]: 200,
+                },
+            };
+        } else if (opts.roomType === RoomType.UnstableCall) {
+            createOpts.power_level_content_override = {
+                events: {
+                    ...DEFAULT_EVENT_POWER_LEVELS,
+                    // Allow all users to send call membership updates
+                    "org.matrix.msc3401.call.member": 0,
+                    // Make calls immutable, even to admins
+                    "org.matrix.msc3401.call": 200,
+                },
+                users: {
+                    // Temporarily give ourselves the power to set up a call
+                    [client.getUserId()!]: 200,
                 },
             };
         }
@@ -281,11 +298,18 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
         }
     }).then(async () => {
         if (opts.roomType === RoomType.ElementVideo) {
-            // Set up video rooms with a Jitsi call
+            // Set up this video room with a Jitsi call
             await JitsiCall.create(await room);
 
             // Reset our power level back to admin so that the widget becomes immutable
-            const plEvent = (await room)?.currentState.getStateEvents(EventType.RoomPowerLevels, "");
+            const plEvent = (await room).currentState.getStateEvents(EventType.RoomPowerLevels, "");
+            await client.setPowerLevel(roomId, client.getUserId()!, 100, plEvent);
+        } else if (opts.roomType === RoomType.UnstableCall) {
+            // Set up this video room with an Element call
+            await ElementCall.create(await room);
+
+            // Reset our power level back to admin so that the call becomes immutable
+            const plEvent = (await room).currentState.getStateEvents(EventType.RoomPowerLevels, "");
             await client.setPowerLevel(roomId, client.getUserId()!, 100, plEvent);
         }
     }).then(function() {
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index e22e6b7499..0aab906f3d 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -909,6 +909,7 @@
     "Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)",
     "Send read receipts": "Send read receipts",
     "Sliding Sync mode (under active development, cannot be disabled)": "Sliding Sync mode (under active development, cannot be disabled)",
+    "Element Call video rooms": "Element Call video rooms",
     "Live Location Sharing (temporary implementation: locations persist in room history)": "Live Location Sharing (temporary implementation: locations persist in room history)",
     "Favourite Messages (under active development)": "Favourite Messages (under active development)",
     "Voice broadcast (under active development)": "Voice broadcast (under active development)",
diff --git a/src/models/Call.ts b/src/models/Call.ts
index 13451ab782..9b11261e85 100644
--- a/src/models/Call.ts
+++ b/src/models/Call.ts
@@ -16,18 +16,23 @@ limitations under the License.
 
 import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter";
 import { logger } from "matrix-js-sdk/src/logger";
+import { randomString } from "matrix-js-sdk/src/randomstring";
 import { MatrixClient } from "matrix-js-sdk/src/client";
 import { RoomEvent } from "matrix-js-sdk/src/models/room";
 import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
 import { CallType } from "matrix-js-sdk/src/webrtc/call";
-import { IWidgetApiRequest } from "matrix-widget-api";
+import { NamespacedValue } from "matrix-js-sdk/src/NamespacedValue";
+import { IWidgetApiRequest, MatrixWidgetType } from "matrix-widget-api";
 
 import type EventEmitter from "events";
 import type { IMyDevice } from "matrix-js-sdk/src/client";
+import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import type { Room } from "matrix-js-sdk/src/models/room";
 import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
 import type { ClientWidgetApi } from "matrix-widget-api";
 import type { IApp } from "../stores/WidgetStore";
+import SdkConfig from "../SdkConfig";
+import SettingsStore from "../settings/SettingsStore";
 import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler";
 import { timeout } from "../utils/promise";
 import WidgetUtils from "../utils/WidgetUtils";
@@ -40,15 +45,19 @@ import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../stores/ActiveWidge
 const TIMEOUT_MS = 16000;
 
 // Waits until an event is emitted satisfying the given predicate
-const waitForEvent = async (emitter: EventEmitter, event: string, pred: (...args) => boolean = () => true) => {
-    let listener: (...args) => void;
+const waitForEvent = async (
+    emitter: EventEmitter,
+    event: string,
+    pred: (...args: any[]) => boolean = () => true,
+): Promise<void> => {
+    let listener: (...args: any[]) => void;
     const wait = new Promise<void>(resolve => {
         listener = (...args) => { if (pred(...args)) resolve(); };
         emitter.on(event, listener);
     });
 
     const timedOut = await timeout(wait, false, TIMEOUT_MS) === false;
-    emitter.off(event, listener);
+    emitter.off(event, listener!);
     if (timedOut) throw new Error("Timed out");
 };
 
@@ -74,18 +83,17 @@ interface CallEventHandlerMap {
     [CallEvent.Destroy]: () => void;
 }
 
-interface JitsiCallMemberContent {
-    // Connected device IDs
-    devices: string[];
-    // Time at which this state event should be considered stale
-    expires_ts: number;
-}
-
 /**
  * A group call accessed through a widget.
  */
 export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandlerMap> {
     protected readonly widgetUid = WidgetUtils.getWidgetUid(this.widget);
+    protected readonly room = this.client.getRoom(this.roomId)!;
+
+    /**
+     * The time after which device member state should be considered expired.
+     */
+    public abstract readonly STUCK_DEVICE_TIMEOUT_MS: number;
 
     private _messaging: ClientWidgetApi | null = null;
     /**
@@ -130,6 +138,7 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
          * The widget used to access this call.
          */
         public readonly widget: IApp,
+        protected readonly client: MatrixClient,
     ) {
         super();
     }
@@ -140,21 +149,77 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
      * @returns {Call | null} The call.
      */
     public static get(room: Room): Call | null {
-        // There's currently only one implementation
-        return JitsiCall.get(room);
+        return ElementCall.get(room) ?? JitsiCall.get(room);
+    }
+
+    /**
+     * Gets the connected devices associated with the given user in room state.
+     * @param userId The user's ID.
+     * @returns The IDs of the user's connected devices.
+     */
+    protected abstract getDevices(userId: string): string[];
+
+    /**
+     * Sets the connected devices associated with ourselves in room state.
+     * @param devices The devices with which we're connected.
+     */
+    protected abstract setDevices(devices: string[]): Promise<void>;
+
+    /**
+     * Updates our member state with the devices returned by the given function.
+     * @param fn A function from the current devices to the new devices. If it
+     *     returns null, the update is skipped.
+     */
+    protected async updateDevices(fn: (devices: string[]) => (string[] | null)): Promise<void> {
+        if (this.room.getMyMembership() !== "join") return;
+
+        const devices = fn(this.getDevices(this.client.getUserId()!));
+        if (devices) {
+            await this.setDevices(devices);
+        }
     }
 
     /**
      * Performs a routine check of the call's associated room state, cleaning up
      * any data left over from an unclean disconnection.
      */
-    public abstract clean(): Promise<void>;
+    public async clean(): Promise<void> {
+        const now = Date.now();
+        const { devices: myDevices } = await this.client.getDevices();
+        const deviceMap = new Map<string, IMyDevice>(myDevices.map(d => [d.device_id, d]));
+
+        // Clean up our member state by filtering out logged out devices,
+        // inactive devices, and our own device (if we're disconnected)
+        await this.updateDevices(devices => {
+            const newDevices = devices.filter(d => {
+                const device = deviceMap.get(d);
+                return device?.last_seen_ts !== undefined
+                    && !(d === this.client.getDeviceId() && !this.connected)
+                    && (now - device.last_seen_ts) < this.STUCK_DEVICE_TIMEOUT_MS;
+            });
+
+            // Skip the update if the devices are unchanged
+            return newDevices.length === devices.length ? null : newDevices;
+        });
+    }
+
+    protected async addOurDevice(): Promise<void> {
+        await this.updateDevices(devices => Array.from(new Set(devices).add(this.client.getDeviceId())));
+    }
+
+    protected async removeOurDevice(): Promise<void> {
+        await this.updateDevices(devices => {
+            const devicesSet = new Set(devices);
+            devicesSet.delete(this.client.getDeviceId());
+            return Array.from(devicesSet);
+        });
+    }
 
     /**
      * Contacts the widget to connect to the call.
-     * @param {MediaDeviceInfo | null} audioDevice The audio input to use, or
+     * @param {MediaDeviceInfo | null} audioInput The audio input to use, or
      *   null to start muted.
-     * @param {MediaDeviceInfo | null} audioDevice The video input to use, or
+     * @param {MediaDeviceInfo | null} audioInput The video input to use, or
      *   null to start muted.
      */
     protected abstract performConnection(
@@ -219,6 +284,8 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
             throw e;
         }
 
+        this.room.on(RoomEvent.MyMembership, this.onMyMembership);
+        window.addEventListener("beforeunload", this.beforeUnload);
         this.connectionState = ConnectionState.Connected;
     }
 
@@ -237,6 +304,8 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
      * Manually marks the call as disconnected and cleans up.
      */
     public setDisconnected() {
+        this.room.off(RoomEvent.MyMembership, this.onMyMembership);
+        window.removeEventListener("beforeunload", this.beforeUnload);
         this.messaging = null;
         this.connectionState = ConnectionState.Disconnected;
     }
@@ -248,6 +317,19 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
         if (this.connected) this.setDisconnected();
         this.emit(CallEvent.Destroy);
     }
+
+    private onMyMembership = async (_room: Room, membership: string) => {
+        if (membership !== "join") this.setDisconnected();
+    };
+
+    private beforeUnload = () => this.setDisconnected();
+}
+
+export interface JitsiCallMemberContent {
+    // Connected device IDs
+    devices: string[];
+    // Time at which this state event should be considered stale
+    expires_ts: number;
 }
 
 /**
@@ -255,14 +337,13 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
  */
 export class JitsiCall extends Call {
     public static readonly MEMBER_EVENT_TYPE = "io.element.video.member";
-    public static readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
+    public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
 
-    private room: Room = this.client.getRoom(this.roomId)!;
     private resendDevicesTimer: number | null = null;
     private participantsExpirationTimer: number | null = null;
 
-    private constructor(widget: IApp, private readonly client: MatrixClient) {
-        super(widget);
+    private constructor(widget: IApp, client: MatrixClient) {
+        super(widget, client);
 
         this.room.on(RoomStateEvent.Update, this.onRoomState);
         this.on(CallEvent.ConnectionState, this.onConnectionState);
@@ -270,10 +351,15 @@ export class JitsiCall extends Call {
     }
 
     public static get(room: Room): JitsiCall | null {
-        const apps = WidgetStore.instance.getApps(room.roomId);
-        // The isVideoChannel field differentiates rich Jitsi calls from bare Jitsi widgets
-        const jitsiWidget = apps.find(app => WidgetType.JITSI.matches(app.type) && app.data?.isVideoChannel);
-        return jitsiWidget ? new JitsiCall(jitsiWidget, room.client) : null;
+        // Only supported in video rooms
+        if (SettingsStore.getValue("feature_video_rooms") && room.isElementVideoRoom()) {
+            const apps = WidgetStore.instance.getApps(room.roomId);
+            // The isVideoChannel field differentiates rich Jitsi calls from bare Jitsi widgets
+            const jitsiWidget = apps.find(app => WidgetType.JITSI.matches(app.type) && app.data?.isVideoChannel);
+            if (jitsiWidget) return new JitsiCall(jitsiWidget, room.client);
+        }
+
+        return null;
     }
 
     public static async create(room: Room): Promise<void> {
@@ -293,15 +379,15 @@ export class JitsiCall extends Call {
         for (const e of this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE)) {
             const member = this.room.getMember(e.getStateKey()!);
             const content = e.getContent<JitsiCallMemberContent>();
-            let devices = Array.isArray(content.devices) ? content.devices : [];
             const expiresAt = typeof content.expires_ts === "number" ? content.expires_ts : -Infinity;
+            let devices = expiresAt > now && Array.isArray(content.devices) ? content.devices : [];
 
             // Apply local echo for the disconnected case
             if (!this.connected && member?.userId === this.client.getUserId()) {
                 devices = devices.filter(d => d !== this.client.getDeviceId());
             }
-            // Must have a connected device, be unexpired, and still be joined to the room
-            if (devices.length && expiresAt > now && member?.membership === "join") {
+            // Must have a connected device and still be joined to the room
+            if (devices.length && member?.membership === "join") {
                 members.add(member);
                 if (expiresAt < allExpireAt) allExpireAt = expiresAt;
             }
@@ -316,59 +402,22 @@ export class JitsiCall extends Call {
         }
     }
 
-    // Helper method that updates our member state with the devices returned by
-    // the given function. If it returns null, the update is skipped.
-    private async updateDevices(fn: (devices: string[]) => (string[] | null)): Promise<void> {
-        if (this.room.getMyMembership() !== "join") return;
+    protected getDevices(userId: string): string[] {
+        const event = this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, userId);
+        const content = event?.getContent<JitsiCallMemberContent>();
+        const expiresAt = typeof content?.expires_ts === "number" ? content.expires_ts : -Infinity;
+        return expiresAt > Date.now() && Array.isArray(content?.devices) ? content.devices : [];
+    }
 
-        const devicesState = this.room.currentState.getStateEvents(
-            JitsiCall.MEMBER_EVENT_TYPE, this.client.getUserId()!,
+    protected async setDevices(devices: string[]): Promise<void> {
+        const content: JitsiCallMemberContent = {
+            devices,
+            expires_ts: Date.now() + this.STUCK_DEVICE_TIMEOUT_MS,
+        };
+
+        await this.client.sendStateEvent(
+            this.roomId, JitsiCall.MEMBER_EVENT_TYPE, content, this.client.getUserId()!,
         );
-        const devices = devicesState?.getContent<JitsiCallMemberContent>().devices ?? [];
-        const newDevices = fn(devices);
-
-        if (newDevices) {
-            const content: JitsiCallMemberContent = {
-                devices: newDevices,
-                expires_ts: Date.now() + JitsiCall.STUCK_DEVICE_TIMEOUT_MS,
-            };
-
-            await this.client.sendStateEvent(
-                this.roomId, JitsiCall.MEMBER_EVENT_TYPE, content, this.client.getUserId()!,
-            );
-        }
-    }
-
-    private async addOurDevice(): Promise<void> {
-        await this.updateDevices(devices => Array.from(new Set(devices).add(this.client.getDeviceId())));
-    }
-
-    private async removeOurDevice(): Promise<void> {
-        await this.updateDevices(devices => {
-            const devicesSet = new Set(devices);
-            devicesSet.delete(this.client.getDeviceId());
-            return Array.from(devicesSet);
-        });
-    }
-
-    public async clean(): Promise<void> {
-        const now = Date.now();
-        const { devices: myDevices } = await this.client.getDevices();
-        const deviceMap = new Map<string, IMyDevice>(myDevices.map(d => [d.device_id, d]));
-
-        // Clean up our member state by filtering out logged out devices,
-        // inactive devices, and our own device (if we're disconnected)
-        await this.updateDevices(devices => {
-            const newDevices = devices.filter(d => {
-                const device = deviceMap.get(d);
-                return device?.last_seen_ts
-                    && !(d === this.client.getDeviceId() && !this.connected)
-                    && (now - device.last_seen_ts) < JitsiCall.STUCK_DEVICE_TIMEOUT_MS;
-            });
-
-            // Skip the update if the devices are unchanged
-            return newDevices.length === devices.length ? null : newDevices;
-        });
     }
 
     protected async performConnection(
@@ -433,8 +482,6 @@ export class JitsiCall extends Call {
 
         ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock);
         ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock);
-        this.room.on(RoomEvent.MyMembership, this.onMyMembership);
-        window.addEventListener("beforeunload", this.beforeUnload);
     }
 
     protected async performDisconnection(): Promise<void> {
@@ -459,14 +506,12 @@ export class JitsiCall extends Call {
         this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
         ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock);
         ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock);
-        this.room.off(RoomEvent.MyMembership, this.onMyMembership);
-        window.removeEventListener("beforeunload", this.beforeUnload);
 
         super.setDisconnected();
     }
 
     public destroy() {
-        this.room.off(RoomStateEvent.Update, this.updateParticipants);
+        this.room.off(RoomStateEvent.Update, this.onRoomState);
         this.on(CallEvent.ConnectionState, this.onConnectionState);
         if (this.participantsExpirationTimer !== null) {
             clearTimeout(this.participantsExpirationTimer);
@@ -483,8 +528,8 @@ export class JitsiCall extends Call {
     private onRoomState = () => this.updateParticipants();
 
     private onConnectionState = async (state: ConnectionState, prevState: ConnectionState) => {
-        if (state === ConnectionState.Connected && prevState === ConnectionState.Connecting) {
-            this.updateParticipants();
+        if (state === ConnectionState.Connected && !isConnected(prevState)) {
+            this.updateParticipants(); // Local echo
 
             // Tell others that we're connected, by adding our device to room state
             await this.addOurDevice();
@@ -492,12 +537,14 @@ export class JitsiCall extends Call {
             this.resendDevicesTimer = setInterval(async () => {
                 logger.log(`Resending video member event for ${this.roomId}`);
                 await this.addOurDevice();
-            }, (JitsiCall.STUCK_DEVICE_TIMEOUT_MS * 3) / 4);
+            }, (this.STUCK_DEVICE_TIMEOUT_MS * 3) / 4);
         } else if (state === ConnectionState.Disconnected && isConnected(prevState)) {
-            this.updateParticipants();
+            this.updateParticipants(); // Local echo
 
-            clearInterval(this.resendDevicesTimer);
-            this.resendDevicesTimer = null;
+            if (this.resendDevicesTimer !== null) {
+                clearInterval(this.resendDevicesTimer);
+                this.resendDevicesTimer = null;
+            }
             // Tell others that we're disconnected, by removing our device from room state
             await this.removeOurDevice();
         }
@@ -514,12 +561,6 @@ export class JitsiCall extends Call {
         await this.messaging!.transport.send(ElementWidgetActions.SpotlightLayout, {});
     };
 
-    private onMyMembership = async (room: Room, membership: string) => {
-        if (membership !== "join") this.setDisconnected();
-    };
-
-    private beforeUnload = () => this.setDisconnected();
-
     private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
         // If we're already in the middle of a client-initiated disconnection,
         // ignore the event
@@ -537,3 +578,239 @@ export class JitsiCall extends Call {
         this.setDisconnected();
     };
 }
+
+export interface ElementCallMemberContent {
+    "m.expires_ts": number;
+    "m.calls": {
+        "m.call_id": string;
+        "m.devices": {
+            device_id: string;
+            session_id: string;
+            feeds: unknown[]; // We don't care about what these are
+        }[];
+    }[];
+}
+
+/**
+ * A group call using MSC3401 and Element Call as a backend.
+ * (somewhat cheekily named)
+ */
+export class ElementCall extends Call {
+    public static readonly CALL_EVENT_TYPE = new NamespacedValue(null, "org.matrix.msc3401.call");
+    public static readonly MEMBER_EVENT_TYPE = new NamespacedValue(null, "org.matrix.msc3401.call.member");
+    public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
+
+    private participantsExpirationTimer: number | null = null;
+
+    private constructor(public readonly groupCall: MatrixEvent, client: MatrixClient) {
+        // Splice together the Element Call URL for this call
+        const url = new URL(SdkConfig.get("element_call").url);
+        url.pathname = "/room";
+        const params = new URLSearchParams({
+            embed: "",
+            preload: "",
+            hideHeader: "",
+            userId: client.getUserId()!,
+            deviceId: client.getDeviceId(),
+            roomId: groupCall.getRoomId()!,
+        });
+        url.hash = `#?${params.toString()}`;
+
+        // To use Element Call without touching room state, we create a virtual
+        // widget (one that doesn't have a corresponding state event)
+        super(
+            WidgetStore.instance.addVirtualWidget({
+                id: randomString(24), // So that it's globally unique
+                creatorUserId: client.getUserId()!,
+                name: "Element Call",
+                type: MatrixWidgetType.Custom,
+                url: url.toString(),
+            }, groupCall.getRoomId()!),
+            client,
+        );
+
+        this.room.on(RoomStateEvent.Update, this.onRoomState);
+        this.on(CallEvent.ConnectionState, this.onConnectionState);
+        this.updateParticipants();
+    }
+
+    public static get(room: Room): ElementCall | null {
+        // Only supported in video rooms (for now)
+        if (
+            SettingsStore.getValue("feature_video_rooms")
+            && SettingsStore.getValue("feature_element_call_video_rooms")
+            && room.isCallRoom()
+        ) {
+            const groupCalls = ElementCall.CALL_EVENT_TYPE.names.flatMap(eventType =>
+                room.currentState.getStateEvents(eventType),
+            );
+
+            // Find the newest unterminated call
+            let groupCall: MatrixEvent | null = null;
+            for (const event of groupCalls) {
+                if (
+                    !("m.terminated" in event.getContent())
+                    && (groupCall === null || event.getTs() > groupCall.getTs())
+                ) {
+                    groupCall = event;
+                }
+            }
+
+            if (groupCall !== null) return new ElementCall(groupCall, room.client);
+        }
+
+        return null;
+    }
+
+    public static async create(room: Room): Promise<void> {
+        await room.client.sendStateEvent(room.roomId, ElementCall.CALL_EVENT_TYPE.name, {
+            "m.intent": "m.room",
+            "m.type": "m.video",
+        }, randomString(24));
+    }
+
+    private updateParticipants() {
+        if (this.participantsExpirationTimer !== null) {
+            clearTimeout(this.participantsExpirationTimer);
+            this.participantsExpirationTimer = null;
+        }
+
+        const members = new Set<RoomMember>();
+        const now = Date.now();
+        let allExpireAt = Infinity;
+
+        const memberEvents = ElementCall.MEMBER_EVENT_TYPE.names.flatMap(eventType =>
+            this.room.currentState.getStateEvents(eventType),
+        );
+
+        for (const e of memberEvents) {
+            const member = this.room.getMember(e.getStateKey()!);
+            const content = e.getContent<ElementCallMemberContent>();
+            const expiresAt = typeof content["m.expires_ts"] === "number" ? content["m.expires_ts"] : -Infinity;
+            const calls = expiresAt > now && Array.isArray(content["m.calls"]) ? content["m.calls"] : [];
+            const call = calls.find(call => call["m.call_id"] === this.groupCall.getStateKey());
+            let devices = Array.isArray(call?.["m.devices"]) ? call!["m.devices"] : [];
+
+            // Apply local echo for the disconnected case
+            if (!this.connected && member?.userId === this.client.getUserId()) {
+                devices = devices.filter(d => d.device_id !== this.client.getDeviceId());
+            }
+            // Must have a connected device and still be joined to the room
+            if (devices.length && member?.membership === "join") {
+                members.add(member);
+                if (expiresAt < allExpireAt) allExpireAt = expiresAt;
+            }
+        }
+
+        // Apply local echo for the connected case
+        if (this.connected) members.add(this.room.getMember(this.client.getUserId()!)!);
+
+        this.participants = members;
+        if (allExpireAt < Infinity) {
+            this.participantsExpirationTimer = setTimeout(() => this.updateParticipants(), allExpireAt - now);
+        }
+    }
+
+    private getCallsState(userId: string): ElementCallMemberContent["m.calls"] {
+        const event = (() => {
+            for (const eventType of ElementCall.MEMBER_EVENT_TYPE.names) {
+                const e = this.room.currentState.getStateEvents(eventType, userId);
+                if (e) return e;
+            }
+            return null;
+        })();
+        const content = event?.getContent<ElementCallMemberContent>();
+        const expiresAt = typeof content?.["m.expires_ts"] === "number" ? content["m.expires_ts"] : -Infinity;
+        return expiresAt > Date.now() && Array.isArray(content?.["m.calls"]) ? content!["m.calls"] : [];
+    }
+
+    protected getDevices(userId: string): string[] {
+        const calls = this.getCallsState(userId);
+        const call = calls.find(call => call["m.call_id"] === this.groupCall.getStateKey());
+        const devices = Array.isArray(call?.["m.devices"]) ? call!["m.devices"] : [];
+        return devices.map(d => d.device_id);
+    }
+
+    protected async setDevices(devices: string[]): Promise<void> {
+        const calls = this.getCallsState(this.client.getUserId()!);
+        const call = calls.find(c => c["m.call_id"] === this.groupCall.getStateKey())!;
+        const prevDevices = Array.isArray(call?.["m.devices"]) ? call!["m.devices"] : [];
+        const prevDevicesMap = new Map(prevDevices.map(d => [d.device_id, d]));
+
+        const newContent: ElementCallMemberContent = {
+            "m.expires_ts": Date.now() + this.STUCK_DEVICE_TIMEOUT_MS,
+            "m.calls": [
+                {
+                    "m.call_id": this.groupCall.getStateKey()!,
+                    // This method will only ever be used to remove devices, so
+                    // it's safe to assume that all requested devices are
+                    // present in the map
+                    "m.devices": devices.map(d => prevDevicesMap.get(d)!),
+                },
+                ...calls.filter(c => c !== call),
+            ],
+        };
+
+        await this.client.sendStateEvent(
+            this.roomId, ElementCall.MEMBER_EVENT_TYPE.name, newContent, this.client.getUserId()!,
+        );
+    }
+
+    protected async performConnection(
+        audioInput: MediaDeviceInfo | null,
+        videoInput: MediaDeviceInfo | null,
+    ): Promise<void> {
+        try {
+            await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
+                audioInput: audioInput?.deviceId ?? null,
+                videoInput: videoInput?.deviceId ?? null,
+            });
+        } catch (e) {
+            throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);
+        }
+
+        this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
+    }
+
+    protected async performDisconnection(): Promise<void> {
+        try {
+            await this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
+        } catch (e) {
+            throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`);
+        }
+    }
+
+    public setDisconnected() {
+        this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
+        super.setDisconnected();
+    }
+
+    public destroy() {
+        WidgetStore.instance.removeVirtualWidget(this.widget.id, this.groupCall.getRoomId()!);
+        this.room.off(RoomStateEvent.Update, this.onRoomState);
+        this.off(CallEvent.ConnectionState, this.onConnectionState);
+        if (this.participantsExpirationTimer !== null) {
+            clearTimeout(this.participantsExpirationTimer);
+            this.participantsExpirationTimer = null;
+        }
+
+        super.destroy();
+    }
+
+    private onRoomState = () => this.updateParticipants();
+
+    private onConnectionState = async (state: ConnectionState, prevState: ConnectionState) => {
+        if (
+            (state === ConnectionState.Connected && !isConnected(prevState))
+            || (state === ConnectionState.Disconnected && isConnected(prevState))
+        ) {
+            this.updateParticipants(); // Local echo
+        }
+    };
+
+    private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
+        ev.preventDefault();
+        await this.messaging!.transport.reply(ev.detail, {}); // ack
+        this.setDisconnected();
+    };
+}
diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx
index cb661b2169..0567d10fb8 100644
--- a/src/settings/Settings.tsx
+++ b/src/settings/Settings.tsx
@@ -423,6 +423,14 @@ export const SETTINGS: {[setting: string]: ISetting} = {
         supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
         default: "",
     },
+    "feature_element_call_video_rooms": {
+        isFeature: true,
+        supportedLevels: LEVELS_FEATURE,
+        labsGroup: LabGroup.Rooms,
+        displayName: _td("Element Call video rooms"),
+        controller: new ReloadOnChangeController(),
+        default: false,
+    },
     "feature_location_share_live": {
         isFeature: true,
         labsGroup: LabGroup.Messaging,
diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts
index 7337ffe896..40b73d3114 100644
--- a/src/stores/CallStore.ts
+++ b/src/stores/CallStore.ts
@@ -22,7 +22,6 @@ import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import type { Room } from "matrix-js-sdk/src/models/room";
 import type { RoomState } from "matrix-js-sdk/src/models/room-state";
 import defaultDispatcher from "../dispatcher/dispatcher";
-import { ActionPayload } from "../dispatcher/payloads";
 import { UPDATE_EVENT } from "./AsyncStore";
 import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
 import WidgetStore from "./WidgetStore";
@@ -51,7 +50,7 @@ export class CallStore extends AsyncStoreWithClient<{}> {
         super(defaultDispatcher);
     }
 
-    protected async onAction(payload: ActionPayload): Promise<void> {
+    protected async onAction(): Promise<void> {
         // nothing to do
     }
 
diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts
index bdb95f1895..93bf683b29 100644
--- a/src/stores/WidgetStore.ts
+++ b/src/stores/WidgetStore.ts
@@ -30,11 +30,11 @@ import WidgetUtils from "../utils/WidgetUtils";
 import { WidgetType } from "../widgets/WidgetType";
 import { UPDATE_EVENT } from "./AsyncStore";
 
-interface IState {}
+interface IState { }
 
 export interface IApp extends IWidget {
     roomId: string;
-    eventId: string;
+    eventId?: string; // not present on virtual widgets
     // eslint-disable-next-line camelcase
     avatar_url?: string; // MSC2765 https://github.com/matrix-org/matrix-doc/pull/2765
 }
@@ -118,7 +118,12 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
         // otherwise we are out of sync with the rest of the app with stale widget events during removal
         Array.from(this.widgetMap.values()).forEach(app => {
             if (app.roomId !== room.roomId) return; // skip - wrong room
-            this.widgetMap.delete(WidgetUtils.getWidgetUid(app));
+            if (app.eventId === undefined) {
+                // virtual widget - keep it
+                roomInfo.widgets.push(app);
+            } else {
+                this.widgetMap.delete(WidgetUtils.getWidgetUid(app));
+            }
         });
 
         let edited = false;
@@ -169,16 +174,38 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
         this.emit(UPDATE_EVENT, roomId);
     };
 
-    public getRoom = (roomId: string, initIfNeeded = false) => {
+    public get(widgetId: string, roomId: string | undefined): IApp | undefined {
+        return this.widgetMap.get(WidgetUtils.calcWidgetUid(widgetId, roomId));
+    }
+
+    public getRoom(roomId: string, initIfNeeded = false): IRoomWidgets {
         if (initIfNeeded) this.initRoom(roomId); // internally handles "if needed"
-        return this.roomMap.get(roomId);
-    };
+        return this.roomMap.get(roomId)!;
+    }
 
     public getApps(roomId: string): IApp[] {
         const roomInfo = this.getRoom(roomId);
         return roomInfo?.widgets || [];
     }
 
+    public addVirtualWidget(widget: IWidget, roomId: string): IApp {
+        this.initRoom(roomId);
+        const app = WidgetUtils.makeAppConfig(widget.id, widget, widget.creatorUserId, roomId, undefined);
+        this.widgetMap.set(WidgetUtils.getWidgetUid(app), app);
+        this.roomMap.get(roomId)!.widgets.push(app);
+        return app;
+    }
+
+    public removeVirtualWidget(widgetId: string, roomId: string): void {
+        this.widgetMap.delete(WidgetUtils.calcWidgetUid(widgetId, roomId));
+        const roomApps = this.roomMap.get(roomId);
+        if (roomApps) {
+            roomApps.widgets = roomApps.widgets.filter(app =>
+                !(app.id === widgetId && app.roomId === roomId),
+            );
+        }
+    }
+
     public doesRoomHaveConference(room: Room): boolean {
         const roomInfo = this.getRoom(room.roomId);
         if (!roomInfo) return false;
diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts
index 8dfced1b70..5e9451efa0 100644
--- a/src/stores/widgets/ElementWidgetActions.ts
+++ b/src/stores/widgets/ElementWidgetActions.ts
@@ -17,7 +17,7 @@
 import { IWidgetApiRequest } from "matrix-widget-api";
 
 export enum ElementWidgetActions {
-    // All of these actions are currently specific to Jitsi
+    // All of these actions are currently specific to Jitsi and Element Call
     JoinCall = "io.element.join",
     HangupCall = "im.vector.hangup",
     CallParticipants = "io.element.participants",
diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts
index 889a050ebf..bbf166150c 100644
--- a/src/stores/widgets/StopGapWidget.ts
+++ b/src/stores/widgets/StopGapWidget.ts
@@ -54,6 +54,7 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
 import { Action } from "../../dispatcher/actions";
 import { ElementWidgetActions, IHangupCallApiRequest, IViewRoomApiRequest } from "./ElementWidgetActions";
 import { ModalWidgetStore } from "../ModalWidgetStore";
+import { IApp } from "../WidgetStore";
 import ThemeWatcher from "../../settings/watchers/ThemeWatcher";
 import { getCustomTheme } from "../../theme";
 import { ElementWidgetCapabilities } from "./ElementWidgetCapabilities";
@@ -69,7 +70,7 @@ import ErrorDialog from "../../components/views/dialogs/ErrorDialog";
 
 interface IAppTileProps {
     // Note: these are only the props we care about
-    app: IWidget;
+    app: IApp;
     room?: Room; // without a room it is a user widget
     userId: string;
     creatorUserId: string;
@@ -155,6 +156,7 @@ export class StopGapWidget extends EventEmitter {
     private scalarToken: string;
     private roomId?: string;
     private kind: WidgetKind;
+    private readonly virtual: boolean;
     private readUpToMap: { [roomId: string]: string } = {}; // room ID to event ID
 
     constructor(private appTileProps: IAppTileProps) {
@@ -171,6 +173,7 @@ export class StopGapWidget extends EventEmitter {
         this.mockWidget = new ElementWidget(app);
         this.roomId = appTileProps.room?.roomId;
         this.kind = appTileProps.userWidget ? WidgetKind.Account : WidgetKind.Room; // probably
+        this.virtual = app.eventId === undefined;
     }
 
     private get eventListenerRoomId(): string {
@@ -265,14 +268,18 @@ export class StopGapWidget extends EventEmitter {
         if (this.started) return;
 
         const allowedCapabilities = this.appTileProps.whitelistCapabilities || [];
-        const driver = new StopGapWidgetDriver(allowedCapabilities, this.mockWidget, this.kind, this.roomId);
+        const driver = new StopGapWidgetDriver(
+            allowedCapabilities, this.mockWidget, this.kind, this.virtual, this.roomId,
+        );
 
         this.messaging = new ClientWidgetApi(this.mockWidget, iframe, driver);
         this.messaging.on("preparing", () => this.emit("preparing"));
-        this.messaging.on("ready", () => this.emit("ready"));
+        this.messaging.on("ready", () => {
+            WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.roomId, this.messaging);
+            this.emit("ready");
+        });
         this.messaging.on("capabilitiesNotified", () => this.emit("capabilitiesNotified"));
         this.messaging.on(`action:${WidgetApiFromWidgetAction.OpenModalWidget}`, this.onOpenModal);
-        WidgetMessagingStore.instance.storeMessaging(this.mockWidget, this.roomId, this.messaging);
 
         // Always attach a handler for ViewRoom, but permission check it internally
         this.messaging.on(`action:${ElementWidgetActions.ViewRoom}`, (ev: CustomEvent<IViewRoomApiRequest>) => {
diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts
index 9fbf5e4c56..f0b496f0b7 100644
--- a/src/stores/widgets/StopGapWidgetDriver.ts
+++ b/src/stores/widgets/StopGapWidgetDriver.ts
@@ -40,6 +40,7 @@ import { logger } from "matrix-js-sdk/src/logger";
 import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
 import { Direction } from "matrix-js-sdk/src/matrix";
 
+import SdkConfig from "../../SdkConfig";
 import { iterableDiff, iterableIntersection } from "../../utils/iterables";
 import { MatrixClientPeg } from "../../MatrixClientPeg";
 import Modal from "../../Modal";
@@ -80,6 +81,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
         allowedCapabilities: Capability[],
         private forWidget: Widget,
         private forWidgetKind: WidgetKind,
+        virtual: boolean,
         private inRoomId?: string,
     ) {
         super();
@@ -102,6 +104,50 @@ export class StopGapWidgetDriver extends WidgetDriver {
             // Auto-approve the legacy visibility capability. We send it regardless of capability.
             // Widgets don't technically need to request this capability, but Scalar still does.
             this.allowedCapabilities.add("visibility");
+        } else if (virtual && new URL(SdkConfig.get("element_call").url).origin === this.forWidget.origin) {
+            // This is a trusted Element Call widget that we control
+            this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen);
+            this.allowedCapabilities.add(MatrixCapabilities.MSC3846TurnServers);
+            this.allowedCapabilities.add(`org.matrix.msc2762.timeline:${inRoomId}`);
+
+            this.allowedCapabilities.add(
+                WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw,
+            );
+            this.allowedCapabilities.add(
+                WidgetEventCapability.forStateEvent(EventDirection.Send, "org.matrix.msc3401.call").raw,
+            );
+            this.allowedCapabilities.add(
+                WidgetEventCapability.forStateEvent(EventDirection.Receive, "org.matrix.msc3401.call").raw,
+            );
+            this.allowedCapabilities.add(
+                WidgetEventCapability.forStateEvent(
+                    EventDirection.Send, "org.matrix.msc3401.call.member", MatrixClientPeg.get().getUserId()!,
+                ).raw,
+            );
+            this.allowedCapabilities.add(
+                WidgetEventCapability.forStateEvent(EventDirection.Receive, "org.matrix.msc3401.call.member").raw,
+            );
+
+            const sendRecvToDevice = [
+                EventType.CallInvite,
+                EventType.CallCandidates,
+                EventType.CallAnswer,
+                EventType.CallHangup,
+                EventType.CallReject,
+                EventType.CallSelectAnswer,
+                EventType.CallNegotiate,
+                EventType.CallSDPStreamMetadataChanged,
+                EventType.CallSDPStreamMetadataChangedPrefix,
+                EventType.CallReplaces,
+            ];
+            for (const eventType of sendRecvToDevice) {
+                this.allowedCapabilities.add(
+                    WidgetEventCapability.forToDeviceEvent(EventDirection.Send, eventType).raw,
+                );
+                this.allowedCapabilities.add(
+                    WidgetEventCapability.forToDeviceEvent(EventDirection.Receive, eventType).raw,
+                );
+            }
         }
     }
 
diff --git a/src/utils/GroupCallUtils.ts b/src/utils/GroupCallUtils.ts
deleted file mode 100644
index 3af6a2b07a..0000000000
--- a/src/utils/GroupCallUtils.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-/*
-Copyright 2022 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 { EventTimeline, MatrixClient, MatrixEvent, RoomState } from "matrix-js-sdk/src/matrix";
-import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue";
-import { deepCopy } from "matrix-js-sdk/src/utils";
-
-export const STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
-
-export const CALL_STATE_EVENT_TYPE = new UnstableValue("m.call", "org.matrix.msc3401.call");
-export const CALL_MEMBER_STATE_EVENT_TYPE = new UnstableValue("m.call.member", "org.matrix.msc3401.call.member");
-const CALL_STATE_EVENT_TERMINATED = "m.terminated";
-
-interface MDevice {
-    ["m.device_id"]: string;
-}
-
-interface MCall {
-    ["m.call_id"]: string;
-    ["m.devices"]: Array<MDevice>;
-}
-
-interface MCallMemberContent {
-    ["m.expires_ts"]: number;
-    ["m.calls"]: Array<MCall>;
-}
-
-const getRoomState = (client: MatrixClient, roomId: string): RoomState => {
-    return client.getRoom(roomId)
-        ?.getLiveTimeline()
-        ?.getState?.(EventTimeline.FORWARDS);
-};
-
-/**
- * Returns all room state events for the stable and unstable type value.
- */
-const getRoomStateEvents = (
-    client: MatrixClient,
-    roomId: string,
-    type: UnstableValue<string, string>,
-): MatrixEvent[] => {
-    const roomState = getRoomState(client, roomId);
-    if (!roomState) return [];
-
-    return [
-        ...roomState.getStateEvents(type.name),
-        ...roomState.getStateEvents(type.altName),
-    ];
-};
-
-/**
- * Finds the latest, non-terminated call state event.
- */
-export const getGroupCall = (client: MatrixClient, roomId: string): MatrixEvent => {
-    return getRoomStateEvents(client, roomId, CALL_STATE_EVENT_TYPE)
-        .sort((a: MatrixEvent, b: MatrixEvent) => b.getTs() - a.getTs())
-        .find((event: MatrixEvent) => {
-            return !(CALL_STATE_EVENT_TERMINATED in event.getContent());
-        });
-};
-
-/**
- * Finds the "m.call.member" events for an "m.call" event.
- *
- * @returns {MatrixEvent[]} non-expired "m.call.member" events for the call
- */
-export const useConnectedMembers = (client: MatrixClient, callEvent: MatrixEvent): MatrixEvent[] => {
-    if (!CALL_STATE_EVENT_TYPE.matches(callEvent.getType())) return [];
-
-    const callId = callEvent.getStateKey();
-    const now = Date.now();
-
-    return getRoomStateEvents(client, callEvent.getRoomId(), CALL_MEMBER_STATE_EVENT_TYPE)
-        .filter((callMemberEvent: MatrixEvent): boolean => {
-            const {
-                ["m.expires_ts"]: expiresTs,
-                ["m.calls"]: calls,
-            } = callMemberEvent.getContent<MCallMemberContent>();
-
-            // state event expired
-            if (expiresTs && expiresTs < now) return false;
-
-            return !!calls?.find((call: MCall) => call["m.call_id"] === callId);
-        }) || [];
-};
-
-/**
- * Removes a list of devices from a call.
- * Only works for the current user's devices.
- */
-const removeDevices = async (client: MatrixClient, callEvent: MatrixEvent, deviceIds: string[]): Promise<void> => {
-    if (!CALL_STATE_EVENT_TYPE.matches(callEvent.getType())) return;
-
-    const roomId = callEvent.getRoomId();
-    const roomState = getRoomState(client, roomId);
-    if (!roomState) return;
-
-    const callMemberEvent = roomState.getStateEvents(CALL_MEMBER_STATE_EVENT_TYPE.name, client.getUserId())
-        ?? roomState.getStateEvents(CALL_MEMBER_STATE_EVENT_TYPE.altName, client.getUserId());
-    const callMemberEventContent = callMemberEvent?.getContent<MCallMemberContent>();
-    if (
-        !Array.isArray(callMemberEventContent?.["m.calls"])
-        || callMemberEventContent?.["m.calls"].length === 0
-    ) {
-        return;
-    }
-
-    // copy the content to prevent mutations
-    const newContent = deepCopy(callMemberEventContent);
-    const callId = callEvent.getStateKey();
-    let changed = false;
-
-    newContent["m.calls"].forEach((call: MCall) => {
-        // skip other calls
-        if (call["m.call_id"] !== callId) return;
-
-        call["m.devices"] = call["m.devices"]?.filter((device: MDevice) => {
-            if (deviceIds.includes(device["m.device_id"])) {
-                changed = true;
-                return false;
-            }
-
-            return true;
-        });
-    });
-
-    if (changed) {
-        // only send a new state event if there has been a change
-        newContent["m.expires_ts"] = Date.now() + STUCK_DEVICE_TIMEOUT_MS;
-        await client.sendStateEvent(
-            roomId,
-            CALL_MEMBER_STATE_EVENT_TYPE.name,
-            newContent,
-            client.getUserId(),
-        );
-    }
-};
-
-/**
- * Removes the current device from a call.
- */
-export const removeOurDevice = async (client: MatrixClient, callEvent: MatrixEvent) => {
-    return removeDevices(client, callEvent, [client.getDeviceId()]);
-};
-
-/**
- * Removes all devices of the current user that have not been seen within the STUCK_DEVICE_TIMEOUT_MS.
- * Does per default not remove the current device unless includeCurrentDevice is true.
- *
- * @param {boolean} includeCurrentDevice - Whether to include the current device of this session here.
- */
-export const fixStuckDevices = async (client: MatrixClient, callEvent: MatrixEvent, includeCurrentDevice: boolean) => {
-    const now = Date.now();
-    const { devices: myDevices } = await client.getDevices();
-    const currentDeviceId = client.getDeviceId();
-    const devicesToBeRemoved = myDevices.filter(({ last_seen_ts: lastSeenTs, device_id: deviceId }) => {
-        return lastSeenTs
-            && (deviceId !== currentDeviceId || includeCurrentDevice)
-            && (now - lastSeenTs) > STUCK_DEVICE_TIMEOUT_MS;
-    }).map(d => d.device_id);
-    return removeDevices(client, callEvent, devicesToBeRemoved);
-};
diff --git a/src/utils/WidgetUtils.ts b/src/utils/WidgetUtils.ts
index 60b71e5f65..728a41f687 100644
--- a/src/utils/WidgetUtils.ts
+++ b/src/utils/WidgetUtils.ts
@@ -482,8 +482,8 @@ export default class WidgetUtils {
         appId: string,
         app: Partial<IApp>,
         senderUserId: string,
-        roomId: string | null,
-        eventId: string,
+        roomId: string | undefined,
+        eventId: string | undefined,
     ): IApp {
         if (!senderUserId) {
             throw new Error("Widgets must be created by someone - provide a senderUserId");
diff --git a/src/utils/video-rooms.ts b/src/utils/video-rooms.ts
new file mode 100644
index 0000000000..7177e0c5e0
--- /dev/null
+++ b/src/utils/video-rooms.ts
@@ -0,0 +1,21 @@
+/*
+Copyright 2022 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 type { Room } from "matrix-js-sdk/src/models/room";
+import SettingsStore from "../settings/SettingsStore";
+
+export const isVideoRoom = (room: Room) => room.isElementVideoRoom()
+    || (SettingsStore.getValue("feature_element_call_video_rooms") && room.isCallRoom());
diff --git a/test/components/views/rooms/RoomPreviewCard-test.tsx b/test/components/views/rooms/RoomPreviewCard-test.tsx
new file mode 100644
index 0000000000..a453f70dcc
--- /dev/null
+++ b/test/components/views/rooms/RoomPreviewCard-test.tsx
@@ -0,0 +1,120 @@
+/*
+Copyright 2022 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 { mocked, Mocked } from "jest-mock";
+import { render, screen, act } from "@testing-library/react";
+import { PendingEventOrdering } from "matrix-js-sdk/src/client";
+import { Room } from "matrix-js-sdk/src/models/room";
+import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
+import { RoomType } from "matrix-js-sdk/src/@types/event";
+
+import type { MatrixClient } from "matrix-js-sdk/src/client";
+import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
+import { stubClient, wrapInMatrixClientContext, mkRoomMember } from "../../../test-utils";
+import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
+import DMRoomMap from "../../../../src/utils/DMRoomMap";
+import SettingsStore from "../../../../src/settings/SettingsStore";
+import _RoomPreviewCard from "../../../../src/components/views/rooms/RoomPreviewCard";
+
+const RoomPreviewCard = wrapInMatrixClientContext(_RoomPreviewCard);
+
+describe("RoomPreviewCard", () => {
+    let client: Mocked<MatrixClient>;
+    let room: Room;
+    let alice: RoomMember;
+    let enabledFeatures: string[];
+
+    beforeEach(() => {
+        stubClient();
+        client = mocked(MatrixClientPeg.get());
+        client.getUserId.mockReturnValue("@alice:example.org");
+        DMRoomMap.makeShared();
+
+        room = new Room("!1:example.org", client, "@alice:example.org", {
+            pendingEventOrdering: PendingEventOrdering.Detached,
+        });
+        alice = mkRoomMember(room.roomId, "@alice:example.org");
+        jest.spyOn(room, "getMember").mockImplementation(userId => userId === alice.userId ? alice : null);
+
+        client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null);
+        client.getRooms.mockReturnValue([room]);
+        client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
+
+        enabledFeatures = [];
+        jest.spyOn(SettingsStore, "getValue").mockImplementation(settingName =>
+            enabledFeatures.includes(settingName) ? true : undefined,
+        );
+    });
+
+    afterEach(() => {
+        client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
+        jest.restoreAllMocks();
+    });
+
+    const renderPreview = async (): Promise<void> => {
+        render(
+            <RoomPreviewCard
+                room={room}
+                onJoinButtonClicked={() => { }}
+                onRejectButtonClicked={() => { }}
+            />,
+        );
+        await act(() => Promise.resolve()); // Allow effects to settle
+    };
+
+    it("shows a beta pill on Jitsi video room invites", async () => {
+        jest.spyOn(room, "getType").mockReturnValue(RoomType.ElementVideo);
+        jest.spyOn(room, "getMyMembership").mockReturnValue("invite");
+        enabledFeatures = ["feature_video_rooms"];
+
+        await renderPreview();
+        screen.getByRole("button", { name: /beta/i });
+    });
+
+    it("shows a beta pill on Element video room invites", async () => {
+        jest.spyOn(room, "getType").mockReturnValue(RoomType.UnstableCall);
+        jest.spyOn(room, "getMyMembership").mockReturnValue("invite");
+        enabledFeatures = ["feature_video_rooms", "feature_element_call_video_rooms"];
+
+        await renderPreview();
+        screen.getByRole("button", { name: /beta/i });
+    });
+
+    it("doesn't show a beta pill on normal invites", async () => {
+        jest.spyOn(room, "getMyMembership").mockReturnValue("invite");
+
+        await renderPreview();
+        expect(screen.queryByRole("button", { name: /beta/i })).toBeNull();
+    });
+
+    it("shows instructions on Jitsi video rooms invites if video rooms are disabled", async () => {
+        jest.spyOn(room, "getType").mockReturnValue(RoomType.ElementVideo);
+        jest.spyOn(room, "getMyMembership").mockReturnValue("invite");
+
+        await renderPreview();
+        screen.getByText(/enable video rooms in labs/i);
+    });
+
+    it("shows instructions on Element video rooms invites if video rooms are disabled", async () => {
+        jest.spyOn(room, "getType").mockReturnValue(RoomType.UnstableCall);
+        jest.spyOn(room, "getMyMembership").mockReturnValue("invite");
+        enabledFeatures = ["feature_element_call_video_rooms"];
+
+        await renderPreview();
+        screen.getByText(/enable video rooms in labs/i);
+    });
+});
diff --git a/test/createRoom-test.ts b/test/createRoom-test.ts
index b1873edf42..7dbd4a2a41 100644
--- a/test/createRoom-test.ts
+++ b/test/createRoom-test.ts
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { mocked } from "jest-mock";
+import { mocked, Mocked } from "jest-mock";
 import { MatrixClient } from "matrix-js-sdk/src/matrix";
 import { IDevice } from "matrix-js-sdk/src/crypto/deviceinfo";
 import { RoomType } from "matrix-js-sdk/src/@types/event";
@@ -23,25 +23,26 @@ import { stubClient, setupAsyncStoreWithClient, mockPlatformPeg } from "./test-u
 import { MatrixClientPeg } from "../src/MatrixClientPeg";
 import WidgetStore from "../src/stores/WidgetStore";
 import WidgetUtils from "../src/utils/WidgetUtils";
-import { JitsiCall } from "../src/models/Call";
+import { JitsiCall, ElementCall } from "../src/models/Call";
 import createRoom, { canEncryptToAllUsers } from '../src/createRoom';
 
 describe("createRoom", () => {
     mockPlatformPeg();
 
-    let client: MatrixClient;
+    let client: Mocked<MatrixClient>;
     beforeEach(() => {
         stubClient();
-        client = MatrixClientPeg.get();
+        client = mocked(MatrixClientPeg.get());
     });
 
     afterEach(() => jest.clearAllMocks());
 
-    it("sets up video rooms correctly", async () => {
+    it("sets up Jitsi video rooms correctly", async () => {
         setupAsyncStoreWithClient(WidgetStore.instance, client);
         jest.spyOn(WidgetUtils, "waitForRoomWidget").mockResolvedValue();
+        const createCallSpy = jest.spyOn(JitsiCall, "create");
 
-        const userId = client.getUserId();
+        const userId = client.getUserId()!;
         const roomId = await createRoom({ roomType: RoomType.ElementVideo });
 
         const [[{
@@ -51,25 +52,63 @@ describe("createRoom", () => {
                 },
                 events: {
                     "im.vector.modular.widgets": widgetPower,
-                    [JitsiCall.MEMBER_EVENT_TYPE]: jitsiMemberPower,
+                    [JitsiCall.MEMBER_EVENT_TYPE]: callMemberPower,
                 },
             },
-        }]] = mocked(client.createRoom).mock.calls as any; // no good type
-        const [[widgetRoomId, widgetStateKey]] = mocked(client.sendStateEvent).mock.calls;
+        }]] = client.createRoom.mock.calls as any; // no good type
 
-        // We should have had enough power to be able to set up the Jitsi widget
+        // We should have had enough power to be able to set up the widget
         expect(userPower).toBeGreaterThanOrEqual(widgetPower);
         // and should have actually set it up
-        expect(widgetRoomId).toEqual(roomId);
-        expect(widgetStateKey).toEqual("im.vector.modular.widgets");
+        expect(createCallSpy).toHaveBeenCalled();
 
         // All members should be able to update their connected devices
-        expect(jitsiMemberPower).toEqual(0);
-        // Jitsi widget should be immutable for admins
+        expect(callMemberPower).toEqual(0);
+        // widget should be immutable for admins
         expect(widgetPower).toBeGreaterThan(100);
         // and we should have been reset back to admin
         expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, userId, 100, undefined);
     });
+
+    it("sets up Element video rooms correctly", async () => {
+        const userId = client.getUserId()!;
+        const createCallSpy = jest.spyOn(ElementCall, "create");
+        const roomId = await createRoom({ roomType: RoomType.UnstableCall });
+
+        const [[{
+            power_level_content_override: {
+                users: {
+                    [userId]: userPower,
+                },
+                events: {
+                    [ElementCall.CALL_EVENT_TYPE.name]: callPower,
+                    [ElementCall.MEMBER_EVENT_TYPE.name]: callMemberPower,
+                },
+            },
+        }]] = client.createRoom.mock.calls as any; // no good type
+
+        // We should have had enough power to be able to set up the call
+        expect(userPower).toBeGreaterThanOrEqual(callPower);
+        // and should have actually set it up
+        expect(createCallSpy).toHaveBeenCalled();
+
+        // All members should be able to update their connected devices
+        expect(callMemberPower).toEqual(0);
+        // call should be immutable for admins
+        expect(callPower).toBeGreaterThan(100);
+        // and we should have been reset back to admin
+        expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, userId, 100, undefined);
+    });
+
+    it("doesn't create calls in non-video-rooms", async () => {
+        const createJitsiCallSpy = jest.spyOn(JitsiCall, "create");
+        const createElementCallSpy = jest.spyOn(ElementCall, "create");
+
+        await createRoom({});
+
+        expect(createJitsiCallSpy).not.toHaveBeenCalled();
+        expect(createElementCallSpy).not.toHaveBeenCalled();
+    });
 });
 
 describe("canEncryptToAllUsers", () => {
@@ -83,20 +122,20 @@ describe("canEncryptToAllUsers", () => {
         "@badUser:localhost": {},
     };
 
-    let client: MatrixClient;
+    let client: Mocked<MatrixClient>;
     beforeEach(() => {
         stubClient();
-        client = MatrixClientPeg.get();
+        client = mocked(MatrixClientPeg.get());
     });
 
     it("returns true if all devices have crypto", async () => {
-        mocked(client.downloadKeys).mockResolvedValue(trueUser);
+        client.downloadKeys.mockResolvedValue(trueUser);
         const response = await canEncryptToAllUsers(client, ["@goodUser:localhost"]);
         expect(response).toBe(true);
     });
 
     it("returns false if not all users have crypto", async () => {
-        mocked(client.downloadKeys).mockResolvedValue({ ...trueUser, ...falseUser });
+        client.downloadKeys.mockResolvedValue({ ...trueUser, ...falseUser });
         const response = await canEncryptToAllUsers(client, ["@goodUser:localhost", "@badUser:localhost"]);
         expect(response).toBe(false);
     });
diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts
index 833c723def..fbbf22eca8 100644
--- a/test/models/Call-test.ts
+++ b/test/models/Call-test.ts
@@ -18,322 +18,834 @@ import EventEmitter from "events";
 import { isEqual } from "lodash";
 import { mocked } from "jest-mock";
 import { waitFor } from "@testing-library/react";
+import { RoomType } from "matrix-js-sdk/src/@types/event";
 import { PendingEventOrdering } from "matrix-js-sdk/src/client";
-import { Room } from "matrix-js-sdk/src/models/room";
+import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
 import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
 import { Widget } from "matrix-widget-api";
 
 import type { Mocked } from "jest-mock";
-import type { MatrixClient } from "matrix-js-sdk/src/client";
+import type { MatrixClient, IMyDevice } from "matrix-js-sdk/src/client";
 import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
 import type { ClientWidgetApi } from "matrix-widget-api";
-import type { Call } from "../../src/models/Call";
+import type { JitsiCallMemberContent, ElementCallMemberContent } from "../../src/models/Call";
 import { stubClient, mkEvent, mkRoomMember, setupAsyncStoreWithClient, mockPlatformPeg } from "../test-utils";
 import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../src/MediaDeviceHandler";
 import { MatrixClientPeg } from "../../src/MatrixClientPeg";
-import { CallEvent, ConnectionState, JitsiCall } from "../../src/models/Call";
+import { Call, CallEvent, ConnectionState, JitsiCall, ElementCall } from "../../src/models/Call";
 import WidgetStore from "../../src/stores/WidgetStore";
 import { WidgetMessagingStore } from "../../src/stores/widgets/WidgetMessagingStore";
 import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../src/stores/ActiveWidgetStore";
 import { ElementWidgetActions } from "../../src/stores/widgets/ElementWidgetActions";
+import SettingsStore from "../../src/settings/SettingsStore";
+
+jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
+    [MediaDeviceKindEnum.AudioInput]: [
+        { deviceId: "1", groupId: "1", kind: "audioinput", label: "Headphones", toJSON: () => { } },
+    ],
+    [MediaDeviceKindEnum.VideoInput]: [
+        { deviceId: "2", groupId: "2", kind: "videoinput", label: "Built-in webcam", toJSON: () => { } },
+    ],
+    [MediaDeviceKindEnum.AudioOutput]: [],
+});
+jest.spyOn(MediaDeviceHandler, "getAudioInput").mockReturnValue("1");
+jest.spyOn(MediaDeviceHandler, "getVideoInput").mockReturnValue("2");
+
+jest.spyOn(SettingsStore, "getValue").mockImplementation(settingName =>
+    settingName === "feature_video_rooms" || settingName === "feature_element_call_video_rooms" ? true : undefined,
+);
+
+const setUpClientRoomAndStores = (roomType: RoomType): {
+    client: Mocked<MatrixClient>;
+    room: Room;
+    alice: RoomMember;
+    bob: RoomMember;
+    carol: RoomMember;
+} => {
+    stubClient();
+    const client = mocked<MatrixClient>(MatrixClientPeg.get());
+
+    const room = new Room("!1:example.org", client, "@alice:example.org", {
+        pendingEventOrdering: PendingEventOrdering.Detached,
+    });
+    jest.spyOn(room, "getType").mockReturnValue(roomType);
+
+    const alice = mkRoomMember(room.roomId, "@alice:example.org");
+    const bob = mkRoomMember(room.roomId, "@bob:example.org");
+    const carol = mkRoomMember(room.roomId, "@carol:example.org");
+    jest.spyOn(room, "getMember").mockImplementation(userId => {
+        switch (userId) {
+            case alice.userId: return alice;
+            case bob.userId: return bob;
+            case carol.userId: return carol;
+            default: return null;
+        }
+    });
+    jest.spyOn(room, "getMyMembership").mockReturnValue("join");
+
+    client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null);
+    client.getRooms.mockReturnValue([room]);
+    client.getUserId.mockReturnValue(alice.userId);
+    client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
+    client.sendStateEvent.mockImplementation(async (roomId, eventType, content, stateKey = "") => {
+        if (roomId !== room.roomId) throw new Error("Unknown room");
+        const event = mkEvent({
+            event: true,
+            type: eventType,
+            room: roomId,
+            user: alice.userId,
+            skey: stateKey,
+            content,
+        });
+        room.addLiveEvents([event]);
+        return { event_id: event.getId() };
+    });
+
+    setupAsyncStoreWithClient(WidgetStore.instance, client);
+    setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
+
+    return { client, room, alice, bob, carol };
+};
+
+const cleanUpClientRoomAndStores = (
+    client: MatrixClient,
+    room: Room,
+) => {
+    client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
+};
+
+const setUpWidget = (call: Call): {
+    widget: Widget;
+    messaging: Mocked<ClientWidgetApi>;
+    audioMutedSpy: jest.SpyInstance<boolean, []>;
+    videoMutedSpy: jest.SpyInstance<boolean, []>;
+} => {
+    const widget = new Widget(call.widget);
+
+    const eventEmitter = new EventEmitter();
+    const messaging = {
+        on: eventEmitter.on.bind(eventEmitter),
+        off: eventEmitter.off.bind(eventEmitter),
+        once: eventEmitter.once.bind(eventEmitter),
+        emit: eventEmitter.emit.bind(eventEmitter),
+        stop: jest.fn(),
+        transport: {
+            send: jest.fn(),
+            reply: jest.fn(),
+        },
+    } as unknown as Mocked<ClientWidgetApi>;
+    WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging);
+
+    const audioMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithAudioMuted", "get");
+    const videoMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithVideoMuted", "get");
+
+    return { widget, messaging, audioMutedSpy, videoMutedSpy };
+};
+
+const cleanUpCallAndWidget = (
+    call: Call,
+    widget: Widget,
+    audioMutedSpy: jest.SpyInstance<boolean, []>,
+    videoMutedSpy: jest.SpyInstance<boolean, []>,
+) => {
+    call.destroy();
+    jest.clearAllMocks();
+    WidgetMessagingStore.instance.stopMessaging(widget, call.roomId);
+    audioMutedSpy.mockRestore();
+    videoMutedSpy.mockRestore();
+};
 
 describe("JitsiCall", () => {
     mockPlatformPeg({ supportsJitsiScreensharing: () => true });
-    jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
-        [MediaDeviceKindEnum.AudioInput]: [
-            { deviceId: "1", groupId: "1", kind: "audioinput", label: "Headphones", toJSON: () => {} },
-        ],
-        [MediaDeviceKindEnum.VideoInput]: [
-            { deviceId: "2", groupId: "2", kind: "videoinput", label: "Built-in webcam", toJSON: () => {} },
-        ],
-        [MediaDeviceKindEnum.AudioOutput]: [],
-    });
-    jest.spyOn(MediaDeviceHandler, "getAudioInput").mockReturnValue("1");
-    jest.spyOn(MediaDeviceHandler, "getVideoInput").mockReturnValue("2");
 
     let client: Mocked<MatrixClient>;
     let room: Room;
     let alice: RoomMember;
     let bob: RoomMember;
     let carol: RoomMember;
-    let call: Call;
-    let widget: Widget;
-    let messaging: Mocked<ClientWidgetApi>;
-    let audioMutedSpy: jest.SpyInstance<boolean, []>;
-    let videoMutedSpy: jest.SpyInstance<boolean, []>;
 
-    beforeEach(async () => {
-        jest.useFakeTimers();
-        jest.setSystemTime(0);
+    beforeEach(() => {
+        ({ client, room, alice, bob, carol } = setUpClientRoomAndStores(RoomType.ElementVideo));
+    });
 
-        stubClient();
-        client = mocked(MatrixClientPeg.get());
+    afterEach(() => cleanUpClientRoomAndStores(client, room));
 
-        room = new Room("!1:example.org", client, "@alice:example.org", {
-            pendingEventOrdering: PendingEventOrdering.Detached,
+    describe("get", () => {
+        it("finds no calls", () => {
+            expect(Call.get(room)).toBeNull();
         });
-        alice = mkRoomMember(room.roomId, "@alice:example.org");
-        bob = mkRoomMember(room.roomId, "@bob:example.org");
-        carol = mkRoomMember(room.roomId, "@carol:example.org");
-        jest.spyOn(room, "getMember").mockImplementation(userId => {
-            switch (userId) {
-                case alice.userId: return alice;
-                case bob.userId: return bob;
-                case carol.userId: return carol;
-                default: return null;
-            }
-        });
-        jest.spyOn(room, "getMyMembership").mockReturnValue("join");
 
-        client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null);
-        client.getRooms.mockReturnValue([room]);
-        client.getUserId.mockReturnValue(alice.userId);
-        client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
-        client.sendStateEvent.mockImplementation(async (roomId, eventType, content, stateKey = "") => {
-            if (roomId !== room.roomId) throw new Error("Unknown room");
-            const event = mkEvent({
-                event: true,
-                type: eventType,
-                room: roomId,
-                user: alice.userId,
-                skey: stateKey,
-                content,
+        it("finds calls", async () => {
+            await JitsiCall.create(room);
+            expect(Call.get(room)).toBeInstanceOf(JitsiCall);
+        });
+
+        it("ignores terminated calls", async () => {
+            await JitsiCall.create(room);
+
+            // Terminate the call
+            const [event] = room.currentState.getStateEvents("im.vector.modular.widgets");
+            await client.sendStateEvent(room.roomId, "im.vector.modular.widgets", {}, event.getStateKey()!);
+
+            expect(Call.get(room)).toBeNull();
+        });
+    });
+
+    describe("instance", () => {
+        let call: JitsiCall;
+        let widget: Widget;
+        let messaging: Mocked<ClientWidgetApi>;
+        let audioMutedSpy: jest.SpyInstance<boolean, []>;
+        let videoMutedSpy: jest.SpyInstance<boolean, []>;
+
+        beforeEach(async () => {
+            jest.useFakeTimers();
+            jest.setSystemTime(0);
+
+            await JitsiCall.create(room);
+            const maybeCall = JitsiCall.get(room);
+            if (maybeCall === null) throw new Error("Failed to create call");
+            call = maybeCall;
+
+            ({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call));
+
+            mocked(messaging.transport).send.mockImplementation(async (action: string) => {
+                if (action === ElementWidgetActions.JoinCall) {
+                    messaging.emit(
+                        `action:${ElementWidgetActions.JoinCall}`,
+                        new CustomEvent("widgetapirequest", { detail: {} }),
+                    );
+                } else if (action === ElementWidgetActions.HangupCall) {
+                    messaging.emit(
+                        `action:${ElementWidgetActions.HangupCall}`,
+                        new CustomEvent("widgetapirequest", { detail: {} }),
+                    );
+                }
+                return {};
             });
-            room.addLiveEvents([event]);
-            return { event_id: event.getId() };
         });
 
-        setupAsyncStoreWithClient(WidgetStore.instance, client);
-        setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
+        afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
 
-        await JitsiCall.create(room);
-        call = JitsiCall.get(room);
-        if (call === null) throw new Error("Failed to create call");
+        it("connects muted", async () => {
+            expect(call.connectionState).toBe(ConnectionState.Disconnected);
+            audioMutedSpy.mockReturnValue(true);
+            videoMutedSpy.mockReturnValue(true);
 
-        widget = new Widget(call.widget);
-
-        const eventEmitter = new EventEmitter();
-        messaging = {
-            on: eventEmitter.on.bind(eventEmitter),
-            off: eventEmitter.off.bind(eventEmitter),
-            once: eventEmitter.once.bind(eventEmitter),
-            emit: eventEmitter.emit.bind(eventEmitter),
-            stop: jest.fn(),
-            transport: {
-                send: jest.fn(async action => {
-                    if (action === ElementWidgetActions.JoinCall) {
-                        messaging.emit(
-                            `action:${ElementWidgetActions.JoinCall}`,
-                            new CustomEvent("widgetapirequest", { detail: {} }),
-                        );
-                    } else if (action === ElementWidgetActions.HangupCall) {
-                        messaging.emit(
-                            `action:${ElementWidgetActions.HangupCall}`,
-                            new CustomEvent("widgetapirequest", { detail: {} }),
-                        );
-                    }
-                    return {};
-                }),
-                reply: jest.fn(),
-            },
-        } as unknown as Mocked<ClientWidgetApi>;
-        WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
-
-        audioMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithAudioMuted", "get");
-        videoMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithVideoMuted", "get");
-    });
-
-    afterEach(() => {
-        call.destroy();
-        client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
-        WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
-        jest.clearAllMocks();
-        audioMutedSpy.mockRestore();
-        videoMutedSpy.mockRestore();
-    });
-
-    it("connects muted", async () => {
-        expect(call.connectionState).toBe(ConnectionState.Disconnected);
-        audioMutedSpy.mockReturnValue(true);
-        videoMutedSpy.mockReturnValue(true);
-
-        await call.connect();
-        expect(call.connectionState).toBe(ConnectionState.Connected);
-        expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
-            audioInput: null,
-            videoInput: null,
+            await call.connect();
+            expect(call.connectionState).toBe(ConnectionState.Connected);
+            expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
+                audioInput: null,
+                videoInput: null,
+            });
         });
-    });
 
-    it("connects unmuted", async () => {
-        expect(call.connectionState).toBe(ConnectionState.Disconnected);
-        audioMutedSpy.mockReturnValue(false);
-        videoMutedSpy.mockReturnValue(false);
+        it("connects unmuted", async () => {
+            expect(call.connectionState).toBe(ConnectionState.Disconnected);
+            audioMutedSpy.mockReturnValue(false);
+            videoMutedSpy.mockReturnValue(false);
 
-        await call.connect();
-        expect(call.connectionState).toBe(ConnectionState.Connected);
-        expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
-            audioInput: "Headphones",
-            videoInput: "Built-in webcam",
+            await call.connect();
+            expect(call.connectionState).toBe(ConnectionState.Connected);
+            expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
+                audioInput: "Headphones",
+                videoInput: "Built-in webcam",
+            });
         });
-    });
 
-    it("waits for messaging when connecting", async () => {
-        // Temporarily remove the messaging to simulate connecting while the
-        // widget is still initializing
-        WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
-        expect(call.connectionState).toBe(ConnectionState.Disconnected);
+        it("waits for messaging when connecting", async () => {
+            // Temporarily remove the messaging to simulate connecting while the
+            // widget is still initializing
+            WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
+            expect(call.connectionState).toBe(ConnectionState.Disconnected);
 
-        const connect = call.connect();
-        expect(call.connectionState).toBe(ConnectionState.Connecting);
+            const connect = call.connect();
+            expect(call.connectionState).toBe(ConnectionState.Connecting);
 
-        WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
-        await connect;
-        expect(call.connectionState).toBe(ConnectionState.Connected);
-    });
+            WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
+            await connect;
+            expect(call.connectionState).toBe(ConnectionState.Connected);
+        });
 
-    it("handles remote disconnection", async () => {
-        expect(call.connectionState).toBe(ConnectionState.Disconnected);
+        it("fails to connect if the widget returns an error", async () => {
+            mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
+            await expect(call.connect()).rejects.toBeDefined();
+        });
 
-        await call.connect();
-        expect(call.connectionState).toBe(ConnectionState.Connected);
+        it("fails to disconnect if the widget returns an error", async () => {
+            await call.connect();
+            mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
+            await expect(call.disconnect()).rejects.toBeDefined();
+        });
 
-        messaging.emit(
-            `action:${ElementWidgetActions.HangupCall}`,
-            new CustomEvent("widgetapirequest", { detail: {} }),
-        );
-        await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 });
-    });
+        it("handles remote disconnection", async () => {
+            expect(call.connectionState).toBe(ConnectionState.Disconnected);
 
-    it("handles instant remote disconnection when connecting", async () => {
-        mocked(messaging.transport).send.mockImplementation(async action => {
-            if (action === ElementWidgetActions.JoinCall) {
-                // Emit the hangup event *before* the join event to fully
-                // exercise the race condition
-                messaging.emit(
-                    `action:${ElementWidgetActions.HangupCall}`,
-                    new CustomEvent("widgetapirequest", { detail: {} }),
+            await call.connect();
+            expect(call.connectionState).toBe(ConnectionState.Connected);
+
+            messaging.emit(
+                `action:${ElementWidgetActions.HangupCall}`,
+                new CustomEvent("widgetapirequest", { detail: {} }),
+            );
+            await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 });
+        });
+
+        it("handles instant remote disconnection when connecting", async () => {
+            mocked(messaging.transport).send.mockImplementation(async action => {
+                if (action === ElementWidgetActions.JoinCall) {
+                    // Emit the hangup event *before* the join event to fully
+                    // exercise the race condition
+                    messaging.emit(
+                        `action:${ElementWidgetActions.HangupCall}`,
+                        new CustomEvent("widgetapirequest", { detail: {} }),
+                    );
+                    messaging.emit(
+                        `action:${ElementWidgetActions.JoinCall}`,
+                        new CustomEvent("widgetapirequest", { detail: {} }),
+                    );
+                }
+                return {};
+            });
+            expect(call.connectionState).toBe(ConnectionState.Disconnected);
+            await call.connect();
+            expect(call.connectionState).toBe(ConnectionState.Connected);
+            // Should disconnect on its own almost instantly
+            await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 });
+        });
+
+        it("disconnects", async () => {
+            expect(call.connectionState).toBe(ConnectionState.Disconnected);
+            await call.connect();
+            expect(call.connectionState).toBe(ConnectionState.Connected);
+            await call.disconnect();
+            expect(call.connectionState).toBe(ConnectionState.Disconnected);
+        });
+
+        it("disconnects when we leave the room", async () => {
+            await call.connect();
+            expect(call.connectionState).toBe(ConnectionState.Connected);
+            room.emit(RoomEvent.MyMembership, room, "leave");
+            expect(call.connectionState).toBe(ConnectionState.Disconnected);
+        });
+
+        it("remains connected if we stay in the room", async () => {
+            await call.connect();
+            expect(call.connectionState).toBe(ConnectionState.Connected);
+            room.emit(RoomEvent.MyMembership, room, "join");
+            expect(call.connectionState).toBe(ConnectionState.Connected);
+        });
+
+        it("tracks participants in room state", async () => {
+            expect([...call.participants]).toEqual([]);
+
+            // A participant with multiple devices (should only show up once)
+            await client.sendStateEvent(
+                room.roomId,
+                JitsiCall.MEMBER_EVENT_TYPE,
+                { devices: ["bobweb", "bobdesktop"], expires_ts: 1000 * 60 * 10 },
+                bob.userId,
+            );
+            // A participant with an expired device (should not show up)
+            await client.sendStateEvent(
+                room.roomId,
+                JitsiCall.MEMBER_EVENT_TYPE,
+                { devices: ["carolandroid"], expires_ts: -1000 * 60 },
+                carol.userId,
+            );
+
+            // Now, stub out client.sendStateEvent so we can test our local echo
+            client.sendStateEvent.mockReset();
+            await call.connect();
+            expect([...call.participants]).toEqual([bob, alice]);
+
+            await call.disconnect();
+            expect([...call.participants]).toEqual([bob]);
+        });
+
+        it("updates room state when connecting and disconnecting", async () => {
+            const now1 = Date.now();
+            await call.connect();
+            await waitFor(() => expect(
+                room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId).getContent(),
+            ).toEqual({
+                devices: [client.getDeviceId()],
+                expires_ts: now1 + call.STUCK_DEVICE_TIMEOUT_MS,
+            }), { interval: 5 });
+
+            const now2 = Date.now();
+            await call.disconnect();
+            await waitFor(() => expect(
+                room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId).getContent(),
+            ).toEqual({
+                devices: [],
+                expires_ts: now2 + call.STUCK_DEVICE_TIMEOUT_MS,
+            }), { interval: 5 });
+        });
+
+        it("repeatedly updates room state while connected", async () => {
+            await call.connect();
+            await waitFor(() => expect(client.sendStateEvent).toHaveBeenLastCalledWith(
+                room.roomId,
+                JitsiCall.MEMBER_EVENT_TYPE,
+                { devices: [client.getDeviceId()], expires_ts: expect.any(Number) },
+                alice.userId,
+            ), { interval: 5 });
+
+            client.sendStateEvent.mockClear();
+            jest.advanceTimersByTime(call.STUCK_DEVICE_TIMEOUT_MS);
+            await waitFor(() => expect(client.sendStateEvent).toHaveBeenLastCalledWith(
+                room.roomId,
+                JitsiCall.MEMBER_EVENT_TYPE,
+                { devices: [client.getDeviceId()], expires_ts: expect.any(Number) },
+                alice.userId,
+            ), { interval: 5 });
+        });
+
+        it("emits events when connection state changes", async () => {
+            const events: ConnectionState[] = [];
+            const onConnectionState = (state: ConnectionState) => events.push(state);
+            call.on(CallEvent.ConnectionState, onConnectionState);
+
+            await call.connect();
+            await call.disconnect();
+            expect(events).toEqual([
+                ConnectionState.Connecting,
+                ConnectionState.Connected,
+                ConnectionState.Disconnecting,
+                ConnectionState.Disconnected,
+            ]);
+        });
+
+        it("emits events when participants change", async () => {
+            const events: Set<RoomMember>[] = [];
+            const onParticipants = (participants: Set<RoomMember>) => {
+                if (!isEqual(participants, events[events.length - 1])) events.push(participants);
+            };
+            call.on(CallEvent.Participants, onParticipants);
+
+            await call.connect();
+            await call.disconnect();
+            expect(events).toEqual([new Set([alice]), new Set()]);
+        });
+
+        it("switches to spotlight layout when the widget becomes a PiP", async () => {
+            await call.connect();
+            ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock);
+            expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
+            ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock);
+            expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {});
+        });
+
+        describe("clean", () => {
+            const aliceWeb: IMyDevice = {
+                device_id: "aliceweb",
+                last_seen_ts: 0,
+            };
+            const aliceDesktop: IMyDevice = {
+                device_id: "alicedesktop",
+                last_seen_ts: 0,
+            };
+            const aliceDesktopOffline: IMyDevice = {
+                device_id: "alicedesktopoffline",
+                last_seen_ts: 1000 * 60 * 60 * -2, // 2 hours ago
+            };
+            const aliceDesktopNeverOnline: IMyDevice = {
+                device_id: "alicedesktopneveronline",
+            };
+
+            const mkContent = (devices: IMyDevice[]): JitsiCallMemberContent => ({
+                expires_ts: 1000 * 60 * 10,
+                devices: devices.map(d => d.device_id),
+            });
+            const expectDevices = (devices: IMyDevice[]) => expect(
+                room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId).getContent(),
+            ).toEqual({
+                expires_ts: expect.any(Number),
+                devices: devices.map(d => d.device_id),
+            });
+
+            beforeEach(() => {
+                client.getDeviceId.mockReturnValue(aliceWeb.device_id);
+                client.getDevices.mockResolvedValue({
+                    devices: [
+                        aliceWeb,
+                        aliceDesktop,
+                        aliceDesktopOffline,
+                        aliceDesktopNeverOnline,
+                    ],
+                });
+            });
+
+            it("doesn't clean up valid devices", async () => {
+                await call.connect();
+                await client.sendStateEvent(
+                    room.roomId,
+                    JitsiCall.MEMBER_EVENT_TYPE,
+                    mkContent([aliceWeb, aliceDesktop]),
+                    alice.userId,
                 );
-                messaging.emit(
-                    `action:${ElementWidgetActions.JoinCall}`,
-                    new CustomEvent("widgetapirequest", { detail: {} }),
+
+                await call.clean();
+                expectDevices([aliceWeb, aliceDesktop]);
+            });
+
+            it("cleans up our own device if we're disconnected", async () => {
+                await client.sendStateEvent(
+                    room.roomId,
+                    JitsiCall.MEMBER_EVENT_TYPE,
+                    mkContent([aliceWeb, aliceDesktop]),
+                    alice.userId,
                 );
-            }
-            return {};
+
+                await call.clean();
+                expectDevices([aliceDesktop]);
+            });
+
+            it("cleans up devices that have been offline for too long", async () => {
+                await client.sendStateEvent(
+                    room.roomId,
+                    JitsiCall.MEMBER_EVENT_TYPE,
+                    mkContent([aliceDesktop, aliceDesktopOffline]),
+                    alice.userId,
+                );
+
+                await call.clean();
+                expectDevices([aliceDesktop]);
+            });
+
+            it("cleans up devices that have never been online", async () => {
+                await client.sendStateEvent(
+                    room.roomId,
+                    JitsiCall.MEMBER_EVENT_TYPE,
+                    mkContent([aliceDesktop, aliceDesktopNeverOnline]),
+                    alice.userId,
+                );
+
+                await call.clean();
+                expectDevices([aliceDesktop]);
+            });
+
+            it("no-ops if there are no state events", async () => {
+                await call.clean();
+                expect(room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId)).toBe(null);
+            });
+        });
+    });
+});
+
+describe("ElementCall", () => {
+    let client: Mocked<MatrixClient>;
+    let room: Room;
+    let alice: RoomMember;
+    let bob: RoomMember;
+    let carol: RoomMember;
+
+    beforeEach(() => {
+        ({ client, room, alice, bob, carol } = setUpClientRoomAndStores(RoomType.UnstableCall));
+    });
+
+    afterEach(() => cleanUpClientRoomAndStores(client, room));
+
+    describe("get", () => {
+        it("finds no calls", () => {
+            expect(Call.get(room)).toBeNull();
+        });
+
+        it("finds calls", async () => {
+            await ElementCall.create(room);
+            expect(Call.get(room)).toBeInstanceOf(ElementCall);
+        });
+
+        it("ignores terminated calls", async () => {
+            await ElementCall.create(room);
+
+            // Terminate the call
+            const [event] = room.currentState.getStateEvents(ElementCall.CALL_EVENT_TYPE.name);
+            const content = { ...event.getContent(), "m.terminated": "Call ended" };
+            await client.sendStateEvent(room.roomId, ElementCall.CALL_EVENT_TYPE.name, content, event.getStateKey()!);
+
+            expect(Call.get(room)).toBeNull();
+        });
+    });
+
+    describe("instance", () => {
+        let call: ElementCall;
+        let widget: Widget;
+        let messaging: Mocked<ClientWidgetApi>;
+        let audioMutedSpy: jest.SpyInstance<boolean, []>;
+        let videoMutedSpy: jest.SpyInstance<boolean, []>;
+
+        beforeEach(async () => {
+            jest.useFakeTimers();
+            jest.setSystemTime(0);
+
+            await ElementCall.create(room);
+            const maybeCall = ElementCall.get(room);
+            if (maybeCall === null) throw new Error("Failed to create call");
+            call = maybeCall;
+
+            ({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call));
+        });
+
+        afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
+
+        it("connects muted", async () => {
+            expect(call.connectionState).toBe(ConnectionState.Disconnected);
+            audioMutedSpy.mockReturnValue(true);
+            videoMutedSpy.mockReturnValue(true);
+
+            await call.connect();
+            expect(call.connectionState).toBe(ConnectionState.Connected);
+            expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
+                audioInput: null,
+                videoInput: null,
+            });
+        });
+
+        it("connects unmuted", async () => {
+            expect(call.connectionState).toBe(ConnectionState.Disconnected);
+            audioMutedSpy.mockReturnValue(false);
+            videoMutedSpy.mockReturnValue(false);
+
+            await call.connect();
+            expect(call.connectionState).toBe(ConnectionState.Connected);
+            expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
+                audioInput: "1",
+                videoInput: "2",
+            });
+        });
+
+        it("waits for messaging when connecting", async () => {
+            // Temporarily remove the messaging to simulate connecting while the
+            // widget is still initializing
+            WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
+            expect(call.connectionState).toBe(ConnectionState.Disconnected);
+
+            const connect = call.connect();
+            expect(call.connectionState).toBe(ConnectionState.Connecting);
+
+            WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
+            await connect;
+            expect(call.connectionState).toBe(ConnectionState.Connected);
+        });
+
+        it("fails to connect if the widget returns an error", async () => {
+            mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
+            await expect(call.connect()).rejects.toBeDefined();
+        });
+
+        it("fails to disconnect if the widget returns an error", async () => {
+            await call.connect();
+            mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
+            await expect(call.disconnect()).rejects.toBeDefined();
+        });
+
+        it("handles remote disconnection", async () => {
+            expect(call.connectionState).toBe(ConnectionState.Disconnected);
+
+            await call.connect();
+            expect(call.connectionState).toBe(ConnectionState.Connected);
+
+            messaging.emit(
+                `action:${ElementWidgetActions.HangupCall}`,
+                new CustomEvent("widgetapirequest", { detail: {} }),
+            );
+            await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 });
+        });
+
+        it("disconnects", async () => {
+            expect(call.connectionState).toBe(ConnectionState.Disconnected);
+            await call.connect();
+            expect(call.connectionState).toBe(ConnectionState.Connected);
+            await call.disconnect();
+            expect(call.connectionState).toBe(ConnectionState.Disconnected);
+        });
+
+        it("disconnects when we leave the room", async () => {
+            await call.connect();
+            expect(call.connectionState).toBe(ConnectionState.Connected);
+            room.emit(RoomEvent.MyMembership, room, "leave");
+            expect(call.connectionState).toBe(ConnectionState.Disconnected);
+        });
+
+        it("remains connected if we stay in the room", async () => {
+            await call.connect();
+            expect(call.connectionState).toBe(ConnectionState.Connected);
+            room.emit(RoomEvent.MyMembership, room, "join");
+            expect(call.connectionState).toBe(ConnectionState.Connected);
+        });
+
+        it("tracks participants in room state", async () => {
+            expect([...call.participants]).toEqual([]);
+
+            // A participant with multiple devices (should only show up once)
+            await client.sendStateEvent(
+                room.roomId,
+                ElementCall.MEMBER_EVENT_TYPE.name,
+                {
+                    "m.expires_ts": 1000 * 60 * 10,
+                    "m.calls": [{
+                        "m.call_id": call.groupCall.getStateKey()!,
+                        "m.devices": [
+                            { device_id: "bobweb", session_id: "1", feeds: [] },
+                            { device_id: "bobdesktop", session_id: "1", feeds: [] },
+                        ],
+                    }],
+                },
+                bob.userId,
+            );
+            // A participant with an expired device (should not show up)
+            await client.sendStateEvent(
+                room.roomId,
+                ElementCall.MEMBER_EVENT_TYPE.name,
+                {
+                    "m.expires_ts": -1000 * 60,
+                    "m.calls": [{
+                        "m.call_id": call.groupCall.getStateKey()!,
+                        "m.devices": [
+                            { device_id: "carolandroid", session_id: "1", feeds: [] },
+                        ],
+                    }],
+                },
+                carol.userId,
+            );
+
+            // Now, stub out client.sendStateEvent so we can test our local echo
+            client.sendStateEvent.mockReset();
+            await call.connect();
+            expect([...call.participants]).toEqual([bob, alice]);
+
+            await call.disconnect();
+            expect([...call.participants]).toEqual([bob]);
+        });
+
+        it("emits events when connection state changes", async () => {
+            const events: ConnectionState[] = [];
+            const onConnectionState = (state: ConnectionState) => events.push(state);
+            call.on(CallEvent.ConnectionState, onConnectionState);
+
+            await call.connect();
+            await call.disconnect();
+            expect(events).toEqual([
+                ConnectionState.Connecting,
+                ConnectionState.Connected,
+                ConnectionState.Disconnecting,
+                ConnectionState.Disconnected,
+            ]);
+        });
+
+        it("emits events when participants change", async () => {
+            const events: Set<RoomMember>[] = [];
+            const onParticipants = (participants: Set<RoomMember>) => {
+                if (!isEqual(participants, events[events.length - 1])) events.push(participants);
+            };
+            call.on(CallEvent.Participants, onParticipants);
+
+            await call.connect();
+            await call.disconnect();
+            expect(events).toEqual([new Set([alice]), new Set()]);
+        });
+
+        describe("clean", () => {
+            const aliceWeb: IMyDevice = {
+                device_id: "aliceweb",
+                last_seen_ts: 0,
+            };
+            const aliceDesktop: IMyDevice = {
+                device_id: "alicedesktop",
+                last_seen_ts: 0,
+            };
+            const aliceDesktopOffline: IMyDevice = {
+                device_id: "alicedesktopoffline",
+                last_seen_ts: 1000 * 60 * 60 * -2, // 2 hours ago
+            };
+            const aliceDesktopNeverOnline: IMyDevice = {
+                device_id: "alicedesktopneveronline",
+            };
+
+            const mkContent = (devices: IMyDevice[]): ElementCallMemberContent => ({
+                "m.expires_ts": 1000 * 60 * 10,
+                "m.calls": [{
+                    "m.call_id": call.groupCall.getStateKey()!,
+                    "m.devices": devices.map(d => ({ device_id: d.device_id, session_id: "1", feeds: [] })),
+                }],
+            });
+            const expectDevices = (devices: IMyDevice[]) => expect(
+                room.currentState.getStateEvents(ElementCall.MEMBER_EVENT_TYPE.name, alice.userId).getContent(),
+            ).toEqual({
+                "m.expires_ts": expect.any(Number),
+                "m.calls": [{
+                    "m.call_id": call.groupCall.getStateKey()!,
+                    "m.devices": devices.map(d => ({ device_id: d.device_id, session_id: "1", feeds: [] })),
+                }],
+            });
+
+            beforeEach(() => {
+                client.getDeviceId.mockReturnValue(aliceWeb.device_id);
+                client.getDevices.mockResolvedValue({
+                    devices: [
+                        aliceWeb,
+                        aliceDesktop,
+                        aliceDesktopOffline,
+                        aliceDesktopNeverOnline,
+                    ],
+                });
+            });
+
+            it("doesn't clean up valid devices", async () => {
+                await call.connect();
+                await client.sendStateEvent(
+                    room.roomId,
+                    ElementCall.MEMBER_EVENT_TYPE.name,
+                    mkContent([aliceWeb, aliceDesktop]),
+                    alice.userId,
+                );
+
+                await call.clean();
+                expectDevices([aliceWeb, aliceDesktop]);
+            });
+
+            it("cleans up our own device if we're disconnected", async () => {
+                await client.sendStateEvent(
+                    room.roomId,
+                    ElementCall.MEMBER_EVENT_TYPE.name,
+                    mkContent([aliceWeb, aliceDesktop]),
+                    alice.userId,
+                );
+
+                await call.clean();
+                expectDevices([aliceDesktop]);
+            });
+
+            it("cleans up devices that have been offline for too long", async () => {
+                await client.sendStateEvent(
+                    room.roomId,
+                    ElementCall.MEMBER_EVENT_TYPE.name,
+                    mkContent([aliceDesktop, aliceDesktopOffline]),
+                    alice.userId,
+                );
+
+                await call.clean();
+                expectDevices([aliceDesktop]);
+            });
+
+            it("cleans up devices that have never been online", async () => {
+                await client.sendStateEvent(
+                    room.roomId,
+                    ElementCall.MEMBER_EVENT_TYPE.name,
+                    mkContent([aliceDesktop, aliceDesktopNeverOnline]),
+                    alice.userId,
+                );
+
+                await call.clean();
+                expectDevices([aliceDesktop]);
+            });
+
+            it("no-ops if there are no state events", async () => {
+                await call.clean();
+                expect(room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId)).toBe(null);
+            });
         });
-        expect(call.connectionState).toBe(ConnectionState.Disconnected);
-        await call.connect();
-        expect(call.connectionState).toBe(ConnectionState.Connected);
-        // Should disconnect on its own almost instantly
-        await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 });
-    });
-
-    it("disconnects", async () => {
-        expect(call.connectionState).toBe(ConnectionState.Disconnected);
-        await call.connect();
-        expect(call.connectionState).toBe(ConnectionState.Connected);
-        await call.disconnect();
-        expect(call.connectionState).toBe(ConnectionState.Disconnected);
-    });
-
-    it("tracks participants in room state", async () => {
-        expect([...call.participants]).toEqual([]);
-
-        // A participant with multiple devices (should only show up once)
-        await client.sendStateEvent(
-            room.roomId,
-            JitsiCall.MEMBER_EVENT_TYPE,
-            { devices: ["bobweb", "bobdesktop"], expires_ts: 1000 * 60 * 10 },
-            bob.userId,
-        );
-        // A participant with an expired device (should not show up)
-        await client.sendStateEvent(
-            room.roomId,
-            JitsiCall.MEMBER_EVENT_TYPE,
-            { devices: ["carolandroid"], expires_ts: -1000 * 60 },
-            carol.userId,
-        );
-
-        // Now, stub out client.sendStateEvent so we can test our local echo
-        client.sendStateEvent.mockReset();
-        await call.connect();
-        expect([...call.participants]).toEqual([bob, alice]);
-
-        await call.disconnect();
-        expect([...call.participants]).toEqual([bob]);
-    });
-
-    it("updates room state when connecting and disconnecting", async () => {
-        const now1 = Date.now();
-        await call.connect();
-        await waitFor(() => expect(
-            room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId).getContent(),
-        ).toEqual({
-            devices: [client.getDeviceId()],
-            expires_ts: now1 + JitsiCall.STUCK_DEVICE_TIMEOUT_MS,
-        }), { interval: 5 });
-
-        const now2 = Date.now();
-        await call.disconnect();
-        await waitFor(() => expect(
-            room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, alice.userId).getContent(),
-        ).toEqual({
-            devices: [],
-            expires_ts: now2 + JitsiCall.STUCK_DEVICE_TIMEOUT_MS,
-        }), { interval: 5 });
-    });
-
-    it("repeatedly updates room state while connected", async () => {
-        await call.connect();
-        await waitFor(() => expect(client.sendStateEvent).toHaveBeenLastCalledWith(
-            room.roomId,
-            JitsiCall.MEMBER_EVENT_TYPE,
-            { devices: [client.getDeviceId()], expires_ts: expect.any(Number) },
-            alice.userId,
-        ), { interval: 5 });
-
-        client.sendStateEvent.mockClear();
-        jest.advanceTimersByTime(JitsiCall.STUCK_DEVICE_TIMEOUT_MS);
-        await waitFor(() => expect(client.sendStateEvent).toHaveBeenLastCalledWith(
-            room.roomId,
-            JitsiCall.MEMBER_EVENT_TYPE,
-            { devices: [client.getDeviceId()], expires_ts: expect.any(Number) },
-            alice.userId,
-        ), { interval: 5 });
-    });
-
-    it("emits events when connection state changes", async () => {
-        const events: ConnectionState[] = [];
-        const onConnectionState = (state: ConnectionState) => events.push(state);
-        call.on(CallEvent.ConnectionState, onConnectionState);
-
-        await call.connect();
-        await call.disconnect();
-        expect(events).toEqual([
-            ConnectionState.Connecting,
-            ConnectionState.Connected,
-            ConnectionState.Disconnecting,
-            ConnectionState.Disconnected,
-        ]);
-    });
-
-    it("emits events when participants change", async () => {
-        const events: Set<RoomMember>[] = [];
-        const onParticipants = (participants: Set<RoomMember>) => {
-            if (!isEqual(participants, events[events.length - 1])) events.push(participants);
-        };
-        call.on(CallEvent.Participants, onParticipants);
-
-        await call.connect();
-        await call.disconnect();
-        expect(events).toEqual([new Set([alice]), new Set()]);
-    });
-
-    it("switches to spotlight layout when the widget becomes a PiP", async () => {
-        await call.connect();
-        ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock);
-        expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
-        ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock);
-        expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {});
     });
 });
diff --git a/test/stores/widgets/StopGapWidget-test.ts b/test/stores/widgets/StopGapWidget-test.ts
index 40292e451b..9f5ca03280 100644
--- a/test/stores/widgets/StopGapWidget-test.ts
+++ b/test/stores/widgets/StopGapWidget-test.ts
@@ -39,6 +39,7 @@ describe("StopGapWidget", () => {
                 creatorUserId: "@alice:example.org",
                 type: "example",
                 url: "https://example.org",
+                roomId: "!1:example.org",
             },
             room: mkRoom(client, "!1:example.org"),
             userId: "@alice:example.org",
diff --git a/test/stores/widgets/StopGapWidgetDriver-test.ts b/test/stores/widgets/StopGapWidgetDriver-test.ts
index dccd203850..2d4fe90e1f 100644
--- a/test/stores/widgets/StopGapWidgetDriver-test.ts
+++ b/test/stores/widgets/StopGapWidgetDriver-test.ts
@@ -15,10 +15,10 @@ limitations under the License.
 */
 
 import { mocked, MockedObject } from "jest-mock";
-import { ClientEvent, ITurnServer as IClientTurnServer, MatrixClient } from "matrix-js-sdk/src/client";
+import { MatrixClient, ClientEvent, ITurnServer as IClientTurnServer } from "matrix-js-sdk/src/client";
 import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo";
 import { Direction, MatrixEvent } from "matrix-js-sdk/src/matrix";
-import { ITurnServer, Widget, WidgetDriver, WidgetKind } from "matrix-widget-api";
+import { Widget, MatrixWidgetType, WidgetKind, WidgetDriver, ITurnServer } from "matrix-widget-api";
 
 import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
 import { RoomViewStore } from "../../../src/stores/RoomViewStore";
@@ -27,22 +27,75 @@ import { stubClient } from "../../test-utils";
 
 describe("StopGapWidgetDriver", () => {
     let client: MockedObject<MatrixClient>;
-    let driver: WidgetDriver;
+
+    const mkDefaultDriver = (): WidgetDriver => new StopGapWidgetDriver(
+        [],
+        new Widget({
+            id: "test",
+            creatorUserId: "@alice:example.org",
+            type: "example",
+            url: "https://example.org",
+        }),
+        WidgetKind.Room,
+        false,
+        "!1:example.org",
+    );
 
     beforeEach(() => {
         stubClient();
         client = mocked(MatrixClientPeg.get());
+        client.getUserId.mockReturnValue("@alice:example.org");
+    });
 
-        driver = new StopGapWidgetDriver(
+    it("auto-approves capabilities of virtual Element Call widgets", async () => {
+        const driver = new StopGapWidgetDriver(
             [],
             new Widget({
-                id: "test",
+                id: "group_call",
                 creatorUserId: "@alice:example.org",
-                type: "example",
-                url: "https://example.org",
+                type: MatrixWidgetType.Custom,
+                url: "https://call.element.io",
             }),
             WidgetKind.Room,
+            true,
+            "!1:example.org",
         );
+
+        // These are intentionally raw identifiers rather than constants, so it's obvious what's being requested
+        const requestedCapabilities = new Set([
+            "m.always_on_screen",
+            "town.robin.msc3846.turn_servers",
+            "org.matrix.msc2762.timeline:!1:example.org",
+            "org.matrix.msc2762.receive.state_event:m.room.member",
+            "org.matrix.msc2762.send.state_event:org.matrix.msc3401.call",
+            "org.matrix.msc2762.receive.state_event:org.matrix.msc3401.call",
+            "org.matrix.msc2762.send.state_event:org.matrix.msc3401.call.member#@alice:example.org",
+            "org.matrix.msc2762.receive.state_event:org.matrix.msc3401.call.member",
+            "org.matrix.msc3819.send.to_device:m.call.invite",
+            "org.matrix.msc3819.receive.to_device:m.call.invite",
+            "org.matrix.msc3819.send.to_device:m.call.candidates",
+            "org.matrix.msc3819.receive.to_device:m.call.candidates",
+            "org.matrix.msc3819.send.to_device:m.call.answer",
+            "org.matrix.msc3819.receive.to_device:m.call.answer",
+            "org.matrix.msc3819.send.to_device:m.call.hangup",
+            "org.matrix.msc3819.receive.to_device:m.call.hangup",
+            "org.matrix.msc3819.send.to_device:m.call.reject",
+            "org.matrix.msc3819.receive.to_device:m.call.reject",
+            "org.matrix.msc3819.send.to_device:m.call.select_answer",
+            "org.matrix.msc3819.receive.to_device:m.call.select_answer",
+            "org.matrix.msc3819.send.to_device:m.call.negotiate",
+            "org.matrix.msc3819.receive.to_device:m.call.negotiate",
+            "org.matrix.msc3819.send.to_device:m.call.sdp_stream_metadata_changed",
+            "org.matrix.msc3819.receive.to_device:m.call.sdp_stream_metadata_changed",
+            "org.matrix.msc3819.send.to_device:org.matrix.call.sdp_stream_metadata_changed",
+            "org.matrix.msc3819.receive.to_device:org.matrix.call.sdp_stream_metadata_changed",
+            "org.matrix.msc3819.send.to_device:m.call.replaces",
+            "org.matrix.msc3819.receive.to_device:m.call.replaces",
+        ]);
+
+        // As long as this resolves, we'll know that it didn't try to pop up a modal
+        const approvedCapabilities = await driver.validateCapabilities(requestedCapabilities);
+        expect(approvedCapabilities).toEqual(requestedCapabilities);
     });
 
     describe("sendToDevice", () => {
@@ -59,6 +112,10 @@ describe("StopGapWidgetDriver", () => {
             },
         };
 
+        let driver: WidgetDriver;
+
+        beforeEach(() => { driver = mkDefaultDriver(); });
+
         it("sends unencrypted messages", async () => {
             await driver.sendToDevice("org.example.foo", false, contentMap);
             expect(client.queueToDevice.mock.calls).toMatchSnapshot();
@@ -80,6 +137,10 @@ describe("StopGapWidgetDriver", () => {
     });
 
     describe("getTurnServers", () => {
+        let driver: WidgetDriver;
+
+        beforeEach(() => { driver = mkDefaultDriver(); });
+
         it("stops if VoIP isn't supported", async () => {
             jest.spyOn(client, "pollingTurnServers", "get").mockReturnValue(false);
             const servers = driver.getTurnServers();
@@ -135,6 +196,10 @@ describe("StopGapWidgetDriver", () => {
     });
 
     describe("readEventRelations", () => {
+        let driver: WidgetDriver;
+
+        beforeEach(() => { driver = mkDefaultDriver(); });
+
         it('reads related events from the current room', async () => {
             jest.spyOn(RoomViewStore.instance, 'getRoomId').mockReturnValue('!this-room-id');
 
diff --git a/test/test-utils/call.ts b/test/test-utils/call.ts
index 8ebaf5140d..0020ab4600 100644
--- a/test/test-utils/call.ts
+++ b/test/test-utils/call.ts
@@ -23,17 +23,21 @@ import { Call } from "../../src/models/Call";
 
 export class MockedCall extends Call {
     private static EVENT_TYPE = "org.example.mocked_call";
+    public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
 
-    private constructor(private readonly room: Room, private readonly id: string) {
-        super({
-            id,
-            eventId: "$1:example.org",
-            roomId: room.roomId,
-            type: MatrixWidgetType.Custom,
-            url: "https://example.org",
-            name: "Group call",
-            creatorUserId: "@alice:example.org",
-        });
+    private constructor(room: Room, id: string) {
+        super(
+            {
+                id,
+                eventId: "$1:example.org",
+                roomId: room.roomId,
+                type: MatrixWidgetType.Custom,
+                url: "https://example.org",
+                name: "Group call",
+                creatorUserId: "@alice:example.org",
+            },
+            room.client,
+        );
     }
 
     public static get(room: Room): MockedCall | null {
@@ -61,12 +65,10 @@ export class MockedCall extends Call {
     }
 
     // No action needed for any of the following methods since this is just a mock
-    public async clean(): Promise<void> {}
+    protected getDevices(): string[] { return []; }
+    protected async setDevices(): Promise<void> { }
     // Public to allow spying
-    public async performConnection(
-        audioInput: MediaDeviceInfo | null,
-        videoInput: MediaDeviceInfo | null,
-    ): Promise<void> {}
+    public async performConnection(): Promise<void> {}
     public async performDisconnection(): Promise<void> {}
 
     public destroy() {
@@ -77,7 +79,7 @@ export class MockedCall extends Call {
             room: this.room.roomId,
             user: "@alice:example.org",
             content: { terminated: true },
-            skey: this.id,
+            skey: this.widget.id,
         })]);
 
         super.destroy();
diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts
index 8d330391fd..ed60040346 100644
--- a/test/test-utils/test-utils.ts
+++ b/test/test-utils/test-utils.ts
@@ -99,7 +99,7 @@ export function createTestClient(): MatrixClient {
         },
 
         getPushActionsForEvent: jest.fn(),
-        getRoom: jest.fn().mockImplementation(mkStubRoom),
+        getRoom: jest.fn().mockImplementation(roomId => mkStubRoom(roomId, "My room", client)),
         getRooms: jest.fn().mockReturnValue([]),
         getVisibleRooms: jest.fn().mockReturnValue([]),
         loginFlows: jest.fn(),
@@ -335,8 +335,10 @@ export function mkRoomMember(roomId: string, userId: string, membership = "join"
         name: userId,
         rawDisplayName: userId,
         roomId,
+        events: {},
         getAvatarUrl: () => {},
         getMxcAvatarUrl: () => {},
+        getDMInviter: () => {},
     } as unknown as RoomMember;
 }
 
diff --git a/test/utils/GroupCallUtils-test.ts b/test/utils/GroupCallUtils-test.ts
deleted file mode 100644
index 971527e803..0000000000
--- a/test/utils/GroupCallUtils-test.ts
+++ /dev/null
@@ -1,673 +0,0 @@
-/*
-Copyright 2022 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 { mocked } from "jest-mock";
-import { IMyDevice, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
-
-import {
-    CALL_MEMBER_STATE_EVENT_TYPE,
-    CALL_STATE_EVENT_TYPE,
-    fixStuckDevices,
-    getGroupCall,
-    removeOurDevice,
-    STUCK_DEVICE_TIMEOUT_MS,
-    useConnectedMembers,
-} from "../../src/utils/GroupCallUtils";
-import { createTestClient, mkEvent } from "../test-utils";
-
-[
-    {
-        callStateEventType: CALL_STATE_EVENT_TYPE.name,
-        callMemberStateEventType: CALL_MEMBER_STATE_EVENT_TYPE.name,
-    },
-    {
-        callStateEventType: CALL_STATE_EVENT_TYPE.altName,
-        callMemberStateEventType: CALL_MEMBER_STATE_EVENT_TYPE.altName,
-    },
-].forEach(({ callStateEventType, callMemberStateEventType }) => {
-    describe(`GroupCallUtils (${callStateEventType}, ${callMemberStateEventType})`, () => {
-        const roomId = "!room:example.com";
-        let client: MatrixClient;
-        let callEvent: MatrixEvent;
-        const callId = "test call";
-        const callId2 = "test call 2";
-        const userId1 = "@user1:example.com";
-        const now = 1654616071686;
-
-        const setUpNonCallStateEvent = () => {
-            callEvent = mkEvent({
-                room: roomId,
-                user: userId1,
-                event: true,
-                type: "test",
-                skey: userId1,
-                content: {},
-            });
-        };
-
-        const setUpEmptyStateKeyCallEvent = () => {
-            callEvent = mkEvent({
-                room: roomId,
-                user: userId1,
-                event: true,
-                type: callStateEventType,
-                skey: "",
-                content: {},
-            });
-        };
-
-        const setUpValidCallEvent = () => {
-            callEvent = mkEvent({
-                room: roomId,
-                user: userId1,
-                event: true,
-                type: callStateEventType,
-                skey: callId,
-                content: {},
-            });
-        };
-
-        beforeEach(() => {
-            client = createTestClient();
-        });
-
-        describe("getGroupCall", () => {
-            describe("for a non-existing room", () => {
-                beforeEach(() => {
-                    mocked(client.getRoom).mockReturnValue(null);
-                });
-
-                it("should return null", () => {
-                    expect(getGroupCall(client, roomId)).toBeUndefined();
-                });
-            });
-
-            describe("for an existing room", () => {
-                let room: Room;
-
-                beforeEach(() => {
-                    room = new Room(roomId, client, client.getUserId());
-                    mocked(client.getRoom).mockImplementation((rid: string) => {
-                        return rid === roomId
-                            ? room
-                            : null;
-                    });
-                });
-
-                it("should return null if no 'call' state event exist", () => {
-                    expect(getGroupCall(client, roomId)).toBeUndefined();
-                });
-
-                describe("with call state events", () => {
-                    let callEvent1: MatrixEvent;
-                    let callEvent2: MatrixEvent;
-                    let callEvent3: MatrixEvent;
-
-                    beforeEach(() => {
-                        callEvent1 = mkEvent({
-                            room: roomId,
-                            user: client.getUserId(),
-                            event: true,
-                            type: callStateEventType,
-                            content: {},
-                            ts: 150,
-                            skey: "call1",
-                        });
-                        room.getLiveTimeline().addEvent(callEvent1, {
-                            toStartOfTimeline: false,
-                        });
-
-                        callEvent2 = mkEvent({
-                            room: roomId,
-                            user: client.getUserId(),
-                            event: true,
-                            type: callStateEventType,
-                            content: {},
-                            ts: 100,
-                            skey: "call2",
-                        });
-                        room.getLiveTimeline().addEvent(callEvent2, {
-                            toStartOfTimeline: false,
-                        });
-
-                        // terminated call - should never be returned
-                        callEvent3 = mkEvent({
-                            room: roomId,
-                            user: client.getUserId(),
-                            event: true,
-                            type: callStateEventType,
-                            content: {
-                                ["m.terminated"]: "time's up",
-                            },
-                            ts: 500,
-                            skey: "call3",
-                        });
-                        room.getLiveTimeline().addEvent(callEvent3, {
-                            toStartOfTimeline: false,
-                        });
-                    });
-
-                    it("should return the newest call state event (1)", () => {
-                        expect(getGroupCall(client, roomId)).toBe(callEvent1);
-                    });
-
-                    it("should return the newest call state event (2)", () => {
-                        callEvent2.getTs = () => 200;
-                        expect(getGroupCall(client, roomId)).toBe(callEvent2);
-                    });
-                });
-            });
-        });
-
-        describe("useConnectedMembers", () => {
-            describe("for a non-call event", () => {
-                beforeEach(() => {
-                    setUpNonCallStateEvent();
-                });
-
-                it("should return an empty list", () => {
-                    expect(useConnectedMembers(client, callEvent)).toEqual([]);
-                });
-            });
-
-            describe("for an empty state key", () => {
-                beforeEach(() => {
-                    setUpEmptyStateKeyCallEvent();
-                });
-
-                it("should return an empty list", () => {
-                    expect(useConnectedMembers(client, callEvent)).toEqual([]);
-                });
-            });
-
-            describe("for a valid call state event", () => {
-                beforeEach(() => {
-                    setUpValidCallEvent();
-                });
-
-                describe("and a non-existing room", () => {
-                    beforeEach(() => {
-                        mocked(client.getRoom).mockReturnValue(null);
-                    });
-
-                    it("should return an empty list", () => {
-                        expect(useConnectedMembers(client, callEvent)).toEqual([]);
-                    });
-                });
-
-                describe("and an existing room", () => {
-                    let room: Room;
-
-                    beforeEach(() => {
-                        room = new Room(roomId, client, client.getUserId());
-                        mocked(client.getRoom).mockImplementation((rid: string) => {
-                            return rid === roomId
-                                ? room
-                                : null;
-                        });
-                    });
-
-                    it("should return an empty list if no call member state events exist", () => {
-                        expect(useConnectedMembers(client, callEvent)).toEqual([]);
-                    });
-
-                    describe("and some call member state events", () => {
-                        const userId2 = "@user2:example.com";
-                        const userId3 = "@user3:example.com";
-                        const userId4 = "@user4:example.com";
-                        let expectedEvent1: MatrixEvent;
-                        let expectedEvent2: MatrixEvent;
-
-                        beforeEach(() => {
-                            jest.useFakeTimers()
-                                .setSystemTime(now);
-
-                            expectedEvent1 = mkEvent({
-                                event: true,
-                                room: roomId,
-                                user: userId1,
-                                skey: userId1,
-                                type: callMemberStateEventType,
-                                content: {
-                                    ["m.expires_ts"]: now + 100,
-                                    ["m.calls"]: [
-                                        {
-                                            ["m.call_id"]: callId2,
-                                        },
-                                        {
-                                            ["m.call_id"]: callId,
-                                        },
-                                    ],
-                                },
-                            });
-                            room.getLiveTimeline().addEvent(expectedEvent1, { toStartOfTimeline: false });
-
-                            expectedEvent2 = mkEvent({
-                                event: true,
-                                room: roomId,
-                                user: userId2,
-                                skey: userId2,
-                                type: callMemberStateEventType,
-                                content: {
-                                    ["m.expires_ts"]: now + 100,
-                                    ["m.calls"]: [
-                                        {
-                                            ["m.call_id"]: callId,
-                                        },
-                                    ],
-                                },
-                            });
-                            room.getLiveTimeline().addEvent(expectedEvent2, { toStartOfTimeline: false });
-
-                            // expired event
-                            const event3 = mkEvent({
-                                event: true,
-                                room: roomId,
-                                user: userId3,
-                                skey: userId3,
-                                type: callMemberStateEventType,
-                                content: {
-                                    ["m.expires_ts"]: now - 100,
-                                    ["m.calls"]: [
-                                        {
-                                            ["m.call_id"]: callId,
-                                        },
-                                    ],
-                                },
-                            });
-                            room.getLiveTimeline().addEvent(event3, { toStartOfTimeline: false });
-
-                            // other call
-                            const event4 = mkEvent({
-                                event: true,
-                                room: roomId,
-                                user: userId4,
-                                skey: userId4,
-                                type: callMemberStateEventType,
-                                content: {
-                                    ["m.expires_ts"]: now + 100,
-                                    ["m.calls"]: [
-                                        {
-                                            ["m.call_id"]: callId2,
-                                        },
-                                    ],
-                                },
-                            });
-                            room.getLiveTimeline().addEvent(event4, { toStartOfTimeline: false });
-
-                            // empty calls
-                            const event5 = mkEvent({
-                                event: true,
-                                room: roomId,
-                                user: userId4,
-                                skey: userId4,
-                                type: callMemberStateEventType,
-                                content: {
-                                    ["m.expires_ts"]: now + 100,
-                                    ["m.calls"]: [],
-                                },
-                            });
-                            room.getLiveTimeline().addEvent(event5, { toStartOfTimeline: false });
-
-                            // no calls prop
-                            const event6 = mkEvent({
-                                event: true,
-                                room: roomId,
-                                user: userId4,
-                                skey: userId4,
-                                type: callMemberStateEventType,
-                                content: {
-                                    ["m.expires_ts"]: now + 100,
-                                },
-                            });
-                            room.getLiveTimeline().addEvent(event6, { toStartOfTimeline: false });
-                        });
-
-                        it("should return the expected call member events", () => {
-                            const callMemberEvents = useConnectedMembers(client, callEvent);
-                            expect(callMemberEvents).toHaveLength(2);
-                            expect(callMemberEvents).toContain(expectedEvent1);
-                            expect(callMemberEvents).toContain(expectedEvent2);
-                        });
-                    });
-                });
-            });
-        });
-
-        describe("removeOurDevice", () => {
-            describe("for a non-call event", () => {
-                beforeEach(() => {
-                    setUpNonCallStateEvent();
-                });
-
-                it("should not update the state", () => {
-                    removeOurDevice(client, callEvent);
-                    expect(client.sendStateEvent).not.toHaveBeenCalled();
-                });
-            });
-
-            describe("for an empty state key", () => {
-                beforeEach(() => {
-                    setUpEmptyStateKeyCallEvent();
-                });
-
-                it("should not update the state", () => {
-                    removeOurDevice(client, callEvent);
-                    expect(client.sendStateEvent).not.toHaveBeenCalled();
-                });
-            });
-
-            describe("for a valid call state event", () => {
-                beforeEach(() => {
-                    setUpValidCallEvent();
-                });
-
-                describe("and a non-existing room", () => {
-                    beforeEach(() => {
-                        mocked(client.getRoom).mockReturnValue(null);
-                    });
-
-                    it("should not update the state", () => {
-                        removeOurDevice(client, callEvent);
-                        expect(client.sendStateEvent).not.toHaveBeenCalled();
-                    });
-                });
-
-                describe("and an existing room", () => {
-                    let room: Room;
-
-                    beforeEach(() => {
-                        room = new Room(roomId, client, client.getUserId());
-                        room.getLiveTimeline().addEvent(callEvent, { toStartOfTimeline: false });
-                        mocked(client.getRoom).mockImplementation((rid: string) => {
-                            return rid === roomId
-                                ? room
-                                : null;
-                        });
-                    });
-
-                    it("should not update the state if no call member event exists", () => {
-                        removeOurDevice(client, callEvent);
-                        expect(client.sendStateEvent).not.toHaveBeenCalled();
-                    });
-
-                    describe("and a call member state event", () => {
-                        beforeEach(() => {
-                            jest.useFakeTimers()
-                                .setSystemTime(now);
-
-                            const callMemberEvent = mkEvent({
-                                event: true,
-                                room: roomId,
-                                user: client.getUserId(),
-                                skey: client.getUserId(),
-                                type: callMemberStateEventType,
-                                content: {
-                                    ["m.expires_ts"]: now - 100,
-                                    ["m.calls"]: [
-                                        {
-                                            ["m.call_id"]: callId,
-                                            ["m.devices"]: [
-                                                // device to be removed
-                                                { "m.device_id": client.getDeviceId() },
-                                                { "m.device_id": "device 2" },
-                                            ],
-                                        },
-                                        {
-                                            // no device list
-                                            ["m.call_id"]: callId,
-                                        },
-                                        {
-                                            // other call
-                                            ["m.call_id"]: callId2,
-                                            ["m.devices"]: [
-                                                { "m.device_id": client.getDeviceId() },
-                                            ],
-                                        },
-                                    ],
-                                },
-                            });
-                            room.getLiveTimeline().addEvent(callMemberEvent, { toStartOfTimeline: false });
-                        });
-
-                        it("should remove the device from the call", async () => {
-                            await removeOurDevice(client, callEvent);
-                            expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
-                            expect(client.sendStateEvent).toHaveBeenCalledWith(
-                                roomId,
-                                CALL_MEMBER_STATE_EVENT_TYPE.name,
-                                {
-                                    ["m.expires_ts"]: now + STUCK_DEVICE_TIMEOUT_MS,
-                                    ["m.calls"]: [
-                                        {
-                                            ["m.call_id"]: callId,
-                                            ["m.devices"]: [
-                                                { "m.device_id": "device 2" },
-                                            ],
-                                        },
-                                        {
-                                            // no device list
-                                            ["m.call_id"]: callId,
-                                        },
-                                        {
-                                            // other call
-                                            ["m.call_id"]: callId2,
-                                            ["m.devices"]: [
-                                                { "m.device_id": client.getDeviceId() },
-                                            ],
-                                        },
-                                    ],
-                                },
-                                client.getUserId(),
-                            );
-                        });
-                    });
-                });
-            });
-        });
-
-        describe("fixStuckDevices", () => {
-            let thisDevice: IMyDevice;
-            let otherDevice: IMyDevice;
-            let noLastSeenTsDevice: IMyDevice;
-            let stuckDevice: IMyDevice;
-
-            beforeEach(() => {
-                jest.useFakeTimers()
-                    .setSystemTime(now);
-
-                thisDevice = { device_id: "ABCDEFGHI", last_seen_ts: now - STUCK_DEVICE_TIMEOUT_MS - 100 };
-                otherDevice = { device_id: "ABCDEFGHJ", last_seen_ts: now };
-                noLastSeenTsDevice = { device_id: "ABCDEFGHK" };
-                stuckDevice = { device_id: "ABCDEFGHL", last_seen_ts: now - STUCK_DEVICE_TIMEOUT_MS - 100 };
-
-                mocked(client.getDeviceId).mockReturnValue(thisDevice.device_id);
-                mocked(client.getDevices).mockResolvedValue({
-                    devices: [
-                        thisDevice,
-                        otherDevice,
-                        noLastSeenTsDevice,
-                        stuckDevice,
-                    ],
-                });
-            });
-
-            describe("for a non-call event", () => {
-                beforeEach(() => {
-                    setUpNonCallStateEvent();
-                });
-
-                it("should not update the state", () => {
-                    fixStuckDevices(client, callEvent, true);
-                    expect(client.sendStateEvent).not.toHaveBeenCalled();
-                });
-            });
-
-            describe("for an empty state key", () => {
-                beforeEach(() => {
-                    setUpEmptyStateKeyCallEvent();
-                });
-
-                it("should not update the state", () => {
-                    fixStuckDevices(client, callEvent, true);
-                    expect(client.sendStateEvent).not.toHaveBeenCalled();
-                });
-            });
-
-            describe("for a valid call state event", () => {
-                beforeEach(() => {
-                    setUpValidCallEvent();
-                });
-
-                describe("and a non-existing room", () => {
-                    beforeEach(() => {
-                        mocked(client.getRoom).mockReturnValue(null);
-                    });
-
-                    it("should not update the state", () => {
-                        fixStuckDevices(client, callEvent, true);
-                        expect(client.sendStateEvent).not.toHaveBeenCalled();
-                    });
-                });
-
-                describe("and an existing room", () => {
-                    let room: Room;
-
-                    beforeEach(() => {
-                        room = new Room(roomId, client, client.getUserId());
-                        room.getLiveTimeline().addEvent(callEvent, { toStartOfTimeline: false });
-                        mocked(client.getRoom).mockImplementation((rid: string) => {
-                            return rid === roomId
-                                ? room
-                                : null;
-                        });
-                    });
-
-                    it("should not update the state if no call member event exists", () => {
-                        fixStuckDevices(client, callEvent, true);
-                        expect(client.sendStateEvent).not.toHaveBeenCalled();
-                    });
-
-                    describe("and a call member state event", () => {
-                        beforeEach(() => {
-                            const callMemberEvent = mkEvent({
-                                event: true,
-                                room: roomId,
-                                user: client.getUserId(),
-                                skey: client.getUserId(),
-                                type: callMemberStateEventType,
-                                content: {
-                                    ["m.expires_ts"]: now - 100,
-                                    ["m.calls"]: [
-                                        {
-                                            ["m.call_id"]: callId,
-                                            ["m.devices"]: [
-                                                { "m.device_id": thisDevice.device_id },
-                                                { "m.device_id": otherDevice.device_id },
-                                                { "m.device_id": noLastSeenTsDevice.device_id },
-                                                { "m.device_id": stuckDevice.device_id },
-                                            ],
-                                        },
-                                        {
-                                            // no device list
-                                            ["m.call_id"]: callId,
-                                        },
-                                        {
-                                            // other call
-                                            ["m.call_id"]: callId2,
-                                            ["m.devices"]: [
-                                                { "m.device_id": stuckDevice.device_id },
-                                            ],
-                                        },
-                                    ],
-                                },
-                            });
-                            room.getLiveTimeline().addEvent(callMemberEvent, { toStartOfTimeline: false });
-                        });
-
-                        it("should remove stuck devices from the call, except this device", async () => {
-                            await fixStuckDevices(client, callEvent, false);
-                            expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
-                            expect(client.sendStateEvent).toHaveBeenCalledWith(
-                                roomId,
-                                CALL_MEMBER_STATE_EVENT_TYPE.name,
-                                {
-                                    ["m.expires_ts"]: now + STUCK_DEVICE_TIMEOUT_MS,
-                                    ["m.calls"]: [
-                                        {
-                                            ["m.call_id"]: callId,
-                                            ["m.devices"]: [
-                                                { "m.device_id": thisDevice.device_id },
-                                                { "m.device_id": otherDevice.device_id },
-                                                { "m.device_id": noLastSeenTsDevice.device_id },
-                                            ],
-                                        },
-                                        {
-                                            // no device list
-                                            ["m.call_id"]: callId,
-                                        },
-                                        {
-                                            // other call
-                                            ["m.call_id"]: callId2,
-                                            ["m.devices"]: [
-                                                { "m.device_id": stuckDevice.device_id },
-                                            ],
-                                        },
-                                    ],
-                                },
-                                client.getUserId(),
-                            );
-                        });
-
-                        it("should remove stuck devices from the call, including this device", async () => {
-                            await fixStuckDevices(client, callEvent, true);
-                            expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
-                            expect(client.sendStateEvent).toHaveBeenCalledWith(
-                                roomId,
-                                CALL_MEMBER_STATE_EVENT_TYPE.name,
-                                {
-                                    ["m.expires_ts"]: now + STUCK_DEVICE_TIMEOUT_MS,
-                                    ["m.calls"]: [
-                                        {
-                                            ["m.call_id"]: callId,
-                                            ["m.devices"]: [
-                                                { "m.device_id": otherDevice.device_id },
-                                                { "m.device_id": noLastSeenTsDevice.device_id },
-                                            ],
-                                        },
-                                        {
-                                            // no device list
-                                            ["m.call_id"]: callId,
-                                        },
-                                        {
-                                            // other call
-                                            ["m.call_id"]: callId2,
-                                            ["m.devices"]: [
-                                                { "m.device_id": stuckDevice.device_id },
-                                            ],
-                                        },
-                                    ],
-                                },
-                                client.getUserId(),
-                            );
-                        });
-                    });
-                });
-            });
-        });
-    });
-});
-