From 860764c05764f181f7c59b6a7e4a6980c3d34cb3 Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Mon, 30 Oct 2023 16:14:27 +0100 Subject: [PATCH] Use new matrixRTC calling (#11792) * initial Signed-off-by: Timo K * cleanup1 Signed-off-by: Timo K * bring back call timer Signed-off-by: Timo K * more cleanup and test removals Signed-off-by: Timo K * remove event Signed-off-by: Timo K * cleanups and minor fixes Signed-off-by: Timo K * add matrixRTC to stubClient Signed-off-by: Timo K * update tests (some got removed) The removal is a consequence of EW now doing less call logic. More logic is done by the js sdk (MatrixRTCSession) And therefore in EC itself. Signed-off-by: Timo K * cleanups Signed-off-by: Timo K * mock the session Signed-off-by: Timo K * lint Signed-off-by: Timo K * remove GroupCallDuration Signed-off-by: Timo K * review and fixing tests Signed-off-by: Timo K --------- Signed-off-by: Timo K --- .../views/beacon/RoomCallBanner.tsx | 21 ++- src/components/views/messages/CallEvent.tsx | 4 +- .../views/rooms/LegacyRoomHeader.tsx | 4 +- src/components/views/voip/CallDuration.tsx | 18 +- src/models/Call.ts | 178 ++++++++---------- src/stores/CallStore.ts | 11 ++ src/stores/WidgetStore.ts | 1 + src/widgets/WidgetType.ts | 1 + .../components/structures/MatrixChat-test.tsx | 2 + .../views/messages/CallEvent-test.tsx | 1 - test/models/Call-test.ts | 161 +++------------- test/test-utils/call.ts | 3 + test/test-utils/test-utils.ts | 26 +++ 13 files changed, 176 insertions(+), 255 deletions(-) diff --git a/src/components/views/beacon/RoomCallBanner.tsx b/src/components/views/beacon/RoomCallBanner.tsx index f85666c652..68885fc693 100644 --- a/src/components/views/beacon/RoomCallBanner.tsx +++ b/src/components/views/beacon/RoomCallBanner.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React, { useCallback } from "react"; -import { EventType, Room } from "matrix-js-sdk/src/matrix"; +import { Room } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { _t } from "../../../languageHandler"; @@ -27,7 +27,7 @@ import { ConnectionState, ElementCall } from "../../../models/Call"; import { useCall } from "../../../hooks/useCall"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../../stores/OwnBeaconStore"; -import { GroupCallDuration } from "../voip/CallDuration"; +import { SessionDuration } from "../voip/CallDuration"; import { SdkContextClass } from "../../../contexts/SDKContext"; interface RoomCallBannerProps { @@ -49,12 +49,13 @@ const RoomCallBannerInner: React.FC = ({ roomId, call }) => [roomId], ); + // TODO matrix rtc const onClick = useCallback(() => { - const event = call.groupCall.room.currentState.getStateEvents( - EventType.GroupCallPrefix, - call.groupCall.groupCallId, - ); - if (event === null) { + logger.log("clicking on the call banner is not supported anymore - there are no timeline events anymore."); + let messageLikeEventId: string | undefined; + if (!messageLikeEventId) { + // Until we have a timeline event for calls this will always be true. + // We will never jump to the non existing timeline event. logger.error("Couldn't find a group call event to jump to"); return; } @@ -63,17 +64,17 @@ const RoomCallBannerInner: React.FC = ({ roomId, call }) => action: Action.ViewRoom, room_id: roomId, metricsTrigger: undefined, - event_id: event.getId(), + event_id: messageLikeEventId, scroll_into_view: true, highlighted: true, }); - }, [call, roomId]); + }, [roomId]); return (
{_t("voip|video_call")} - +
diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx index 560831bc6b..8a74eaa34f 100644 --- a/src/components/views/messages/CallEvent.tsx +++ b/src/components/views/messages/CallEvent.tsx @@ -33,7 +33,7 @@ import MemberAvatar from "../avatars/MemberAvatar"; import { LiveContentSummary, LiveContentType } from "../rooms/LiveContentSummary"; import FacePile from "../elements/FacePile"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import { CallDuration, GroupCallDuration } from "../voip/CallDuration"; +import { CallDuration, SessionDuration } from "../voip/CallDuration"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; const MAX_FACES = 8; @@ -77,7 +77,7 @@ const ActiveCallEvent = forwardRef( />
- {call && } + {call && } { {icon} {name} {this.props.activeCall instanceof ElementCall && ( - + )} {/* Empty topic element to fill out space */}
diff --git a/src/components/views/voip/CallDuration.tsx b/src/components/views/voip/CallDuration.tsx index 1f0ec4822d..03e26a819f 100644 --- a/src/components/views/voip/CallDuration.tsx +++ b/src/components/views/voip/CallDuration.tsx @@ -15,7 +15,8 @@ limitations under the License. */ import React, { FC, useState, useEffect, memo } from "react"; -import { GroupCall } from "matrix-js-sdk/src/matrix"; +// eslint-disable-next-line no-restricted-imports +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { formatPreciseDuration } from "../../../DateUtils"; @@ -32,20 +33,25 @@ export const CallDuration: FC = memo(({ delta }) => { return
{formatPreciseDuration(delta)}
; }); -interface GroupCallDurationProps { - groupCall: GroupCall; +interface SessionDurationProps { + session: MatrixRTCSession | undefined; } /** - * A call duration counter that automatically counts up, given a live GroupCall + * A call duration counter that automatically counts up, given a matrixRTC session * object. */ -export const GroupCallDuration: FC = ({ groupCall }) => { +export const SessionDuration: FC = ({ session }) => { const [now, setNow] = useState(() => Date.now()); + useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, []); - return groupCall.creationTs === null ? null : ; + // This is a temporal solution. + // Using the oldest membership will update when this user leaves. + // This implies that the displayed call duration will also update consequently. + const createdTs = session?.getOldestMembership()?.createdTs(); + return createdTs ? : ; }; diff --git a/src/models/Call.ts b/src/models/Call.ts index a87be00291..b5b222a622 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -20,20 +20,21 @@ import { RoomStateEvent, EventType, MatrixClient, - GroupCall, - GroupCallEvent, - GroupCallIntent, - GroupCallState, - GroupCallType, + IMyDevice, + Room, + RoomMember, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { randomString } from "matrix-js-sdk/src/randomstring"; 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 { IWidgetApiRequest } from "matrix-widget-api"; +// eslint-disable-next-line no-restricted-imports +import { MatrixRTCSession, MatrixRTCSessionEvent } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; +// eslint-disable-next-line no-restricted-imports +import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager"; import type EventEmitter from "events"; -import type { IMyDevice, Room, RoomMember } from "matrix-js-sdk/src/matrix"; import type { ClientWidgetApi } from "matrix-widget-api"; import type { IApp } from "../stores/WidgetStore"; import SdkConfig, { DEFAULTS } from "../SdkConfig"; @@ -615,12 +616,13 @@ export class JitsiCall extends Call { * (somewhat cheekily named) */ export class ElementCall extends Call { + // TODO this is only there to support backwards compatiblity in timeline rendering + // this should not be part of this class since it has nothing to do with it. 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 terminationTimer: number | null = null; - private _layout = Layout.Tile; public get layout(): Layout { return this._layout; @@ -630,7 +632,13 @@ export class ElementCall extends Call { this.emit(CallEvent.Layout, value); } - private constructor(public readonly groupCall: GroupCall, client: MatrixClient) { + private static createCallWidget(roomId: string, client: MatrixClient): IApp { + const ecWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.CALL.matches(app.type)); + if (ecWidget) { + logger.log("There is already a widget in this room, so we recreate it"); + ActiveWidgetStore.instance.destroyPersistentWidget(ecWidget.id, ecWidget.roomId); + WidgetStore.instance.removeVirtualWidget(ecWidget.id, ecWidget.roomId); + } const accountAnalyticsData = client.getAccountData(PosthogAnalytics.ANALYTICS_EVENT_TYPE); // The analyticsID is passed directly to element call (EC) since this codepath is only for EC and no other widget. // We really don't want the same analyticID's for the EC and EW posthog instances (Data on posthog should be limited/anonymized as much as possible). @@ -639,7 +647,6 @@ export class ElementCall extends Call { const analyticsID: string = accountAnalyticsData?.getContent().pseudonymousAnalyticsOptIn ? accountAnalyticsData?.getContent().id : ""; - // Splice together the Element Call URL for this call const params = new URLSearchParams({ embed: "true", // We're embedding EC within another application @@ -648,14 +655,14 @@ export class ElementCall extends Call { hideHeader: "true", // Hide the header since our room header is enough userId: client.getUserId()!, deviceId: client.getDeviceId()!, - roomId: groupCall.room.roomId, + roomId: roomId, baseUrl: client.baseUrl, lang: getCurrentLanguage().replace("_", "-"), fontScale: `${(SettingsStore.getValue("baseFontSizeV2") ?? 16) / FontWatcher.DEFAULT_SIZE}`, analyticsID, }); - if (client.isRoomEncrypted(groupCall.room.roomId)) params.append("perParticipantE2EE", ""); + if (client.isRoomEncrypted(roomId)) params.append("perParticipantE2EE", ""); if (SettingsStore.getValue("fallbackICEServerAllowed")) params.append("allowIceFallback", ""); if (SettingsStore.getValue("feature_allow_screen_share_only_mode")) params.append("allowVoipWithNoMedia", ""); @@ -678,30 +685,23 @@ export class ElementCall extends Call { // To use Element Call without touching room state, we create a virtual // widget (one that doesn't have a corresponding state event) - super( - WidgetStore.instance.addVirtualWidget( - { - id: randomString(24), // So that it's globally unique - creatorUserId: client.getUserId()!, - name: "Element Call", - type: MatrixWidgetType.Custom, - url: url.toString(), - // This option makes the widget API wait for the 'contentLoaded' event instead - // of waiting for a 'load' event from the iframe, which means the widget code isn't - // racing to set up its listener before the 'load' event is fired. EC sends this event - // of of https://github.com/matrix-org/matrix-js-sdk/pull/3556 so we should uncomment - // the line below once we've made both livekit and full-mesh releases that include that - // PR, and everything will be less racy. - //waitForIframeLoad: false, - }, - groupCall.room.roomId, - ), - client, + return WidgetStore.instance.addVirtualWidget( + { + id: randomString(24), // So that it's globally unique + creatorUserId: client.getUserId()!, + name: "Element Call", + type: WidgetType.CALL.preferred, + url: url.toString(), + // waitForIframeLoad: false, + }, + roomId, ); + } + private constructor(public session: MatrixRTCSession, widget: IApp, client: MatrixClient) { + super(widget, client); - this.on(CallEvent.Participants, this.onParticipants); - groupCall.on(GroupCallEvent.ParticipantsChanged, this.onGroupCallParticipants); - groupCall.on(GroupCallEvent.GroupCallStateChanged, this.onGroupCallState); + this.session.on(MatrixRTCSessionEvent.MembershipsChanged, this.onMembershipChanged); + this.client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, this.onRTCSessionEnded); this.updateParticipants(); } @@ -714,8 +714,18 @@ export class ElementCall extends Call { SettingsStore.getValue("feature_element_call_video_rooms") && room.isCallRoom()) ) { - const groupCall = room.client.groupCallEventHandler!.groupCalls.get(room.roomId); - if (groupCall !== undefined) return new ElementCall(groupCall, room.client); + const apps = WidgetStore.instance.getApps(room.roomId); + const ecWidget = apps.find((app) => WidgetType.CALL.matches(app.type)); + const session = room.client.matrixRTC.getRoomSession(room); + + // A call is present if we + // - have a widget: This means the create function was called + // - or there is a running session where we have not yet created a widget for. + if (ecWidget || session.memberships.length !== 0) { + // create a widget for the case we are joining a running call and don't have on yet. + const availableOrCreatedWidget = ecWidget ?? ElementCall.createCallWidget(room.roomId, room.client); + return new ElementCall(session, availableOrCreatedWidget, room.client); + } } return null; @@ -727,19 +737,8 @@ export class ElementCall extends Call { SettingsStore.getValue("feature_element_call_video_rooms") && room.isCallRoom(); - const groupCall = new GroupCall( - room.client, - room, - GroupCallType.Video, - false, - isVideoRoom ? GroupCallIntent.Room : GroupCallIntent.Prompt, - ); - - await groupCall.create(); - } - - public clean(): Promise { - return this.groupCall.cleanMemberState(); + console.log("Intend is ", isVideoRoom ? "VideoRoom" : "Prompt", " TODO, handle intent appropriately"); + ElementCall.createCallWidget(room.roomId, room.client); } protected async performConnection( @@ -755,7 +754,6 @@ export class ElementCall extends Call { throw new Error(`Failed to join call in room ${this.roomId}: ${e}`); } - this.groupCall.enteredViaAnotherSession = true; this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); @@ -774,15 +772,14 @@ export class ElementCall extends Call { this.messaging!.off(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); this.messaging!.off(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); super.setDisconnected(); - this.groupCall.enteredViaAnotherSession = false; } public destroy(): void { - ActiveWidgetStore.instance.destroyPersistentWidget(this.widget.id, this.groupCall.room.roomId); - WidgetStore.instance.removeVirtualWidget(this.widget.id, this.groupCall.room.roomId); - this.off(CallEvent.Participants, this.onParticipants); - this.groupCall.off(GroupCallEvent.ParticipantsChanged, this.onGroupCallParticipants); - this.groupCall.off(GroupCallEvent.GroupCallStateChanged, this.onGroupCallState); + ActiveWidgetStore.instance.destroyPersistentWidget(this.widget.id, this.widget.roomId); + WidgetStore.instance.removeVirtualWidget(this.widget.id, this.widget.roomId); + + this.session.off(MatrixRTCSessionEvent.MembershipsChanged, this.onMembershipChanged); + this.client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, this.onRTCSessionEnded); if (this.terminationTimer !== null) { clearTimeout(this.terminationTimer); @@ -792,70 +789,41 @@ export class ElementCall extends Call { super.destroy(); } + private onRTCSessionEnded = (roomId: string, session: MatrixRTCSession): void => { + if (roomId == this.roomId) { + this.destroy(); + } + }; + /** * Sets the call's layout. * @param layout The layout to switch to. */ public async setLayout(layout: Layout): Promise { const action = layout === Layout.Tile ? ElementWidgetActions.TileLayout : ElementWidgetActions.SpotlightLayout; - await this.messaging!.transport.send(action, {}); } + private onMembershipChanged = (): void => this.updateParticipants(); + private updateParticipants(): void { const participants = new Map>(); - for (const [member, deviceMap] of this.groupCall.participants) { - participants.set(member, new Set(deviceMap.keys())); + for (const m of this.session.memberships) { + if (!m.sender) continue; + const member = this.room.getMember(m.sender); + if (member) { + if (participants.has(member)) { + participants.get(member)?.add(m.deviceId); + } else { + participants.set(member, new Set([m.deviceId])); + } + } } this.participants = participants; } - private get mayTerminate(): boolean { - return ( - this.groupCall.intent !== GroupCallIntent.Room && - this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client) - ); - } - - private onParticipants = async ( - participants: Map>, - prevParticipants: Map>, - ): Promise => { - 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 (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.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 - // other reasons, we can't rely on other clients to terminate the call. - // Since it's likely that other clients are using this same logic, we wait - // 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 = window.setTimeout( - () => this.groupCall.terminate(), - Math.random() * 6000 + 2000, - ); - } - } - }; - - private onGroupCallParticipants = (): void => this.updateParticipants(); - - private onGroupCallState = (state: GroupCallState): void => { - if (state === GroupCallState.Ended) this.destroy(); - }; - private onHangup = async (ev: CustomEvent): Promise => { ev.preventDefault(); await this.messaging!.transport.reply(ev.detail, {}); // ack @@ -873,4 +841,8 @@ export class ElementCall extends Call { this.layout = Layout.Spotlight; await this.messaging!.transport.reply(ev.detail, {}); // ack }; + + public clean(): Promise { + return Promise.resolve(); + } } diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index 9c90a4929b..c401d8c25a 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -16,6 +16,10 @@ limitations under the License. import { logger } from "matrix-js-sdk/src/logger"; import { GroupCallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/groupCallEventHandler"; +// eslint-disable-next-line no-restricted-imports +import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager"; +// eslint-disable-next-line no-restricted-imports +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import type { GroupCall, Room } from "matrix-js-sdk/src/matrix"; import defaultDispatcher from "../dispatcher/dispatcher"; @@ -61,6 +65,8 @@ export class CallStore extends AsyncStoreWithClient<{}> { } this.matrixClient.on(GroupCallEventHandlerEvent.Incoming, this.onGroupCall); this.matrixClient.on(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall); + this.matrixClient.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, this.onRTCSession); + this.matrixClient.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, this.onRTCSession); WidgetStore.instance.on(UPDATE_EVENT, this.onWidgets); // If the room ID of a previously connected call is still in settings at @@ -94,6 +100,8 @@ export class CallStore extends AsyncStoreWithClient<{}> { this.matrixClient.off(GroupCallEventHandlerEvent.Incoming, this.onGroupCall); this.matrixClient.off(GroupCallEventHandlerEvent.Outgoing, this.onGroupCall); this.matrixClient.off(GroupCallEventHandlerEvent.Ended, this.onGroupCall); + this.matrixClient.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, this.onRTCSession); + this.matrixClient.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, this.onRTCSession); } WidgetStore.instance.off(UPDATE_EVENT, this.onWidgets); } @@ -191,4 +199,7 @@ export class CallStore extends AsyncStoreWithClient<{}> { }; private onGroupCall = (groupCall: GroupCall): void => this.updateRoom(groupCall.room); + private onRTCSession = (roomId: string, session: MatrixRTCSession): void => { + this.updateRoom(session.room); + }; } diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index fd092e7fbb..aac8037ea6 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -202,6 +202,7 @@ export default class WidgetStore extends AsyncStoreWithClient { const app = WidgetUtils.makeAppConfig(widget.id, widget, widget.creatorUserId, roomId, undefined); this.widgetMap.set(WidgetUtils.getWidgetUid(app), app); this.roomMap.get(roomId)!.widgets.push(app); + this.emit(UPDATE_EVENT, roomId); return app; } diff --git a/src/widgets/WidgetType.ts b/src/widgets/WidgetType.ts index 8b9405659d..8dc02e70f8 100644 --- a/src/widgets/WidgetType.ts +++ b/src/widgets/WidgetType.ts @@ -20,6 +20,7 @@ export class WidgetType { public static readonly STICKERPICKER = new WidgetType("m.stickerpicker", "m.stickerpicker"); public static readonly INTEGRATION_MANAGER = new WidgetType("m.integration_manager", "m.integration_manager"); public static readonly CUSTOM = new WidgetType("m.custom", "m.custom"); + public static readonly CALL = new WidgetType("m.call", "m.call"); public constructor(public readonly preferred: string, public readonly legacy: string) {} diff --git a/test/components/structures/MatrixChat-test.tsx b/test/components/structures/MatrixChat-test.tsx index f64d0a2d79..8698036ba9 100644 --- a/test/components/structures/MatrixChat-test.tsx +++ b/test/components/structures/MatrixChat-test.tsx @@ -34,6 +34,7 @@ import { Action } from "../../../src/dispatcher/actions"; import { UserTab } from "../../../src/components/views/dialogs/UserTab"; import { clearAllModals, + createStubMatrixRTC, filterConsole, flushPromises, getMockClientWithEventEmitter, @@ -109,6 +110,7 @@ describe("", () => { secretStorage: { isStored: jest.fn().mockReturnValue(null), }, + matrixRTC: createStubMatrixRTC(), getDehydratedDevice: jest.fn(), whoami: jest.fn(), isRoomEncrypted: jest.fn(), diff --git a/test/components/views/messages/CallEvent-test.tsx b/test/components/views/messages/CallEvent-test.tsx index 39630e71a9..2b29f866c6 100644 --- a/test/components/views/messages/CallEvent-test.tsx +++ b/test/components/views/messages/CallEvent-test.tsx @@ -141,7 +141,6 @@ describe("CallEvent", () => { screen.getByText("@alice:example.org started a video call"); screen.getByLabelText("2 participants"); - screen.getByText("1m 30s"); // Test that the join button works const dispatcherSpy = jest.fn(); diff --git a/test/models/Call-test.ts b/test/models/Call-test.ts index 12839bfd11..94a102c95c 100644 --- a/test/models/Call-test.ts +++ b/test/models/Call-test.ts @@ -17,16 +17,14 @@ limitations under the License. import EventEmitter from "events"; import { mocked } from "jest-mock"; import { waitFor } from "@testing-library/react"; -import { - RoomType, - Room, - RoomEvent, - MatrixEvent, - RoomStateEvent, - PendingEventOrdering, - GroupCallIntent, -} from "matrix-js-sdk/src/matrix"; +import { RoomType, Room, RoomEvent, MatrixEvent, RoomStateEvent, PendingEventOrdering } from "matrix-js-sdk/src/matrix"; import { Widget } from "matrix-widget-api"; +// eslint-disable-next-line no-restricted-imports +import { MatrixRTCSessionManagerEvents } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager"; +// eslint-disable-next-line no-restricted-imports +import { CallMembership } from "matrix-js-sdk/src/matrixrtc/CallMembership"; +// eslint-disable-next-line no-restricted-imports +import { MatrixRTCSession, MatrixRTCSessionEvent } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import type { Mocked } from "jest-mock"; import type { MatrixClient, IMyDevice, RoomMember } from "matrix-js-sdk/src/matrix"; @@ -96,9 +94,16 @@ const setUpClientRoomAndStores = (): { return null; } }); + jest.spyOn(room, "getMyMembership").mockReturnValue("join"); client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null)); + client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null)); + client.matrixRTC.getRoomSession.mockImplementation((roomId) => { + const session = new EventEmitter() as MatrixRTCSession; + session.memberships = []; + return session; + }); client.getRooms.mockReturnValue([room]); client.getUserId.mockReturnValue(alice.userId); client.getDeviceId.mockReturnValue("alices_device"); @@ -576,11 +581,9 @@ describe("ElementCall", () => { let client: Mocked; let room: Room; let alice: RoomMember; - let bob: RoomMember; - let carol: RoomMember; beforeEach(() => { - ({ client, room, alice, bob, carol } = setUpClientRoomAndStores()); + ({ client, room, alice } = setUpClientRoomAndStores()); }); afterEach(() => cleanUpClientRoomAndStores(client, room)); @@ -595,15 +598,14 @@ describe("ElementCall", () => { expect(Call.get(room)).toBeInstanceOf(ElementCall); }); - it("ignores terminated calls", async () => { - await ElementCall.create(room); + it("finds ongoing calls that are created by the session manager", async () => { + // There is an existing session created by another user in this room. + client.matrixRTC.getRoomSession.mockReturnValue({ + on: (ev: any, fn: any) => {}, + memberships: [{ fakeVal: "fake membership" }], + } as unknown as MatrixRTCSession); const call = Call.get(room); if (!(call instanceof ElementCall)) throw new Error("Failed to create call"); - - // Terminate the call - await call.groupCall.terminate(); - - expect(Call.get(room)).toBeNull(); }); it("passes font settings through widget URL", async () => { @@ -731,10 +733,6 @@ describe("ElementCall", () => { afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy)); - it("has prompt intent", () => { - expect(call.groupCall.intent).toBe(GroupCallIntent.Prompt); - }); - it("connects muted", async () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); audioMutedSpy.mockReturnValue(true); @@ -828,57 +826,6 @@ describe("ElementCall", () => { expect(call.connectionState).toBe(ConnectionState.Disconnected); }); - it("tracks participants in room state", async () => { - 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.calls": [ - { - "m.call_id": call.groupCall.groupCallId, - "m.devices": [ - { device_id: "bobweb", session_id: "1", feeds: [], expires_ts: 1000 * 60 * 10 }, - { device_id: "bobdesktop", session_id: "1", feeds: [], expires_ts: 1000 * 60 * 10 }, - ], - }, - ], - }, - bob.userId, - ); - // A participant with an expired device (should not show up) - await client.sendStateEvent( - room.roomId, - ElementCall.MEMBER_EVENT_TYPE.name, - { - "m.calls": [ - { - "m.call_id": call.groupCall.groupCallId, - "m.devices": [ - { device_id: "carolandroid", session_id: "1", feeds: [], expires_ts: -1000 * 60 }, - ], - }, - ], - }, - carol.userId, - ); - - // Now, stub out client.sendStateEvent so we can test our local echo - client.sendStateEvent.mockReset(); - await call.connect(); - expect(call.participants).toEqual( - new Map([ - [alice, new Set(["alices_device"])], - [bob, new Set(["bobweb", "bobdesktop"])], - ]), - ); - - await call.disconnect(); - expect(call.participants).toEqual(new Map([[bob, new Set(["bobweb", "bobdesktop"])]])); - }); - it("tracks layout", async () => { await call.connect(); expect(call.layout).toBe(Layout.Tile); @@ -924,14 +871,11 @@ describe("ElementCall", () => { it("emits events when participants change", async () => { const onParticipants = jest.fn(); + call.session.memberships = [{ sender: alice.userId, deviceId: "alices_device" } as CallMembership]; call.on(CallEvent.Participants, onParticipants); + call.session.emit(MatrixRTCSessionEvent.MembershipsChanged, [], []); - await call.connect(); - await call.disconnect(); - expect(onParticipants.mock.calls).toEqual([ - [new Map([[alice, new Set(["alices_device"])]]), new Map()], - [new Map(), new Map([[alice, new Set(["alices_device"])]])], - ]); + expect(onParticipants.mock.calls).toEqual([[new Map([[alice, new Set(["alices_device"])]]), new Map()]]); call.off(CallEvent.Participants, onParticipants); }); @@ -954,60 +898,19 @@ describe("ElementCall", () => { call.off(CallEvent.Layout, onLayout); }); - it("ends the call immediately if we're the last participant to leave", async () => { + it("ends the call immediately if the session ended", async () => { await call.connect(); const onDestroy = jest.fn(); call.on(CallEvent.Destroy, onDestroy); await call.disconnect(); - expect(onDestroy).toHaveBeenCalled(); - call.off(CallEvent.Destroy, onDestroy); - }); - - it("ends the call after a random delay if the last participant leaves without ending it", async () => { - // Bob connects - await client.sendStateEvent( + // this will be called automatically + // disconnect -> widget sends state event -> session manager notices no-one left + client.matrixRTC.emit( + MatrixRTCSessionManagerEvents.SessionEnded, room.roomId, - ElementCall.MEMBER_EVENT_TYPE.name, - { - "m.calls": [ - { - "m.call_id": call.groupCall.groupCallId, - "m.devices": [ - { device_id: "bobweb", session_id: "1", feeds: [], expires_ts: 1000 * 60 * 10 }, - ], - }, - ], - }, - bob.userId, + {} as unknown as MatrixRTCSession, ); - - const onDestroy = jest.fn(); - call.on(CallEvent.Destroy, onDestroy); - - // Bob disconnects - await client.sendStateEvent( - room.roomId, - ElementCall.MEMBER_EVENT_TYPE.name, - { - "m.calls": [ - { - "m.call_id": call.groupCall.groupCallId, - "m.devices": [], - }, - ], - }, - bob.userId, - ); - - // Nothing should happen for at least a second, to give Bob a chance - // to end the call on his own - jest.advanceTimersByTime(1000); - expect(onDestroy).not.toHaveBeenCalled(); - - // Within 10 seconds, our client should end the call on behalf of Bob - jest.advanceTimersByTime(9000); expect(onDestroy).toHaveBeenCalled(); - call.off(CallEvent.Destroy, onDestroy); }); @@ -1040,10 +943,6 @@ describe("ElementCall", () => { afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy)); - it("has room intent", () => { - expect(call.groupCall.intent).toBe(GroupCallIntent.Room); - }); - it("doesn't end the call when the last participant leaves", async () => { await call.connect(); const onDestroy = jest.fn(); diff --git a/test/test-utils/call.ts b/test/test-utils/call.ts index de136c1649..ed3eb82174 100644 --- a/test/test-utils/call.ts +++ b/test/test-utils/call.ts @@ -35,6 +35,9 @@ export class MockedCall extends Call { url: "https://example.org", name: "Group call", creatorUserId: "@alice:example.org", + // waitForIframeLoad = false, makes the widget API wait for the 'contentLoaded' event instead. + // This is how the EC is designed, but for backwards compatibility (full mesh) we currently need to use waitForIframeLoad = true + // waitForIframeLoad: false }, room.client, ); diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index d92ebe89bb..c0cb061321 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -45,6 +45,10 @@ import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler"; import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; import { CryptoBackend } from "matrix-js-sdk/src/common-crypto/CryptoBackend"; import { MapperOpts } from "matrix-js-sdk/src/event-mapper"; +// eslint-disable-next-line no-restricted-imports +import { MatrixRTCSessionManager } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSessionManager"; +// eslint-disable-next-line no-restricted-imports +import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import type { GroupCall } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg as peg } from "../../src/MatrixClientPeg"; @@ -89,6 +93,7 @@ export function stubClient(): MatrixClient { */ export function createTestClient(): MatrixClient { const eventEmitter = new EventEmitter(); + let txnId = 1; const client = { @@ -256,6 +261,7 @@ export function createTestClient(): MatrixClient { submitMsisdnToken: jest.fn(), getMediaConfig: jest.fn(), baseUrl: "https://matrix-client.matrix.org", + matrixRTC: createStubMatrixRTC(), } as unknown as MatrixClient; client.reEmitter = new ReEmitter(client); @@ -272,6 +278,26 @@ export function createTestClient(): MatrixClient { return client; } +export function createStubMatrixRTC(): MatrixRTCSessionManager { + const eventEmitterMatrixRTCSessionManager = new EventEmitter(); + const mockGetRoomSession = jest.fn(); + mockGetRoomSession.mockImplementation((roomId) => { + const session = new EventEmitter() as MatrixRTCSession; + session.memberships = []; + session.getOldestMembership = () => undefined; + return session; + }); + return { + start: jest.fn(), + stop: jest.fn(), + getActiveRoomSession: jest.fn(), + getRoomSession: mockGetRoomSession, + on: eventEmitterMatrixRTCSessionManager.on.bind(eventEmitterMatrixRTCSessionManager), + off: eventEmitterMatrixRTCSessionManager.off.bind(eventEmitterMatrixRTCSessionManager), + removeListener: eventEmitterMatrixRTCSessionManager.removeListener.bind(eventEmitterMatrixRTCSessionManager), + emit: eventEmitterMatrixRTCSessionManager.emit.bind(eventEmitterMatrixRTCSessionManager), + } as unknown as MatrixRTCSessionManager; +} type MakeEventPassThruProps = { user: User["userId"]; relatesTo?: IEventRelation;