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" });