From 2c612d5aa135f409de9dcec6f42bdfb283ceff87 Mon Sep 17 00:00:00 2001
From: Robin <robin@robin.town>
Date: Mon, 28 Nov 2022 16:37:32 -0500
Subject: [PATCH] Use native js-sdk group call support (#9625)

* Use native js-sdk group call support

Now that the js-sdk supports group calls natively, our group call implementation can be simplified a bit. Switching to the js-sdk implementation also brings the react-sdk up to date with recent MSC3401 changes, and adds support for joining calls from multiple devices. (So, the previous logic which sent to-device messages to prevent multi-device sessions is no longer necessary.)

* Fix strings

* Fix strict type errors
---
 .../views/beacon/RoomCallBanner.tsx           |  35 +-
 src/components/views/messages/CallEvent.tsx   |  52 +--
 .../views/rooms/LiveContentSummary.tsx        |  11 +-
 src/components/views/rooms/RoomHeader.tsx     |   6 +-
 .../views/rooms/RoomTileCallSummary.tsx       |   9 +-
 src/components/views/voip/CallDuration.tsx    |  22 +-
 src/components/views/voip/CallView.tsx        |  30 +-
 src/hooks/useCall.ts                          |  51 ++-
 src/i18n/strings/en_EN.json                   |   1 -
 src/models/Call.ts                            | 432 +++++++-----------
 src/stores/CallStore.ts                       |  31 +-
 src/toasts/IncomingCallToast.tsx              |   9 +-
 .../views/messages/CallEvent-test.tsx         |   2 +-
 test/components/views/rooms/RoomTile-test.tsx |  25 +-
 test/components/views/voip/CallView-test.tsx  |  20 +-
 test/createRoom-test.ts                       |   4 +-
 test/models/Call-test.ts                      | 181 +++-----
 test/test-utils/call.ts                       |  17 +-
 test/test-utils/test-utils.ts                 |   5 +-
 test/toasts/IncomingCallToast-test.tsx        |   7 +-
 20 files changed, 383 insertions(+), 567 deletions(-)

diff --git a/src/components/views/beacon/RoomCallBanner.tsx b/src/components/views/beacon/RoomCallBanner.tsx
index 6085fe141b..9c1d92346f 100644
--- a/src/components/views/beacon/RoomCallBanner.tsx
+++ b/src/components/views/beacon/RoomCallBanner.tsx
@@ -15,34 +15,34 @@ limitations under the License.
 */
 
 import React, { useCallback } from "react";
-import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
+import { EventType } from "matrix-js-sdk/src/@types/event";
+import { Room } from "matrix-js-sdk/src/models/room";
+import { logger } from "matrix-js-sdk/src/logger";
 
 import { _t } from "../../../languageHandler";
 import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
 import dispatcher, { defaultDispatcher } from "../../../dispatcher/dispatcher";
 import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
 import { Action } from "../../../dispatcher/actions";
-import { Call, ConnectionState, ElementCall } from "../../../models/Call";
+import { ConnectionState, ElementCall } from "../../../models/Call";
 import { useCall } from "../../../hooks/useCall";
 import { useEventEmitterState } from "../../../hooks/useEventEmitter";
 import {
     OwnBeaconStore,
     OwnBeaconStoreEvent,
 } from "../../../stores/OwnBeaconStore";
-import { CallDurationFromEvent } from "../voip/CallDuration";
+import { GroupCallDuration } from "../voip/CallDuration";
 import { SdkContextClass } from "../../../contexts/SDKContext";
 
 interface RoomCallBannerProps {
     roomId: Room["roomId"];
-    call: Call;
+    call: ElementCall;
 }
 
 const RoomCallBannerInner: React.FC<RoomCallBannerProps> = ({
     roomId,
     call,
 }) => {
-    const callEvent: MatrixEvent | null = (call as ElementCall)?.groupCall;
-
     const connect = useCallback(
         (ev: ButtonEvent) => {
             ev.preventDefault();
@@ -57,15 +57,23 @@ const RoomCallBannerInner: React.FC<RoomCallBannerProps> = ({
     );
 
     const onClick = useCallback(() => {
+        const event = call.groupCall.room.currentState.getStateEvents(
+            EventType.GroupCallPrefix, call.groupCall.groupCallId,
+        );
+        if (event === null) {
+            logger.error("Couldn't find a group call event to jump to");
+            return;
+        }
+
         dispatcher.dispatch<ViewRoomPayload>({
             action: Action.ViewRoom,
             room_id: roomId,
             metricsTrigger: undefined,
-            event_id: callEvent.getId(),
+            event_id: event.getId(),
             scroll_into_view: true,
             highlighted: true,
         });
-    }, [callEvent, roomId]);
+    }, [call, roomId]);
 
     return (
         <div
@@ -74,7 +82,7 @@ const RoomCallBannerInner: React.FC<RoomCallBannerProps> = ({
         >
             <div className="mx_RoomCallBanner_text">
                 <span className="mx_RoomCallBanner_label">{ _t("Video call") }</span>
-                <CallDurationFromEvent mxEvent={callEvent} />
+                <GroupCallDuration groupCall={call.groupCall} />
             </div>
 
             <AccessibleButton
@@ -119,12 +127,11 @@ const RoomCallBanner: React.FC<Props> = ({ roomId }) => {
     }
 
     // Split into outer/inner to avoid watching various parts if there is no call
-    if (call) {
-        // No banner if the call is connected (or connecting/disconnecting)
-        if (call.connectionState !== ConnectionState.Disconnected) return null;
-
-        return <RoomCallBannerInner call={call} roomId={roomId} />;
+    // No banner if the call is connected (or connecting/disconnecting)
+    if (call !== null && call.connectionState === ConnectionState.Disconnected) {
+        return <RoomCallBannerInner call={call as ElementCall} roomId={roomId} />;
     }
+
     return null;
 };
 
diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx
index f3b7988469..867a6b32d6 100644
--- a/src/components/views/messages/CallEvent.tsx
+++ b/src/components/views/messages/CallEvent.tsx
@@ -18,14 +18,13 @@ import React, { forwardRef, useCallback, useContext, useMemo } from "react";
 
 import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
-import { Call, ConnectionState } from "../../../models/Call";
+import { ConnectionState, ElementCall } from "../../../models/Call";
 import { _t } from "../../../languageHandler";
 import {
     useCall,
     useConnectionState,
-    useJoinCallButtonDisabled,
-    useJoinCallButtonTooltip,
-    useParticipants,
+    useJoinCallButtonDisabledTooltip,
+    useParticipatingMembers,
 } from "../../../hooks/useCall";
 import defaultDispatcher from "../../../dispatcher/dispatcher";
 import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
@@ -35,18 +34,18 @@ import MemberAvatar from "../avatars/MemberAvatar";
 import { LiveContentSummary, LiveContentType } from "../rooms/LiveContentSummary";
 import FacePile from "../elements/FacePile";
 import MatrixClientContext from "../../../contexts/MatrixClientContext";
-import { CallDuration, CallDurationFromEvent } from "../voip/CallDuration";
+import { CallDuration, GroupCallDuration } from "../voip/CallDuration";
 import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
 
 const MAX_FACES = 8;
 
 interface ActiveCallEventProps {
     mxEvent: MatrixEvent;
-    participants: Set<RoomMember>;
+    call: ElementCall | null;
+    participatingMembers: RoomMember[];
     buttonText: string;
     buttonKind: string;
-    buttonTooltip?: string;
-    buttonDisabled?: boolean;
+    buttonDisabledTooltip?: string;
     onButtonClick: ((ev: ButtonEvent) => void) | null;
 }
 
@@ -54,19 +53,19 @@ const ActiveCallEvent = forwardRef<any, ActiveCallEventProps>(
     (
         {
             mxEvent,
-            participants,
+            call,
+            participatingMembers,
             buttonText,
             buttonKind,
-            buttonDisabled,
-            buttonTooltip,
+            buttonDisabledTooltip,
             onButtonClick,
         },
         ref,
     ) => {
         const senderName = useMemo(() => mxEvent.sender?.name ?? mxEvent.getSender(), [mxEvent]);
 
-        const facePileMembers = useMemo(() => [...participants].slice(0, MAX_FACES), [participants]);
-        const facePileOverflow = participants.size > facePileMembers.length;
+        const facePileMembers = useMemo(() => participatingMembers.slice(0, MAX_FACES), [participatingMembers]);
+        const facePileOverflow = participatingMembers.length > facePileMembers.length;
 
         return <div className="mx_CallEvent_wrapper" ref={ref}>
             <div className="mx_CallEvent mx_CallEvent_active">
@@ -85,17 +84,17 @@ const ActiveCallEvent = forwardRef<any, ActiveCallEventProps>(
                         type={LiveContentType.Video}
                         text={_t("Video call")}
                         active={false}
-                        participantCount={participants.size}
+                        participantCount={participatingMembers.length}
                     />
                     <FacePile members={facePileMembers} faceSize={24} overflow={facePileOverflow} />
                 </div>
-                <CallDurationFromEvent mxEvent={mxEvent} />
+                { call && <GroupCallDuration groupCall={call.groupCall} /> }
                 <AccessibleTooltipButton
                     className="mx_CallEvent_button"
                     kind={buttonKind}
-                    disabled={onButtonClick === null || buttonDisabled}
+                    disabled={onButtonClick === null || buttonDisabledTooltip !== undefined}
                     onClick={onButtonClick}
-                    tooltip={buttonTooltip}
+                    tooltip={buttonDisabledTooltip}
                 >
                     { buttonText }
                 </AccessibleTooltipButton>
@@ -106,14 +105,13 @@ const ActiveCallEvent = forwardRef<any, ActiveCallEventProps>(
 
 interface ActiveLoadedCallEventProps {
     mxEvent: MatrixEvent;
-    call: Call;
+    call: ElementCall;
 }
 
 const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxEvent, call }, ref) => {
     const connectionState = useConnectionState(call);
-    const participants = useParticipants(call);
-    const joinCallButtonTooltip = useJoinCallButtonTooltip(call);
-    const joinCallButtonDisabled = useJoinCallButtonDisabled(call);
+    const participatingMembers = useParticipatingMembers(call);
+    const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call);
 
     const connect = useCallback((ev: ButtonEvent) => {
         ev.preventDefault();
@@ -142,11 +140,11 @@ const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxE
     return <ActiveCallEvent
         ref={ref}
         mxEvent={mxEvent}
-        participants={participants}
+        call={call}
+        participatingMembers={participatingMembers}
         buttonText={buttonText}
         buttonKind={buttonKind}
-        buttonDisabled={joinCallButtonDisabled}
-        buttonTooltip={joinCallButtonTooltip}
+        buttonDisabledTooltip={joinCallButtonDisabledTooltip ?? undefined}
         onButtonClick={onButtonClick}
     />;
 });
@@ -159,7 +157,6 @@ interface CallEventProps {
  * An event tile representing an active or historical Element call.
  */
 export const CallEvent = forwardRef<any, CallEventProps>(({ mxEvent }, ref) => {
-    const noParticipants = useMemo(() => new Set<RoomMember>(), []);
     const client = useContext(MatrixClientContext);
     const call = useCall(mxEvent.getRoomId()!);
     const latestEvent = client.getRoom(mxEvent.getRoomId())!.currentState
@@ -180,12 +177,13 @@ export const CallEvent = forwardRef<any, CallEventProps>(({ mxEvent }, ref) => {
         return <ActiveCallEvent
             ref={ref}
             mxEvent={mxEvent}
-            participants={noParticipants}
+            call={null}
+            participatingMembers={[]}
             buttonText={_t("Join")}
             buttonKind="primary"
             onButtonClick={null}
         />;
     }
 
-    return <ActiveLoadedCallEvent mxEvent={mxEvent} call={call} ref={ref} />;
+    return <ActiveLoadedCallEvent mxEvent={mxEvent} call={call as ElementCall} ref={ref} />;
 });
diff --git a/src/components/views/rooms/LiveContentSummary.tsx b/src/components/views/rooms/LiveContentSummary.tsx
index 34ee825268..ff4da6979e 100644
--- a/src/components/views/rooms/LiveContentSummary.tsx
+++ b/src/components/views/rooms/LiveContentSummary.tsx
@@ -19,7 +19,7 @@ import classNames from "classnames";
 
 import { _t } from "../../../languageHandler";
 import { Call } from "../../../models/Call";
-import { useParticipants } from "../../../hooks/useCall";
+import { useParticipantCount } from "../../../hooks/useCall";
 
 export enum LiveContentType {
     Video,
@@ -62,13 +62,10 @@ interface LiveContentSummaryWithCallProps {
     call: Call;
 }
 
-export function LiveContentSummaryWithCall({ call }: LiveContentSummaryWithCallProps) {
-    const participants = useParticipants(call);
-
-    return <LiveContentSummary
+export const LiveContentSummaryWithCall: FC<LiveContentSummaryWithCallProps> = ({ call }) =>
+    <LiveContentSummary
         type={LiveContentType.Video}
         text={_t("Video")}
         active={false}
-        participantCount={participants.size}
+        participantCount={useParticipantCount(call)}
     />;
-}
diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx
index 87ed74198c..644c35232e 100644
--- a/src/components/views/rooms/RoomHeader.tsx
+++ b/src/components/views/rooms/RoomHeader.tsx
@@ -66,7 +66,7 @@ import IconizedContextMenu, {
     IconizedContextMenuRadio,
 } from "../context_menus/IconizedContextMenu";
 import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
-import { CallDurationFromEvent } from "../voip/CallDuration";
+import { GroupCallDuration } from "../voip/CallDuration";
 import { Alignment } from "../elements/Tooltip";
 import RoomCallBanner from '../beacon/RoomCallBanner';
 
@@ -512,7 +512,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
         }
 
         if (this.props.viewingCall && this.props.activeCall instanceof ElementCall) {
-            startButtons.push(<CallLayoutSelector call={this.props.activeCall} />);
+            startButtons.push(<CallLayoutSelector key="layout" call={this.props.activeCall} />);
         }
 
         if (!this.props.viewingCall && this.props.onForgetClick) {
@@ -685,7 +685,7 @@ export default class RoomHeader extends React.Component<IProps, IState> {
                             { _t("Video call") }
                         </div>
                         { this.props.activeCall instanceof ElementCall && (
-                            <CallDurationFromEvent mxEvent={this.props.activeCall.groupCall} />
+                            <GroupCallDuration groupCall={this.props.activeCall.groupCall} />
                         ) }
                         { /* Empty topic element to fill out space */ }
                         <div className="mx_RoomHeader_topic" />
diff --git a/src/components/views/rooms/RoomTileCallSummary.tsx b/src/components/views/rooms/RoomTileCallSummary.tsx
index 717ab5e36f..f5a6f651c6 100644
--- a/src/components/views/rooms/RoomTileCallSummary.tsx
+++ b/src/components/views/rooms/RoomTileCallSummary.tsx
@@ -18,7 +18,7 @@ import React, { FC } from "react";
 
 import type { Call } from "../../../models/Call";
 import { _t } from "../../../languageHandler";
-import { useConnectionState, useParticipants } from "../../../hooks/useCall";
+import { useConnectionState, useParticipantCount } from "../../../hooks/useCall";
 import { ConnectionState } from "../../../models/Call";
 import { LiveContentSummary, LiveContentType } from "./LiveContentSummary";
 
@@ -27,13 +27,10 @@ interface Props {
 }
 
 export const RoomTileCallSummary: FC<Props> = ({ call }) => {
-    const connectionState = useConnectionState(call);
-    const participants = useParticipants(call);
-
     let text: string;
     let active: boolean;
 
-    switch (connectionState) {
+    switch (useConnectionState(call)) {
         case ConnectionState.Disconnected:
             text = _t("Video");
             active = false;
@@ -53,6 +50,6 @@ export const RoomTileCallSummary: FC<Props> = ({ call }) => {
         type={LiveContentType.Video}
         text={text}
         active={active}
-        participantCount={participants.size}
+        participantCount={useParticipantCount(call)}
     />;
 };
diff --git a/src/components/views/voip/CallDuration.tsx b/src/components/views/voip/CallDuration.tsx
index 38b30038ea..2965f6265b 100644
--- a/src/components/views/voip/CallDuration.tsx
+++ b/src/components/views/voip/CallDuration.tsx
@@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import React, { FC, useState, useEffect } from "react";
+import React, { FC, useState, useEffect, memo } from "react";
+import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
 
-import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import { formatCallTime } from "../../../DateUtils";
 
 interface CallDurationProps {
@@ -26,26 +26,28 @@ interface CallDurationProps {
 /**
  * A call duration counter.
  */
-export const CallDuration: FC<CallDurationProps> = ({ delta }) => {
+export const CallDuration: FC<CallDurationProps> = memo(({ delta }) => {
     // Clock desync could lead to a negative duration, so just hide it if that happens
     if (delta <= 0) return null;
     return <div className="mx_CallDuration">{ formatCallTime(new Date(delta)) }</div>;
-};
+});
 
-interface CallDurationFromEventProps {
-    mxEvent: MatrixEvent;
+interface GroupCallDurationProps {
+    groupCall: GroupCall;
 }
 
 /**
- * A call duration counter that automatically counts up, given the event that
- * started the call.
+ * A call duration counter that automatically counts up, given a live GroupCall
+ * object.
  */
-export const CallDurationFromEvent: FC<CallDurationFromEventProps> = ({ mxEvent }) => {
+export const GroupCallDuration: FC<GroupCallDurationProps> = ({ groupCall }) => {
     const [now, setNow] = useState(() => Date.now());
     useEffect(() => {
         const timer = setInterval(() => setNow(Date.now()), 1000);
         return () => clearInterval(timer);
     }, []);
 
-    return <CallDuration delta={now - mxEvent.getTs()} />;
+    return groupCall.creationTs === null
+        ? null
+        : <CallDuration delta={now - groupCall.creationTs} />;
 };
diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx
index f003fdc6ca..f8f34144ed 100644
--- a/src/components/views/voip/CallView.tsx
+++ b/src/components/views/voip/CallView.tsx
@@ -25,9 +25,8 @@ import { Call, CallEvent, ElementCall, isConnected } from "../../../models/Call"
 import {
     useCall,
     useConnectionState,
-    useJoinCallButtonDisabled,
-    useJoinCallButtonTooltip,
-    useParticipants,
+    useJoinCallButtonDisabledTooltip,
+    useParticipatingMembers,
 } from "../../../hooks/useCall";
 import MatrixClientContext from "../../../contexts/MatrixClientContext";
 import AppTile from "../elements/AppTile";
@@ -116,12 +115,11 @@ const MAX_FACES = 8;
 interface LobbyProps {
     room: Room;
     connect: () => Promise<void>;
-    joinCallButtonTooltip?: string;
-    joinCallButtonDisabled?: boolean;
+    joinCallButtonDisabledTooltip?: string;
     children?: ReactNode;
 }
 
-export const Lobby: FC<LobbyProps> = ({ room, joinCallButtonDisabled, joinCallButtonTooltip, connect, children }) => {
+export const Lobby: FC<LobbyProps> = ({ room, joinCallButtonDisabledTooltip, connect, children }) => {
     const [connecting, setConnecting] = useState(false);
     const me = useMemo(() => room.getMember(room.myUserId)!, [room]);
     const videoRef = useRef<HTMLVideoElement>(null);
@@ -246,10 +244,10 @@ export const Lobby: FC<LobbyProps> = ({ room, joinCallButtonDisabled, joinCallBu
         <AccessibleTooltipButton
             className="mx_CallView_connectButton"
             kind="primary"
-            disabled={connecting || joinCallButtonDisabled}
+            disabled={connecting || joinCallButtonDisabledTooltip !== undefined}
             onClick={onConnectClick}
             label={_t("Join")}
-            tooltip={connecting ? _t("Connecting") : joinCallButtonTooltip}
+            tooltip={connecting ? _t("Connecting") : joinCallButtonDisabledTooltip}
             alignment={Alignment.Bottom}
         />
     </div>;
@@ -331,9 +329,8 @@ interface JoinCallViewProps {
 const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call }) => {
     const cli = useContext(MatrixClientContext);
     const connected = isConnected(useConnectionState(call));
-    const participants = useParticipants(call);
-    const joinCallButtonTooltip = useJoinCallButtonTooltip(call);
-    const joinCallButtonDisabled = useJoinCallButtonDisabled(call);
+    const members = useParticipatingMembers(call);
+    const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call);
 
     const connect = useCallback(async () => {
         // Disconnect from any other active calls first, since we don't yet support holding
@@ -347,12 +344,12 @@ const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call }) => {
     let lobby: JSX.Element | null = null;
     if (!connected) {
         let facePile: JSX.Element | null = null;
-        if (participants.size) {
-            const shownMembers = [...participants].slice(0, MAX_FACES);
-            const overflow = participants.size > shownMembers.length;
+        if (members.length) {
+            const shownMembers = members.slice(0, MAX_FACES);
+            const overflow = members.length > shownMembers.length;
 
             facePile = <div className="mx_CallView_participants">
-                { _t("%(count)s people joined", { count: participants.size }) }
+                { _t("%(count)s people joined", { count: members.length }) }
                 <FacePile members={shownMembers} faceSize={24} overflow={overflow} />
             </div>;
         }
@@ -360,8 +357,7 @@ const JoinCallView: FC<JoinCallViewProps> = ({ room, resizing, call }) => {
         lobby = <Lobby
             room={room}
             connect={connect}
-            joinCallButtonTooltip={joinCallButtonTooltip ?? undefined}
-            joinCallButtonDisabled={joinCallButtonDisabled}
+            joinCallButtonDisabledTooltip={joinCallButtonDisabledTooltip ?? undefined}
         >
             { facePile }
         </Lobby>;
diff --git a/src/hooks/useCall.ts b/src/hooks/useCall.ts
index cf9bbee0d0..3e83703d3c 100644
--- a/src/hooks/useCall.ts
+++ b/src/hooks/useCall.ts
@@ -24,7 +24,6 @@ import { CallStore, CallStoreEvent } from "../stores/CallStore";
 import { useEventEmitter } from "./useEventEmitter";
 import SdkConfig, { DEFAULTS } from "../SdkConfig";
 import { _t } from "../languageHandler";
-import { MatrixClientPeg } from "../MatrixClientPeg";
 
 export const useCall = (roomId: string): Call | null => {
     const [call, setCall] = useState(() => CallStore.instance.getCall(roomId));
@@ -41,49 +40,51 @@ export const useConnectionState = (call: Call): ConnectionState =>
         useCallback(state => state ?? call.connectionState, [call]),
     );
 
-export const useParticipants = (call: Call): Set<RoomMember> =>
+export const useParticipants = (call: Call): Map<RoomMember, Set<string>> =>
     useTypedEventEmitterState(
         call,
         CallEvent.Participants,
         useCallback(state => state ?? call.participants, [call]),
     );
 
-export const useFull = (call: Call): boolean => {
-    const participants = useParticipants(call);
-
-    return (
-        participants.size
-        >= (SdkConfig.get("element_call").participant_limit ?? DEFAULTS.element_call.participant_limit)
-    );
-};
-
-export const useIsAlreadyParticipant = (call: Call): boolean => {
-    const client = MatrixClientPeg.get();
+export const useParticipantCount = (call: Call): number => {
     const participants = useParticipants(call);
 
     return useMemo(() => {
-        return participants.has(client.getRoom(call.roomId).getMember(client.getUserId()));
-    }, [participants, client, call]);
+        let count = 0;
+        for (const devices of participants.values()) count += devices.size;
+        return count;
+    }, [participants]);
 };
 
-export const useJoinCallButtonTooltip = (call: Call): string | null => {
+export const useParticipatingMembers = (call: Call): RoomMember[] => {
+    const participants = useParticipants(call);
+
+    return useMemo(() => {
+        const members: RoomMember[] = [];
+        for (const [member, devices] of participants) {
+            // Repeat the member for as many devices as they're using
+            for (let i = 0; i < devices.size; i++) members.push(member);
+        }
+        return members;
+    }, [participants]);
+};
+
+export const useFull = (call: Call): boolean => {
+    return useParticipantCount(call) >= (
+        SdkConfig.get("element_call").participant_limit ?? DEFAULTS.element_call.participant_limit!
+    );
+};
+
+export const useJoinCallButtonDisabledTooltip = (call: Call): string | null => {
     const isFull = useFull(call);
     const state = useConnectionState(call);
-    const isAlreadyParticipant = useIsAlreadyParticipant(call);
 
     if (state === ConnectionState.Connecting) return _t("Connecting");
     if (isFull) return _t("Sorry — this call is currently full");
-    if (isAlreadyParticipant) return _t("You have already joined this call from another device");
     return null;
 };
 
-export const useJoinCallButtonDisabled = (call: Call): boolean => {
-    const isFull = useFull(call);
-    const state = useConnectionState(call);
-
-    return isFull || state === ConnectionState.Connecting;
-};
-
 export const useLayout = (call: ElementCall): Layout =>
     useTypedEventEmitterState(
         call,
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 9c6b057945..376133905d 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1042,7 +1042,6 @@
     "This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!",
     "Connecting": "Connecting",
     "Sorry — this call is currently full": "Sorry — this call is currently full",
-    "You have already joined this call from another device": "You have already joined this call from another device",
     "Create account": "Create account",
     "You made it!": "You made it!",
     "Find and invite your friends": "Find and invite your friends",
diff --git a/src/models/Call.ts b/src/models/Call.ts
index 4276e4f973..0e20c331fb 100644
--- a/src/models/Call.ts
+++ b/src/models/Call.ts
@@ -17,13 +17,20 @@ 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 { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
+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 { NamespacedValue } from "matrix-js-sdk/src/NamespacedValue";
 import { IWidgetApiRequest, MatrixWidgetType } from "matrix-widget-api";
-import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
+import {
+    GroupCall,
+    GroupCallEvent,
+    GroupCallIntent,
+    GroupCallState,
+    GroupCallType,
+} from "matrix-js-sdk/src/webrtc/groupCall";
+import { EventType } from "matrix-js-sdk/src/@types/event";
 
 import type EventEmitter from "events";
 import type { IMyDevice } from "matrix-js-sdk/src/client";
@@ -89,7 +96,10 @@ export enum CallEvent {
 
 interface CallEventHandlerMap {
     [CallEvent.ConnectionState]: (state: ConnectionState, prevState: ConnectionState) => void;
-    [CallEvent.Participants]: (participants: Set<RoomMember>, prevParticipants: Set<RoomMember>) => void;
+    [CallEvent.Participants]: (
+        participants: Map<RoomMember, Set<string>>,
+        prevParticipants: Map<RoomMember, Set<string>>,
+    ) => void;
     [CallEvent.Layout]: (layout: Layout) => void;
     [CallEvent.Destroy]: () => void;
 }
@@ -135,11 +145,14 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
         return isConnected(this.connectionState);
     }
 
-    private _participants = new Set<RoomMember>();
-    public get participants(): Set<RoomMember> {
+    private _participants = new Map<RoomMember, Set<string>>();
+    /**
+     * The participants in the call, as a map from members to device IDs.
+     */
+    public get participants(): Map<RoomMember, Set<string>> {
         return this._participants;
     }
-    protected set participants(value: Set<RoomMember>) {
+    protected set participants(value: Map<RoomMember, Set<string>>) {
         const prevValue = this._participants;
         this._participants = value;
         this.emit(CallEvent.Participants, value, prevValue);
@@ -164,68 +177,11 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
         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 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);
-        });
-    }
+    public abstract clean(): Promise<void>;
 
     /**
      * Contacts the widget to connect to the call.
@@ -384,7 +340,7 @@ export class JitsiCall extends Call {
             this.participantsExpirationTimer = null;
         }
 
-        const members = new Set<RoomMember>();
+        const participants = new Map<RoomMember, Set<string>>();
         const now = Date.now();
         let allExpireAt = Infinity;
 
@@ -392,44 +348,95 @@ export class JitsiCall extends Call {
             const member = this.room.getMember(e.getStateKey()!);
             const content = e.getContent<JitsiCallMemberContent>();
             const expiresAt = typeof content.expires_ts === "number" ? content.expires_ts : -Infinity;
-            let devices = expiresAt > now && Array.isArray(content.devices) ? content.devices : [];
+            let devices = expiresAt > now && Array.isArray(content.devices)
+                ? content.devices.filter(d => typeof d === "string")
+                : [];
 
             // 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 and still be joined to the room
-            if (devices.length && member?.membership === "join") {
-                members.add(member);
+            if (devices.length > 0 && member?.membership === "join") {
+                participants.set(member, new Set(devices));
                 if (expiresAt < allExpireAt) allExpireAt = expiresAt;
             }
         }
 
         // Apply local echo for the connected case
-        if (this.connected) members.add(this.room.getMember(this.client.getUserId()!)!);
+        if (this.connected) {
+            const localMember = this.room.getMember(this.client.getUserId()!)!;
+            let devices = participants.get(localMember);
+            if (devices === undefined) {
+                devices = new Set();
+                participants.set(localMember, devices);
+            }
 
-        this.participants = members;
+            devices.add(this.client.getDeviceId()!);
+        }
+
+        this.participants = participants;
         if (allExpireAt < Infinity) {
             this.participantsExpirationTimer = setTimeout(() => this.updateParticipants(), allExpireAt - now);
         }
     }
 
-    protected getDevices(userId: string): string[] {
-        const event = this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, userId);
+    /**
+     * 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.
+     */
+    private async updateDevices(fn: (devices: string[]) => (string[] | null)): Promise<void> {
+        if (this.room.getMyMembership() !== "join") return;
+
+        const event = this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, this.client.getUserId()!);
         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 devices = expiresAt > Date.now() && Array.isArray(content?.devices) ? content!.devices : [];
+        const newDevices = fn(devices);
+
+        if (newDevices !== null) {
+            const newContent: JitsiCallMemberContent = {
+                devices: newDevices,
+                expires_ts: Date.now() + this.STUCK_DEVICE_TIMEOUT_MS,
+            };
+
+            await this.client.sendStateEvent(
+                this.roomId, JitsiCall.MEMBER_EVENT_TYPE, newContent, this.client.getUserId()!,
+            );
+        }
     }
 
-    protected async setDevices(devices: string[]): Promise<void> {
-        const content: JitsiCallMemberContent = {
-            devices,
-            expires_ts: Date.now() + this.STUCK_DEVICE_TIMEOUT_MS,
-        };
+    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]));
 
-        await this.client.sendStateEvent(
-            this.roomId, JitsiCall.MEMBER_EVENT_TYPE, content, this.client.getUserId()!,
-        );
+        // 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;
+        });
+    }
+
+    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);
+        });
     }
 
     protected async performConnection(
@@ -591,31 +598,15 @@ export class JitsiCall extends Call {
     };
 }
 
-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 static readonly DUPLICATE_CALL_DEVICE_EVENT_TYPE = "io.element.duplicate_call_device";
+    public static readonly CALL_EVENT_TYPE = new NamespacedValue(null, EventType.GroupCallPrefix);
+    public static readonly MEMBER_EVENT_TYPE = new NamespacedValue(null, EventType.GroupCallMemberPrefix);
     public readonly STUCK_DEVICE_TIMEOUT_MS = 1000 * 60 * 60; // 1 hour
 
-    private kickedOutByAnotherDevice = false;
-    private connectionTime: number | null = null;
-    private participantsExpirationTimer: number | null = null;
     private terminationTimer: number | null = null;
 
     private _layout = Layout.Tile;
@@ -627,7 +618,7 @@ export class ElementCall extends Call {
         this.emit(CallEvent.Layout, value);
     }
 
-    private constructor(public readonly groupCall: MatrixEvent, client: MatrixClient) {
+    private constructor(public readonly groupCall: GroupCall, client: MatrixClient) {
         // Splice together the Element Call URL for this call
         const url = new URL(SdkConfig.get("element_call").url ?? DEFAULTS.element_call.url!);
         url.pathname = "/room";
@@ -636,8 +627,8 @@ export class ElementCall extends Call {
             preload: "",
             hideHeader: "",
             userId: client.getUserId()!,
-            deviceId: client.getDeviceId(),
-            roomId: groupCall.getRoomId()!,
+            deviceId: client.getDeviceId()!,
+            roomId: groupCall.room.roomId,
             baseUrl: client.baseUrl,
             lang: getCurrentLanguage().replace("_", "-"),
         });
@@ -652,14 +643,15 @@ export class ElementCall extends Call {
                 name: "Element Call",
                 type: MatrixWidgetType.Custom,
                 url: url.toString(),
-            }, groupCall.getRoomId()!),
+            }, groupCall.room.roomId),
             client,
         );
 
-        this.groupCall.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
-        this.room.on(RoomStateEvent.Update, this.onRoomState);
         this.on(CallEvent.ConnectionState, this.onConnectionState);
         this.on(CallEvent.Participants, this.onParticipants);
+        groupCall.on(GroupCallEvent.ParticipantsChanged, this.onGroupCallParticipants);
+        groupCall.on(GroupCallEvent.GroupCallStateChanged, this.onGroupCallState);
+
         this.updateParticipants();
     }
 
@@ -672,22 +664,8 @@ export class ElementCall extends Call {
                 && 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);
+            const groupCall = room.client.groupCallEventHandler!.groupCalls.get(room.roomId);
+            if (groupCall !== undefined) return new ElementCall(groupCall, room.client);
         }
 
         return null;
@@ -698,113 +676,25 @@ export class ElementCall extends Call {
             && SettingsStore.getValue("feature_element_call_video_rooms")
             && room.isCallRoom();
 
-        await room.client.sendStateEvent(room.roomId, ElementCall.CALL_EVENT_TYPE.name, {
-            "m.intent": isVideoRoom ? "m.room" : "m.prompt",
-            "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),
+        const groupCall = new GroupCall(
+            room.client,
+            room,
+            GroupCallType.Video,
+            false,
+            isVideoRoom ? GroupCallIntent.Room : GroupCallIntent.Prompt,
         );
 
-        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);
-        }
+        await groupCall.create();
     }
 
-    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()!,
-        );
+    public clean(): Promise<void> {
+        return this.groupCall.cleanMemberState();
     }
 
     protected async performConnection(
         audioInput: MediaDeviceInfo | null,
         videoInput: MediaDeviceInfo | null,
     ): Promise<void> {
-        this.kickedOutByAnotherDevice = false;
-        this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
-
-        this.connectionTime = Date.now();
-        await this.client.sendToDevice(ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE, {
-            [this.client.getUserId()]: {
-                "*": { device_id: this.client.getDeviceId(), timestamp: this.connectionTime },
-            },
-        });
-
         try {
             await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
                 audioInput: audioInput?.label ?? null,
@@ -829,7 +719,6 @@ export class ElementCall extends Call {
     }
 
     public setDisconnected() {
-        this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
         this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
         this.messaging!.off(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout);
         this.messaging!.off(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout);
@@ -838,16 +727,12 @@ export class ElementCall extends Call {
     }
 
     public destroy() {
-        this.groupCall.off(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
-        WidgetStore.instance.removeVirtualWidget(this.widget.id, this.groupCall.getRoomId()!);
-        this.room.off(RoomStateEvent.Update, this.onRoomState);
+        WidgetStore.instance.removeVirtualWidget(this.widget.id, this.groupCall.room.roomId);
         this.off(CallEvent.ConnectionState, this.onConnectionState);
         this.off(CallEvent.Participants, this.onParticipants);
+        this.groupCall.off(GroupCallEvent.ParticipantsChanged, this.onGroupCallParticipants);
+        this.groupCall.off(GroupCallEvent.GroupCallStateChanged, this.onGroupCallState);
 
-        if (this.participantsExpirationTimer !== null) {
-            clearTimeout(this.participantsExpirationTimer);
-            this.participantsExpirationTimer = null;
-        }
         if (this.terminationTimer !== null) {
             clearTimeout(this.terminationTimer);
             this.terminationTimer = null;
@@ -868,50 +753,35 @@ export class ElementCall extends Call {
         await this.messaging!.transport.send(action, {});
     }
 
+    private updateParticipants() {
+        const participants = new Map<RoomMember, Set<string>>();
+
+        for (const [member, deviceMap] of this.groupCall.participants) {
+            participants.set(member, new Set(deviceMap.keys()));
+        }
+
+        // We never enter group calls natively, so the GroupCall will think it's
+        // disconnected regardless of what our call member state says. Thus we
+        // have to insert our own device manually when connected via the widget.
+        if (this.connected) {
+            const localMember = this.room.getMember(this.client.getUserId()!)!;
+            let devices = participants.get(localMember);
+            if (devices === undefined) {
+                devices = new Set();
+                participants.set(localMember, devices);
+            }
+
+            devices.add(this.client.getDeviceId()!);
+        }
+
+        this.participants = participants;
+    }
+
     private get mayTerminate(): boolean {
-        if (this.kickedOutByAnotherDevice) return false;
-        if (this.groupCall.getContent()["m.intent"] === "m.room") return false;
-        if (
-            !this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client)
-        ) return false;
-
-        return true;
+        return this.groupCall.intent !== GroupCallIntent.Room
+            && this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client);
     }
 
-    private async terminate(): Promise<void> {
-        await this.client.sendStateEvent(
-            this.roomId,
-            ElementCall.CALL_EVENT_TYPE.name,
-            { ...this.groupCall.getContent(), "m.terminated": "Call ended" },
-            this.groupCall.getStateKey(),
-        );
-    }
-
-    private onBeforeRedaction = (): void => {
-        this.disconnect();
-    };
-
-    private onRoomState = () => {
-        this.updateParticipants();
-
-        // Destroy the call if it's been terminated
-        const newGroupCall = this.room.currentState.getStateEvents(
-            this.groupCall.getType(), this.groupCall.getStateKey()!,
-        );
-        if ("m.terminated" in newGroupCall.getContent()) this.destroy();
-    };
-
-    private onToDeviceEvent = (event: MatrixEvent): void => {
-        const content = event.getContent();
-        if (event.getType() !== ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE) return;
-        if (event.getSender() !== this.client.getUserId()) return;
-        if (content.device_id === this.client.getDeviceId()) return;
-        if (content.timestamp <= this.connectionTime) return;
-
-        this.kickedOutByAnotherDevice = true;
-        this.disconnect();
-    };
-
     private onConnectionState = (state: ConnectionState, prevState: ConnectionState) => {
         if (
             (state === ConnectionState.Connected && !isConnected(prevState))
@@ -921,12 +791,21 @@ export class ElementCall extends Call {
         }
     };
 
-    private onParticipants = async (participants: Set<RoomMember>, prevParticipants: Set<RoomMember>) => {
+    private onParticipants = async (
+        participants: Map<RoomMember, Set<string>>,
+        prevParticipants: Map<RoomMember, Set<string>>,
+    ) => {
+        let participantCount = 0;
+        for (const devices of participants.values()) participantCount += devices.size;
+
+        let prevParticipantCount = 0;
+        for (const devices of prevParticipants.values()) prevParticipantCount += devices.size;
+
         // If the last participant disconnected, terminate the call
-        if (participants.size === 0 && prevParticipants.size > 0 && this.mayTerminate) {
-            if (prevParticipants.has(this.room.getMember(this.client.getUserId()!)!)) {
+        if (participantCount === 0 && prevParticipantCount > 0 && this.mayTerminate) {
+            if (prevParticipants.get(this.room.getMember(this.client.getUserId()!)!)?.has(this.client.getDeviceId()!)) {
                 // If we were that last participant, do the termination ourselves
-                await this.terminate();
+                await this.groupCall.terminate();
             } else {
                 // We don't appear to have been the last participant, but because of
                 // the potential for races, users lacking permission, and a myriad of
@@ -935,11 +814,20 @@ export class ElementCall extends Call {
                 // randomly between 2 and 8 seconds before terminating the call, to
                 // probabilistically reduce event spam. If someone else beats us to it,
                 // this timer will be automatically cleared upon the call's destruction.
-                this.terminationTimer = setTimeout(() => this.terminate(), Math.random() * 6000 + 2000);
+                this.terminationTimer = setTimeout(
+                    () => this.groupCall.terminate(),
+                    Math.random() * 6000 + 2000,
+                );
             }
         }
     };
 
+    private onGroupCallParticipants = () => this.updateParticipants();
+
+    private onGroupCallState = (state: GroupCallState) => {
+        if (state === GroupCallState.Ended) this.destroy();
+    };
+
     private onHangup = async (ev: CustomEvent<IWidgetApiRequest>) => {
         ev.preventDefault();
         await this.messaging!.transport.reply(ev.detail, {}); // ack
diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts
index abfb6b54af..d6f16bcf8c 100644
--- a/src/stores/CallStore.ts
+++ b/src/stores/CallStore.ts
@@ -15,12 +15,10 @@ limitations under the License.
 */
 
 import { logger } from "matrix-js-sdk/src/logger";
-import { ClientEvent } from "matrix-js-sdk/src/client";
-import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
+import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler";
 
-import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
 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 { UPDATE_EVENT } from "./AsyncStore";
 import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
@@ -56,13 +54,13 @@ export class CallStore extends AsyncStoreWithClient<{}> {
 
     protected async onReady(): Promise<any> {
         // We assume that the calls present in a room are a function of room
-        // state and room widgets, so we initialize the room map here and then
+        // widgets and group calls, so we initialize the room map here and then
         // update it whenever those change
         for (const room of this.matrixClient.getRooms()) {
             this.updateRoom(room);
         }
-        this.matrixClient.on(ClientEvent.Room, this.onRoom);
-        this.matrixClient.on(RoomStateEvent.Events, this.onRoomState);
+        this.matrixClient.on(GroupCallEventHandlerEvent.Incoming, this.onGroupCall);
+        this.matrixClient.on(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall);
         WidgetStore.instance.on(UPDATE_EVENT, this.onWidgets);
 
         // If the room ID of a previously connected call is still in settings at
@@ -92,8 +90,9 @@ export class CallStore extends AsyncStoreWithClient<{}> {
         this.calls.clear();
         this._activeCalls.clear();
 
-        this.matrixClient.off(ClientEvent.Room, this.onRoom);
-        this.matrixClient.off(RoomStateEvent.Events, this.onRoomState);
+        this.matrixClient.off(GroupCallEventHandlerEvent.Incoming, this.onGroupCall);
+        this.matrixClient.off(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall);
+        this.matrixClient.off(GroupCallEventHandlerEvent.Ended, this.onGroupCall);
         WidgetStore.instance.off(UPDATE_EVENT, this.onWidgets);
     }
 
@@ -166,18 +165,6 @@ export class CallStore extends AsyncStoreWithClient<{}> {
         return call !== null && this.activeCalls.has(call) ? call : null;
     }
 
-    private onRoom = (room: Room) => this.updateRoom(room);
-
-    private onRoomState = (event: MatrixEvent, state: RoomState) => {
-        // If there's already a call stored for this room, it's understood to
-        // still be valid until destroyed
-        if (!this.calls.has(state.roomId)) {
-            const room = this.matrixClient.getRoom(state.roomId);
-            // State events can arrive before the room does, when creating a room
-            if (room !== null) this.updateRoom(room);
-        }
-    };
-
     private onWidgets = (roomId: string | null) => {
         if (roomId === null) {
             // This store happened to start before the widget store was done
@@ -191,4 +178,6 @@ export class CallStore extends AsyncStoreWithClient<{}> {
             if (room !== null) this.updateRoom(room);
         }
     };
+
+    private onGroupCall = (groupCall: GroupCall) => this.updateRoom(groupCall.room);
 }
diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx
index c5e363089b..d0a20d5bcb 100644
--- a/src/toasts/IncomingCallToast.tsx
+++ b/src/toasts/IncomingCallToast.tsx
@@ -30,7 +30,7 @@ import {
     LiveContentSummaryWithCall,
     LiveContentType,
 } from "../components/views/rooms/LiveContentSummary";
-import { useCall, useJoinCallButtonDisabled, useJoinCallButtonTooltip } from "../hooks/useCall";
+import { useCall, useJoinCallButtonDisabledTooltip } from "../hooks/useCall";
 import { useRoomState } from "../hooks/useRoomState";
 import { ButtonEvent } from "../components/views/elements/AccessibleButton";
 import { useDispatcher } from "../hooks/useDispatcher";
@@ -45,14 +45,13 @@ interface JoinCallButtonWithCallProps {
 }
 
 function JoinCallButtonWithCall({ onClick, call }: JoinCallButtonWithCallProps) {
-    const tooltip = useJoinCallButtonTooltip(call);
-    const disabled = useJoinCallButtonDisabled(call);
+    const disabledTooltip = useJoinCallButtonDisabledTooltip(call);
 
     return <AccessibleTooltipButton
         className="mx_IncomingCallToast_joinButton"
         onClick={onClick}
-        disabled={disabled}
-        tooltip={tooltip}
+        disabled={disabledTooltip !== null}
+        tooltip={disabledTooltip}
         kind="primary"
     >
         { _t("Join") }
diff --git a/test/components/views/messages/CallEvent-test.tsx b/test/components/views/messages/CallEvent-test.tsx
index 04ca0a4bf7..23b7a978a1 100644
--- a/test/components/views/messages/CallEvent-test.tsx
+++ b/test/components/views/messages/CallEvent-test.tsx
@@ -121,7 +121,7 @@ describe("CallEvent", () => {
 
     it("shows call details and connection controls if the call is loaded", async () => {
         jest.advanceTimersByTime(90000);
-        call.participants = new Set([alice, bob]);
+        call.participants = new Map([[alice, new Set(["a"])], [bob, new Set(["b"])]]);
         renderEvent();
 
         screen.getByText("@alice:example.org started a video call");
diff --git a/test/components/views/rooms/RoomTile-test.tsx b/test/components/views/rooms/RoomTile-test.tsx
index 7ee9cf11df..cf1ae59d09 100644
--- a/test/components/views/rooms/RoomTile-test.tsx
+++ b/test/components/views/rooms/RoomTile-test.tsx
@@ -22,6 +22,7 @@ import { Room } 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 { RoomMember } from "matrix-js-sdk/src/models/room-member";
 import type { ClientWidgetApi } from "matrix-widget-api";
 import {
     stubClient,
@@ -74,7 +75,9 @@ describe("RoomTile", () => {
             setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
 
             MockedCall.create(room, "1");
-            call = CallStore.instance.getCall(room.roomId) as MockedCall;
+            const maybeCall = CallStore.instance.getCall(room.roomId);
+            if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call");
+            call = maybeCall;
 
             widget = new Widget(call.widget);
             WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
@@ -123,19 +126,25 @@ describe("RoomTile", () => {
         });
 
         it("tracks participants", () => {
-            const alice = mkRoomMember(room.roomId, "@alice:example.org");
-            const bob = mkRoomMember(room.roomId, "@bob:example.org");
-            const carol = mkRoomMember(room.roomId, "@carol:example.org");
+            const alice: [RoomMember, Set<string>] = [
+                mkRoomMember(room.roomId, "@alice:example.org"), new Set(["a"]),
+            ];
+            const bob: [RoomMember, Set<string>] = [
+                mkRoomMember(room.roomId, "@bob:example.org"), new Set(["b1", "b2"]),
+            ];
+            const carol: [RoomMember, Set<string>] = [
+                mkRoomMember(room.roomId, "@carol:example.org"), new Set(["c"]),
+            ];
 
             expect(screen.queryByLabelText(/participant/)).toBe(null);
 
-            act(() => { call.participants = new Set([alice]); });
+            act(() => { call.participants = new Map([alice]); });
             expect(screen.getByLabelText("1 participant").textContent).toBe("1");
 
-            act(() => { call.participants = new Set([alice, bob, carol]); });
-            expect(screen.getByLabelText("3 participants").textContent).toBe("3");
+            act(() => { call.participants = new Map([alice, bob, carol]); });
+            expect(screen.getByLabelText("4 participants").textContent).toBe("4");
 
-            act(() => { call.participants = new Set(); });
+            act(() => { call.participants = new Map(); });
             expect(screen.queryByLabelText(/participant/)).toBe(null);
         });
     });
diff --git a/test/components/views/voip/CallView-test.tsx b/test/components/views/voip/CallView-test.tsx
index 0be81c9040..40d19c2fec 100644
--- a/test/components/views/voip/CallView-test.tsx
+++ b/test/components/views/voip/CallView-test.tsx
@@ -131,7 +131,7 @@ describe("CallLobby", () => {
 
                 for (const [userId, avatar] of zip(userIds, avatars)) {
                     fireEvent.focus(avatar!);
-                    screen.getByRole("tooltip", { name: userId });
+                    screen.getAllByRole("tooltip", { name: userId });
                 }
             };
 
@@ -139,15 +139,21 @@ describe("CallLobby", () => {
             expect(screen.queryByLabelText(/joined/)).toBe(null);
             expectAvatars([]);
 
-            act(() => { call.participants = new Set([alice]); });
+            act(() => { call.participants = new Map([[alice, new Set(["a"])]]); });
             screen.getByText("1 person joined");
             expectAvatars([alice.userId]);
 
-            act(() => { call.participants = new Set([alice, bob, carol]); });
-            screen.getByText("3 people joined");
-            expectAvatars([alice.userId, bob.userId, carol.userId]);
+            act(() => {
+                call.participants = new Map([
+                    [alice, new Set(["a"])],
+                    [bob, new Set(["b1", "b2"])],
+                    [carol, new Set(["c"])],
+                ]);
+            });
+            screen.getByText("4 people joined");
+            expectAvatars([alice.userId, bob.userId, bob.userId, carol.userId]);
 
-            act(() => { call.participants = new Set(); });
+            act(() => { call.participants = new Map(); });
             expect(screen.queryByLabelText(/joined/)).toBe(null);
             expectAvatars([]);
         });
@@ -166,7 +172,7 @@ describe("CallLobby", () => {
             SdkConfig.put({
                 "element_call": { participant_limit: 2, url: "", use_exclusively: false, brand: "Element Call" },
             });
-            call.participants = new Set([bob, carol]);
+            call.participants = new Map([[bob, new Set("b")], [carol, new Set("c")]]);
 
             await renderView();
             const connectSpy = jest.spyOn(call, "connect");
diff --git a/test/createRoom-test.ts b/test/createRoom-test.ts
index ec38f6f3c0..735f75c873 100644
--- a/test/createRoom-test.ts
+++ b/test/createRoom-test.ts
@@ -68,7 +68,7 @@ describe("createRoom", () => {
         // 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);
+        expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, userId, 100, null);
     });
 
     it("sets up Element video rooms correctly", async () => {
@@ -98,7 +98,7 @@ describe("createRoom", () => {
         // 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);
+        expect(client.setPowerLevel).toHaveBeenCalledWith(roomId, userId, 100, null);
     });
 
     it("doesn't create calls in non-video-rooms", async () => {
diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts
index 9450d08a84..aa22db2718 100644
--- a/test/models/Call-test.ts
+++ b/test/models/Call-test.ts
@@ -18,17 +18,17 @@ import EventEmitter from "events";
 import { mocked } from "jest-mock";
 import { waitFor } from "@testing-library/react";
 import { RoomType } from "matrix-js-sdk/src/@types/event";
-import { ClientEvent, PendingEventOrdering } from "matrix-js-sdk/src/client";
+import { PendingEventOrdering } from "matrix-js-sdk/src/client";
 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 { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { GroupCallIntent } from "matrix-js-sdk/src/webrtc/groupCall";
 
 import type { Mocked } from "jest-mock";
 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 { JitsiCallMemberContent, ElementCallMemberContent, Layout } from "../../src/models/Call";
+import { JitsiCallMemberContent, Layout } from "../../src/models/Call";
 import { stubClient, mkEvent, mkRoomMember, setupAsyncStoreWithClient, mockPlatformPeg } from "../test-utils";
 import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../src/MediaDeviceHandler";
 import { MatrixClientPeg } from "../../src/MatrixClientPeg";
@@ -341,7 +341,7 @@ describe("JitsiCall", () => {
         });
 
         it("tracks participants in room state", async () => {
-            expect([...call.participants]).toEqual([]);
+            expect(call.participants).toEqual(new Map());
 
             // A participant with multiple devices (should only show up once)
             await client.sendStateEvent(
@@ -361,10 +361,13 @@ describe("JitsiCall", () => {
             // Now, stub out client.sendStateEvent so we can test our local echo
             client.sendStateEvent.mockReset();
             await call.connect();
-            expect([...call.participants]).toEqual([bob, alice]);
+            expect(call.participants).toEqual(new Map([
+                [alice, new Set(["alices_device"])],
+                [bob, new Set(["bobweb", "bobdesktop"])],
+            ]));
 
             await call.disconnect();
-            expect([...call.participants]).toEqual([bob]);
+            expect(call.participants).toEqual(new Map([[bob, new Set(["bobweb", "bobdesktop"])]]));
         });
 
         it("updates room state when connecting and disconnecting", async () => {
@@ -429,10 +432,10 @@ describe("JitsiCall", () => {
             await call.connect();
             await call.disconnect();
             expect(onParticipants.mock.calls).toEqual([
-                [new Set([alice]), new Set()],
-                [new Set([alice]), new Set([alice])],
-                [new Set(), new Set([alice])],
-                [new Set(), new Set()],
+                [new Map([[alice, new Set(["alices_device"])]]), new Map()],
+                [new Map([[alice, new Set(["alices_device"])]]), new Map([[alice, new Set(["alices_device"])]])],
+                [new Map(), new Map([[alice, new Set(["alices_device"])]])],
+                [new Map(), new Map()],
             ]);
 
             call.off(CallEvent.Participants, onParticipants);
@@ -568,11 +571,11 @@ describe("ElementCall", () => {
 
         it("ignores terminated calls", async () => {
             await ElementCall.create(room);
+            const call = Call.get(room);
+            if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
 
             // 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()!);
+            await call.groupCall.terminate();
 
             expect(Call.get(room)).toBeNull();
         });
@@ -599,8 +602,8 @@ describe("ElementCall", () => {
 
         afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
 
-        it("has intent m.prompt", () => {
-            expect(call.groupCall.getContent()["m.intent"]).toBe("m.prompt");
+        it("has prompt intent", () => {
+            expect(call.groupCall.intent).toBe(GroupCallIntent.Prompt);
         });
 
         it("connects muted", async () => {
@@ -690,19 +693,18 @@ describe("ElementCall", () => {
         });
 
         it("tracks participants in room state", async () => {
-            expect([...call.participants]).toEqual([]);
+            expect(call.participants).toEqual(new Map());
 
             // 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.call_id": call.groupCall.groupCallId,
                         "m.devices": [
-                            { device_id: "bobweb", session_id: "1", feeds: [] },
-                            { device_id: "bobdesktop", session_id: "1", feeds: [] },
+                            { device_id: "bobweb", session_id: "1", feeds: [], expires_ts: 1000 * 60 * 10 },
+                            { device_id: "bobdesktop", session_id: "1", feeds: [], expires_ts: 1000 * 60 * 10 },
                         ],
                     }],
                 },
@@ -713,11 +715,10 @@ describe("ElementCall", () => {
                 room.roomId,
                 ElementCall.MEMBER_EVENT_TYPE.name,
                 {
-                    "m.expires_ts": -1000 * 60,
                     "m.calls": [{
-                        "m.call_id": call.groupCall.getStateKey()!,
+                        "m.call_id": call.groupCall.groupCallId,
                         "m.devices": [
-                            { device_id: "carolandroid", session_id: "1", feeds: [] },
+                            { device_id: "carolandroid", session_id: "1", feeds: [], expires_ts: -1000 * 60 },
                         ],
                     }],
                 },
@@ -727,10 +728,13 @@ describe("ElementCall", () => {
             // Now, stub out client.sendStateEvent so we can test our local echo
             client.sendStateEvent.mockReset();
             await call.connect();
-            expect([...call.participants]).toEqual([bob, alice]);
+            expect(call.participants).toEqual(new Map([
+                [alice, new Set(["alices_device"])],
+                [bob, new Set(["bobweb", "bobdesktop"])],
+            ]));
 
             await call.disconnect();
-            expect([...call.participants]).toEqual([bob]);
+            expect(call.participants).toEqual(new Map([[bob, new Set(["bobweb", "bobdesktop"])]]));
         });
 
         it("tracks layout", async () => {
@@ -783,9 +787,8 @@ describe("ElementCall", () => {
             await call.connect();
             await call.disconnect();
             expect(onParticipants.mock.calls).toEqual([
-                [new Set([alice]), new Set()],
-                [new Set(), new Set()],
-                [new Set(), new Set([alice])],
+                [new Map([[alice, new Set(["alices_device"])]]), new Map()],
+                [new Map(), new Map([[alice, new Set(["alices_device"])]])],
             ]);
 
             call.off(CallEvent.Participants, onParticipants);
@@ -893,87 +896,17 @@ describe("ElementCall", () => {
             call.off(CallEvent.Destroy, onDestroy);
         });
 
-        describe("being kicked out by another device", () => {
-            const onDestroy = jest.fn();
-
-            beforeEach(async () => {
-                await call.connect();
-                call.on(CallEvent.Destroy, onDestroy);
-
-                jest.advanceTimersByTime(100);
-                jest.clearAllMocks();
-            });
-
-            afterEach(() => {
-                call.off(CallEvent.Destroy, onDestroy);
-            });
-
-            it("does not terminate the call if we are the last", async () => {
-                client.emit(ClientEvent.ToDeviceEvent, {
-                    getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
-                    getContent: () => ({ device_id: "random_device_id", timestamp: Date.now() }),
-                    getSender: () => (client.getUserId()),
-                } as MatrixEvent);
-
-                expect(client.sendStateEvent).not.toHaveBeenCalled();
-                expect(
-                    [ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
-                ).toBeTruthy();
-            });
-
-            it("ignores messages from our device", async () => {
-                client.emit(ClientEvent.ToDeviceEvent, {
-                    getSender: () => (client.getUserId()),
-                    getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
-                    getContent: () => ({ device_id: client.getDeviceId(), timestamp: Date.now() }),
-                } as MatrixEvent);
-
-                expect(client.sendStateEvent).not.toHaveBeenCalled();
-                expect(
-                    [ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
-                ).toBeFalsy();
-                expect(onDestroy).not.toHaveBeenCalled();
-            });
-
-            it("ignores messages from other users", async () => {
-                client.emit(ClientEvent.ToDeviceEvent, {
-                    getSender: () => (bob.userId),
-                    getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
-                    getContent: () => ({ device_id: "random_device_id", timestamp: Date.now() }),
-                } as MatrixEvent);
-
-                expect(client.sendStateEvent).not.toHaveBeenCalled();
-                expect(
-                    [ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
-                ).toBeFalsy();
-                expect(onDestroy).not.toHaveBeenCalled();
-            });
-
-            it("ignores messages from the past", async () => {
-                client.emit(ClientEvent.ToDeviceEvent, {
-                    getSender: () => (client.getUserId()),
-                    getType: () => (ElementCall.DUPLICATE_CALL_DEVICE_EVENT_TYPE),
-                    getContent: () => ({ device_id: "random_device_id", timestamp: 0 }),
-                } as MatrixEvent);
-
-                expect(client.sendStateEvent).not.toHaveBeenCalled();
-                expect(
-                    [ConnectionState.Disconnecting, ConnectionState.Disconnected].includes(call.connectionState),
-                ).toBeFalsy();
-                expect(onDestroy).not.toHaveBeenCalled();
-            });
-        });
-
         it("ends the call after a random delay if the last participant leaves without ending it", async () => {
             // Bob connects
             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: [] }],
+                        "m.call_id": call.groupCall.groupCallId,
+                        "m.devices": [
+                            { device_id: "bobweb", session_id: "1", feeds: [], expires_ts: 1000 * 60 * 10 },
+                        ],
                     }],
                 },
                 bob.userId,
@@ -987,9 +920,8 @@ describe("ElementCall", () => {
                 room.roomId,
                 ElementCall.MEMBER_EVENT_TYPE.name,
                 {
-                    "m.expires_ts": 1000 * 60 * 10,
                     "m.calls": [{
-                        "m.call_id": call.groupCall.getStateKey()!,
+                        "m.call_id": call.groupCall.groupCallId,
                         "m.devices": [],
                     }],
                 },
@@ -1025,20 +957,22 @@ describe("ElementCall", () => {
                 device_id: "alicedesktopneveronline",
             };
 
-            const mkContent = (devices: IMyDevice[]): ElementCallMemberContent => ({
-                "m.expires_ts": 1000 * 60 * 10,
+            const mkContent = (devices: IMyDevice[]) => ({
                 "m.calls": [{
-                    "m.call_id": call.groupCall.getStateKey()!,
-                    "m.devices": devices.map(d => ({ device_id: d.device_id, session_id: "1", feeds: [] })),
+                    "m.call_id": call.groupCall.groupCallId,
+                    "m.devices": devices.map(d => ({
+                        device_id: d.device_id, session_id: "1", feeds: [], expires_ts: 1000 * 60 * 10,
+                    })),
                 }],
             });
             const expectDevices = (devices: IMyDevice[]) => expect(
-                room.currentState.getStateEvents(ElementCall.MEMBER_EVENT_TYPE.name, alice.userId).getContent(),
+                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: [] })),
+                    "m.call_id": call.groupCall.groupCallId,
+                    "m.devices": devices.map(d => ({
+                        device_id: d.device_id, session_id: "1", feeds: [], expires_ts: expect.any(Number),
+                    })),
                 }],
             });
 
@@ -1055,23 +989,10 @@ describe("ElementCall", () => {
             });
 
             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]),
+                    mkContent([aliceDesktop]),
                     alice.userId,
                 );
 
@@ -1079,11 +1000,11 @@ describe("ElementCall", () => {
                 expectDevices([aliceDesktop]);
             });
 
-            it("cleans up devices that have been offline for too long", async () => {
+            it("cleans up our own device if we're disconnected", async () => {
                 await client.sendStateEvent(
                     room.roomId,
                     ElementCall.MEMBER_EVENT_TYPE.name,
-                    mkContent([aliceDesktop, aliceDesktopOffline]),
+                    mkContent([aliceWeb, aliceDesktop]),
                     alice.userId,
                 );
 
@@ -1132,8 +1053,8 @@ describe("ElementCall", () => {
 
         afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
 
-        it("has intent m.room", () => {
-            expect(call.groupCall.getContent()["m.intent"]).toBe("m.room");
+        it("has room intent", () => {
+            expect(call.groupCall.intent).toBe(GroupCallIntent.Room);
         });
 
         it("doesn't end the call when the last participant leaves", async () => {
diff --git a/test/test-utils/call.ts b/test/test-utils/call.ts
index fc0f358054..0ddedbb04a 100644
--- a/test/test-utils/call.ts
+++ b/test/test-utils/call.ts
@@ -16,11 +16,13 @@ limitations under the License.
 
 import { MatrixWidgetType } from "matrix-widget-api";
 
+import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
 import type { Room } from "matrix-js-sdk/src/models/room";
 import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
 import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import { mkEvent } from "./test-utils";
 import { Call, ConnectionState, ElementCall, JitsiCall } from "../../src/models/Call";
+import { CallStore } from "../../src/stores/CallStore";
 
 export class MockedCall extends Call {
     public static readonly EVENT_TYPE = "org.example.mocked_call";
@@ -49,7 +51,6 @@ export class MockedCall extends Call {
     }
 
     public static create(room: Room, id: string) {
-        // Update room state to let CallStore know that a call might now exist
         room.addLiveEvents([mkEvent({
             event: true,
             type: this.EVENT_TYPE,
@@ -59,16 +60,17 @@ export class MockedCall extends Call {
             skey: id,
             ts: Date.now(),
         })]);
+        // @ts-ignore deliberately calling a private method
+        // Let CallStore know that a call might now exist
+        CallStore.instance.updateRoom(room);
     }
 
-    public get groupCall(): MatrixEvent {
-        return this.event;
-    }
+    public readonly groupCall = { creationTs: this.event.getTs() } as unknown as GroupCall;
 
-    public get participants(): Set<RoomMember> {
+    public get participants(): Map<RoomMember, Set<string>> {
         return super.participants;
     }
-    public set participants(value: Set<RoomMember>) {
+    public set participants(value: Map<RoomMember, Set<string>>) {
         super.participants = value;
     }
 
@@ -77,8 +79,7 @@ export class MockedCall extends Call {
     }
 
     // No action needed for any of the following methods since this is just a mock
-    protected getDevices(): string[] { return []; }
-    protected async setDevices(): Promise<void> { }
+    public async clean(): Promise<void> {}
     // Public to allow spying
     public async performConnection(): Promise<void> {}
     public async performDisconnection(): Promise<void> {}
diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts
index 8b4ca9ba86..e218629547 100644
--- a/test/test-utils/test-utils.ts
+++ b/test/test-utils/test-utils.ts
@@ -39,6 +39,7 @@ import { ReEmitter } from "matrix-js-sdk/src/ReEmitter";
 import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler";
 import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
 
+import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
 import { MatrixClientPeg as peg } from '../../src/MatrixClientPeg';
 import { makeType } from "../../src/utils/TypeUtils";
 import { ValidatedServerConfig } from "../../src/utils/ValidatedServerConfig";
@@ -190,6 +191,7 @@ export function createTestClient(): MatrixClient {
             setVideoInput: jest.fn(),
             setAudioInput: jest.fn(),
             setAudioSettings: jest.fn(),
+            stopAllStreams: jest.fn(),
         } as unknown as MediaHandler),
         uploadContent: jest.fn(),
         getEventMapper: () => (opts) => new MatrixEvent(opts),
@@ -197,6 +199,7 @@ export function createTestClient(): MatrixClient {
         doesServerSupportLogoutDevices: jest.fn().mockReturnValue(true),
         requestPasswordEmailToken: jest.fn().mockRejectedValue({}),
         setPassword: jest.fn().mockRejectedValue({}),
+        groupCallEventHandler: { groupCalls: new Map<string, GroupCall>() },
     } as unknown as MatrixClient;
 
     client.reEmitter = new ReEmitter(client);
@@ -453,7 +456,7 @@ export function mkStubRoom(roomId: string = null, name: string, client: MatrixCl
         getMyMembership: jest.fn().mockReturnValue("join"),
         maySendMessage: jest.fn().mockReturnValue(true),
         currentState: {
-            getStateEvents: jest.fn(),
+            getStateEvents: jest.fn((_type, key) => key === undefined ? [] : null),
             getMember: jest.fn(),
             mayClientSendStateEvent: jest.fn().mockReturnValue(true),
             maySendStateEvent: jest.fn().mockReturnValue(true),
diff --git a/test/toasts/IncomingCallToast-test.tsx b/test/toasts/IncomingCallToast-test.tsx
index 51ae8cd48e..c1fecea767 100644
--- a/test/toasts/IncomingCallToast-test.tsx
+++ b/test/toasts/IncomingCallToast-test.tsx
@@ -99,12 +99,15 @@ describe("IncomingCallEvent", () => {
     const renderToast = () => { render(<IncomingCallToast callEvent={call.event} />); };
 
     it("correctly shows all the information", () => {
-        call.participants = new Set([alice, bob]);
+        call.participants = new Map([
+            [alice, new Set("a")],
+            [bob, new Set(["b1", "b2"])],
+        ]);
         renderToast();
 
         screen.getByText("Video call started");
         screen.getByText("Video");
-        screen.getByLabelText("2 participants");
+        screen.getByLabelText("3 participants");
 
         screen.getByRole("button", { name: "Join" });
         screen.getByRole("button", { name: "Close" });