From d7382de42fc5edcb4c878abdc69bd739fc197b3d Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Fri, 25 Nov 2022 23:48:08 -0500 Subject: [PATCH 1/3] 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.) --- .../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 | 43 +- src/models/Call.ts | 424 +++++++----------- 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 | 177 ++------ test/test-utils/call.ts | 17 +- test/test-utils/test-utils.ts | 5 +- test/toasts/IncomingCallToast-test.tsx | 7 +- 19 files changed, 373 insertions(+), 556 deletions(-) diff --git a/src/components/views/beacon/RoomCallBanner.tsx b/src/components/views/beacon/RoomCallBanner.tsx index 6085fe141b3..9c1d92346f8 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 = ({ 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 = ({ ); 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({ 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 (
= ({ >
{ _t("Video call") } - +
= ({ 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 ; + // No banner if the call is connected (or connecting/disconnecting) + if (call !== null && call.connectionState === ConnectionState.Disconnected) { + return ; } + return null; }; diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx index f3b79884697..867a6b32d63 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; + 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( ( { 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
@@ -85,17 +84,17 @@ const ActiveCallEvent = forwardRef( type={LiveContentType.Video} text={_t("Video call")} active={false} - participantCount={participants.size} + participantCount={participatingMembers.length} />
- + { call && } { buttonText } @@ -106,14 +105,13 @@ const ActiveCallEvent = forwardRef( interface ActiveLoadedCallEventProps { mxEvent: MatrixEvent; - call: Call; + call: ElementCall; } const ActiveLoadedCallEvent = forwardRef(({ 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(({ mxE return ; }); @@ -159,7 +157,6 @@ interface CallEventProps { * An event tile representing an active or historical Element call. */ export const CallEvent = forwardRef(({ mxEvent }, ref) => { - const noParticipants = useMemo(() => new Set(), []); const client = useContext(MatrixClientContext); const call = useCall(mxEvent.getRoomId()!); const latestEvent = client.getRoom(mxEvent.getRoomId())!.currentState @@ -180,12 +177,13 @@ export const CallEvent = forwardRef(({ mxEvent }, ref) => { return ; } - return ; + return ; }); diff --git a/src/components/views/rooms/LiveContentSummary.tsx b/src/components/views/rooms/LiveContentSummary.tsx index 34ee8252687..ff4da6979e0 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 = ({ call }) => + ; -} diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 87ed74198c3..644c35232e0 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 { } if (this.props.viewingCall && this.props.activeCall instanceof ElementCall) { - startButtons.push(); + startButtons.push(); } if (!this.props.viewingCall && this.props.onForgetClick) { @@ -685,7 +685,7 @@ export default class RoomHeader extends React.Component { { _t("Video call") }
{ this.props.activeCall instanceof ElementCall && ( - + ) } { /* Empty topic element to fill out space */ }
diff --git a/src/components/views/rooms/RoomTileCallSummary.tsx b/src/components/views/rooms/RoomTileCallSummary.tsx index 717ab5e36ff..f5a6f651c6a 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 = ({ 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 = ({ 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 38b30038ea7..2965f6265ba 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 = ({ delta }) => { +export const CallDuration: FC = memo(({ delta }) => { // Clock desync could lead to a negative duration, so just hide it if that happens if (delta <= 0) return null; return
{ formatCallTime(new Date(delta)) }
; -}; +}); -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 = ({ mxEvent }) => { +export const GroupCallDuration: FC = ({ groupCall }) => { const [now, setNow] = useState(() => Date.now()); useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, []); - return ; + return groupCall.creationTs === null + ? null + : ; }; diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index f003fdc6ca4..f8f34144ed0 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; - joinCallButtonTooltip?: string; - joinCallButtonDisabled?: boolean; + joinCallButtonDisabledTooltip?: string; children?: ReactNode; } -export const Lobby: FC = ({ room, joinCallButtonDisabled, joinCallButtonTooltip, connect, children }) => { +export const Lobby: FC = ({ room, joinCallButtonDisabledTooltip, connect, children }) => { const [connecting, setConnecting] = useState(false); const me = useMemo(() => room.getMember(room.myUserId)!, [room]); const videoRef = useRef(null); @@ -246,10 +244,10 @@ export const Lobby: FC = ({ room, joinCallButtonDisabled, joinCallBu
; @@ -331,9 +329,8 @@ interface JoinCallViewProps { const JoinCallView: FC = ({ 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 = ({ 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 =
- { _t("%(count)s people joined", { count: participants.size }) } + { _t("%(count)s people joined", { count: members.length }) }
; } @@ -360,8 +357,7 @@ const JoinCallView: FC = ({ room, resizing, call }) => { lobby = { facePile } ; diff --git a/src/hooks/useCall.ts b/src/hooks/useCall.ts index cf9bbee0d0d..b53898556db 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 => +export const useParticipants = (call: Call): Map> => useTypedEventEmitterState( call, CallEvent.Participants, useCallback(state => state ?? call.participants, [call]), ); -export const useFull = (call: Call): boolean => { +export const useParticipantCount = (call: Call): number => { const participants = useParticipants(call); - return ( - participants.size - >= (SdkConfig.get("element_call").participant_limit ?? DEFAULTS.element_call.participant_limit) - ); + return useMemo(() => { + let count = 0; + for (const devices of participants.values()) count += devices.size; + return count; + }, [participants]); }; -export const useIsAlreadyParticipant = (call: Call): boolean => { - const client = MatrixClientPeg.get(); +export const useParticipatingMembers = (call: Call): RoomMember[] => { const participants = useParticipants(call); return useMemo(() => { - return participants.has(client.getRoom(call.roomId).getMember(client.getUserId())); - }, [participants, client, call]); + 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 useJoinCallButtonTooltip = (call: Call): string | null => { +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/models/Call.ts b/src/models/Call.ts index 4276e4f9739..899e6a08e58 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, prevParticipants: Set) => void; + [CallEvent.Participants]: ( + participants: Map>, + prevParticipants: Map>, + ) => void; [CallEvent.Layout]: (layout: Layout) => void; [CallEvent.Destroy]: () => void; } @@ -135,11 +145,14 @@ export abstract class Call extends TypedEventEmitter(); - public get participants(): Set { + private _participants = new Map>(); + /** + * The participants in the call, as a map from members to device IDs. + */ + public get participants(): Map> { return this._participants; } - protected set participants(value: Set) { + protected set participants(value: Map>) { const prevValue = this._participants; this._participants = value; this.emit(CallEvent.Participants, value, prevValue); @@ -164,68 +177,11 @@ export abstract class Call extends TypedEventEmitter; - - /** - * 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 { - 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 { - const now = Date.now(); - const { devices: myDevices } = await this.client.getDevices(); - const deviceMap = new Map(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 { - await this.updateDevices(devices => Array.from(new Set(devices).add(this.client.getDeviceId()))); - } - - protected async removeOurDevice(): Promise { - await this.updateDevices(devices => { - const devicesSet = new Set(devices); - devicesSet.delete(this.client.getDeviceId()); - return Array.from(devicesSet); - }); - } + public abstract clean(): Promise; /** * Contacts the widget to connect to the call. @@ -384,7 +340,7 @@ export class JitsiCall extends Call { this.participantsExpirationTimer = null; } - const members = new Set(); + const participants = new Map>(); 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(); 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 { + if (this.room.getMyMembership() !== "join") return; + + const event = this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, this.client.getUserId()!); const content = event?.getContent(); 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 { - const content: JitsiCallMemberContent = { - devices, - expires_ts: Date.now() + this.STUCK_DEVICE_TIMEOUT_MS, - }; + public async clean(): Promise { + const now = Date.now(); + const { devices: myDevices } = await this.client.getDevices(); + const deviceMap = new Map(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 { + await this.updateDevices(devices => Array.from(new Set(devices).add(this.client.getDeviceId()))); + } + + private async removeOurDevice(): Promise { + 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"; @@ -637,7 +628,7 @@ export class ElementCall extends Call { hideHeader: "", userId: client.getUserId()!, deviceId: client.getDeviceId(), - roomId: groupCall.getRoomId()!, + 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(); - 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(); - 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(); - 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 { - 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 { + return this.groupCall.cleanMemberState(); } protected async performConnection( audioInput: MediaDeviceInfo | null, videoInput: MediaDeviceInfo | null, ): Promise { - 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,49 +753,34 @@ export class ElementCall extends Call { await this.messaging!.transport.send(action, {}); } - 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; - } - - private async terminate(): Promise { - await this.client.sendStateEvent( - this.roomId, - ElementCall.CALL_EVENT_TYPE.name, - { ...this.groupCall.getContent(), "m.terminated": "Call ended" }, - this.groupCall.getStateKey(), - ); - } + private updateParticipants() { + const participants = new Map>(); - private onBeforeRedaction = (): void => { - this.disconnect(); - }; + for (const [member, deviceMap] of this.groupCall.participants) { + participants.set(member, new Set(deviceMap.keys())); + } - private onRoomState = () => { - this.updateParticipants(); + // 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); + } - // 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(); - }; + devices.add(this.client.getDeviceId()!); + } - 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.participants = participants; + } - this.kickedOutByAnotherDevice = true; - this.disconnect(); - }; + private get mayTerminate(): boolean { + return this.groupCall.intent !== GroupCallIntent.Room + && this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client); + } private onConnectionState = (state: ConnectionState, prevState: ConnectionState) => { if ( @@ -921,12 +791,21 @@ export class ElementCall extends Call { } }; - private onParticipants = async (participants: Set, prevParticipants: Set) => { + private onParticipants = async ( + participants: Map>, + prevParticipants: Map>, + ) => { + 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) => { ev.preventDefault(); await this.messaging!.transport.reply(ev.detail, {}); // ack diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index abfb6b54afe..d6f16bcf8cc 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 { // 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 c5e363089b7..d0a20d5bcb6 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 { _t("Join") } diff --git a/test/components/views/messages/CallEvent-test.tsx b/test/components/views/messages/CallEvent-test.tsx index 04ca0a4bf78..23b7a978a1d 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 7ee9cf11df4..cf1ae59d091 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] = [ + mkRoomMember(room.roomId, "@alice:example.org"), new Set(["a"]), + ]; + const bob: [RoomMember, Set] = [ + mkRoomMember(room.roomId, "@bob:example.org"), new Set(["b1", "b2"]), + ]; + const carol: [RoomMember, Set] = [ + 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 0be81c90404..40d19c2feca 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 ec38f6f3c0a..735f75c8734 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 9450d08a84d..e4bc9177ced 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(), ).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,16 +989,15 @@ 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]), + mkContent([aliceDesktop]), alice.userId, ); await call.clean(); - expectDevices([aliceWeb, aliceDesktop]); + expectDevices([aliceDesktop]); }); it("cleans up our own device if we're disconnected", async () => { @@ -1079,18 +1012,6 @@ describe("ElementCall", () => { expectDevices([aliceDesktop]); }); - it("cleans up devices that have been offline for too long", async () => { - await client.sendStateEvent( - room.roomId, - ElementCall.MEMBER_EVENT_TYPE.name, - mkContent([aliceDesktop, aliceDesktopOffline]), - alice.userId, - ); - - await call.clean(); - expectDevices([aliceDesktop]); - }); - it("cleans up devices that have never been online", async () => { await client.sendStateEvent( room.roomId, @@ -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 fc0f358054a..0ddedbb04a7 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 { + public get participants(): Map> { return super.participants; } - public set participants(value: Set) { + public set participants(value: Map>) { 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 { } + public async clean(): Promise {} // Public to allow spying public async performConnection(): Promise {} public async performDisconnection(): Promise {} diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 8b4ca9ba865..e2186295472 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() }, } 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 51ae8cd48ec..c1fecea7670 100644 --- a/test/toasts/IncomingCallToast-test.tsx +++ b/test/toasts/IncomingCallToast-test.tsx @@ -99,12 +99,15 @@ describe("IncomingCallEvent", () => { const renderToast = () => { render(); }; 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" }); From 5cb3a8f600a65ff32c13314dd9ebec33d05a19a4 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Sat, 26 Nov 2022 00:29:01 -0500 Subject: [PATCH 2/3] Fix strings --- src/i18n/strings/en_EN.json | 1 - 1 file changed, 1 deletion(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index af1cab69dcb..0c6061e9780 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1041,7 +1041,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", From 1616e699e5f44935b6c65c325dc926fb464949f1 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Sat, 26 Nov 2022 00:32:37 -0500 Subject: [PATCH 3/3] Fix strict type errors --- src/hooks/useCall.ts | 2 +- src/models/Call.ts | 8 ++++---- test/models/Call-test.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/hooks/useCall.ts b/src/hooks/useCall.ts index b53898556db..3e83703d3cd 100644 --- a/src/hooks/useCall.ts +++ b/src/hooks/useCall.ts @@ -72,7 +72,7 @@ export const useParticipatingMembers = (call: Call): RoomMember[] => { export const useFull = (call: Call): boolean => { return useParticipantCount(call) >= ( - SdkConfig.get("element_call").participant_limit ?? DEFAULTS.element_call.participant_limit + SdkConfig.get("element_call").participant_limit ?? DEFAULTS.element_call.participant_limit! ); }; diff --git a/src/models/Call.ts b/src/models/Call.ts index 899e6a08e58..0e20c331fbc 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -392,7 +392,7 @@ export class JitsiCall extends Call { const event = this.room.currentState.getStateEvents(JitsiCall.MEMBER_EVENT_TYPE, this.client.getUserId()!); const content = event?.getContent(); const expiresAt = typeof content?.expires_ts === "number" ? content.expires_ts : -Infinity; - const devices = 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) { @@ -428,13 +428,13 @@ export class JitsiCall extends Call { } private async addOurDevice(): Promise { - await this.updateDevices(devices => Array.from(new Set(devices).add(this.client.getDeviceId()))); + await this.updateDevices(devices => Array.from(new Set(devices).add(this.client.getDeviceId()!))); } private async removeOurDevice(): Promise { await this.updateDevices(devices => { const devicesSet = new Set(devices); - devicesSet.delete(this.client.getDeviceId()); + devicesSet.delete(this.client.getDeviceId()!); return Array.from(devicesSet); }); } @@ -627,7 +627,7 @@ export class ElementCall extends Call { preload: "", hideHeader: "", userId: client.getUserId()!, - deviceId: client.getDeviceId(), + deviceId: client.getDeviceId()!, roomId: groupCall.room.roomId, baseUrl: client.baseUrl, lang: getCurrentLanguage().replace("_", "-"), diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index e4bc9177ced..aa22db2718e 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -966,7 +966,7 @@ describe("ElementCall", () => { }], }); 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.calls": [{ "m.call_id": call.groupCall.groupCallId,