1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-07-31 15:24:23 +03:00

Revert "Revert "Add the call object to Call events""

This commit is contained in:
David Baker
2023-03-29 15:32:25 +01:00
committed by GitHub
parent eb0c0f7b93
commit 798ac7b94c
6 changed files with 152 additions and 48 deletions

View File

@ -123,6 +123,7 @@ export class MockRTCPeerConnection {
public iceCandidateListener?: (e: RTCPeerConnectionIceEvent) => void; public iceCandidateListener?: (e: RTCPeerConnectionIceEvent) => void;
public iceConnectionStateChangeListener?: () => void; public iceConnectionStateChangeListener?: () => void;
public onTrackListener?: (e: RTCTrackEvent) => void; public onTrackListener?: (e: RTCTrackEvent) => void;
public onDataChannelListener?: (ev: RTCDataChannelEvent) => void;
public needsNegotiation = false; public needsNegotiation = false;
public readyToNegotiate: Promise<void>; public readyToNegotiate: Promise<void>;
private onReadyToNegotiate?: () => void; private onReadyToNegotiate?: () => void;
@ -168,6 +169,8 @@ export class MockRTCPeerConnection {
this.iceConnectionStateChangeListener = listener; this.iceConnectionStateChangeListener = listener;
} else if (type == "track") { } else if (type == "track") {
this.onTrackListener = listener; this.onTrackListener = listener;
} else if (type == "datachannel") {
this.onDataChannelListener = listener;
} }
} }
public createDataChannel(label: string, opts: RTCDataChannelInit) { public createDataChannel(label: string, opts: RTCDataChannelInit) {
@ -232,6 +235,10 @@ export class MockRTCPeerConnection {
this.negotiationNeededListener(); this.negotiationNeededListener();
} }
} }
public triggerIncomingDataChannel(): void {
this.onDataChannelListener?.({ channel: {} } as RTCDataChannelEvent);
}
} }
export class MockRTCRtpSender { export class MockRTCRtpSender {

View File

@ -431,6 +431,33 @@ describe("Call", function () {
expect(transceivers.get("m.usermedia:video")!.sender.track!.id).toBe("usermedia_video_track"); expect(transceivers.get("m.usermedia:video")!.sender.track!.id).toBe("usermedia_video_track");
}); });
it("should handle error on call upgrade", async () => {
const onError = jest.fn();
call.on(CallEvent.Error, onError);
await startVoiceCall(client, call);
await call.onAnswerReceived(
makeMockEvent("@test:foo", {
version: 1,
call_id: call.callId,
party_id: "party_id",
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {},
}),
);
const mockGetUserMediaStream = jest.fn().mockRejectedValue(new Error("Test error"));
client.client.getMediaHandler().getUserMediaStream = mockGetUserMediaStream;
// then unmute which should cause an upgrade
await call.setLocalVideoMuted(false);
expect(onError).toHaveBeenCalled();
});
it("should unmute video after upgrading to video call", async () => { it("should unmute video after upgrading to video call", async () => {
// Regression test for https://github.com/vector-im/element-call/issues/925 // Regression test for https://github.com/vector-im/element-call/issues/925
await startVoiceCall(client, call); await startVoiceCall(client, call);
@ -737,11 +764,22 @@ describe("Call", function () {
const dataChannel = call.createDataChannel("data_channel_label", { id: 123 }); const dataChannel = call.createDataChannel("data_channel_label", { id: 123 });
expect(dataChannelCallback).toHaveBeenCalledWith(dataChannel); expect(dataChannelCallback).toHaveBeenCalledWith(dataChannel, call);
expect(dataChannel.label).toBe("data_channel_label"); expect(dataChannel.label).toBe("data_channel_label");
expect(dataChannel.id).toBe(123); expect(dataChannel.id).toBe(123);
}); });
it("should emit a data channel event when the other side adds a data channel", async () => {
await startVoiceCall(client, call);
const dataChannelCallback = jest.fn();
call.on(CallEvent.DataChannel, dataChannelCallback);
(call.peerConn as unknown as MockRTCPeerConnection).triggerIncomingDataChannel();
expect(dataChannelCallback).toHaveBeenCalled();
});
describe("supportsMatrixCall", () => { describe("supportsMatrixCall", () => {
it("should return true when the environment is right", () => { it("should return true when the environment is right", () => {
expect(supportsMatrixCall()).toBe(true); expect(supportsMatrixCall()).toBe(true);
@ -1604,7 +1642,7 @@ describe("Call", function () {
hasAdvancedBy += advanceBy; hasAdvancedBy += advanceBy;
expect(lengthChangedListener).toHaveBeenCalledTimes(hasAdvancedBy); expect(lengthChangedListener).toHaveBeenCalledTimes(hasAdvancedBy);
expect(lengthChangedListener).toHaveBeenCalledWith(hasAdvancedBy); expect(lengthChangedListener).toHaveBeenCalledWith(hasAdvancedBy, call);
} }
}); });
@ -1634,4 +1672,24 @@ describe("Call", function () {
expect(call.hangup).not.toHaveBeenCalled(); expect(call.hangup).not.toHaveBeenCalled();
}); });
}); });
describe("Call replace", () => {
it("Fires event when call replaced", async () => {
const onReplace = jest.fn();
call.on(CallEvent.Replaced, onReplace);
await call.placeVoiceCall();
const call2 = new MatrixCall({
client: client.client,
roomId: FAKE_ROOM_ID,
});
call2.on(CallEvent.Error, errorListener);
await fakeIncomingCall(client, call2);
call.replacedBy(call2);
expect(onReplace).toHaveBeenCalled();
});
});
}); });

View File

@ -102,7 +102,7 @@ describe("CallFeed", () => {
[CallState.Connected, true], [CallState.Connected, true],
[CallState.Connecting, false], [CallState.Connecting, false],
])("should react to call state, when !isLocal()", (state: CallState, expected: Boolean) => { ])("should react to call state, when !isLocal()", (state: CallState, expected: Boolean) => {
call.emit(CallEvent.State, state); call.emit(CallEvent.State, state, CallState.InviteSent, call.typed());
expect(feed.connected).toBe(expected); expect(feed.connected).toBe(expected);
}); });

View File

@ -794,7 +794,7 @@ describe("Group Call", function () {
call.isLocalVideoMuted = jest.fn().mockReturnValue(true); call.isLocalVideoMuted = jest.fn().mockReturnValue(true);
call.setLocalVideoMuted = jest.fn(); call.setLocalVideoMuted = jest.fn();
call.emit(CallEvent.State, CallState.Connected); call.emit(CallEvent.State, CallState.Connected, CallState.InviteSent, call);
expect(call.setMicrophoneMuted).toHaveBeenCalledWith(false); expect(call.setMicrophoneMuted).toHaveBeenCalledWith(false);
expect(call.setLocalVideoMuted).toHaveBeenCalledWith(false); expect(call.setLocalVideoMuted).toHaveBeenCalledWith(false);
@ -1154,7 +1154,7 @@ describe("Group Call", function () {
}); });
it("handles regular case", () => { it("handles regular case", () => {
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed()); oldMockCall.emit(CallEvent.Replaced, newMockCall.typed(), oldMockCall.typed());
expect(oldMockCall.hangup).toHaveBeenCalled(); expect(oldMockCall.hangup).toHaveBeenCalled();
expect(callChangedListener).toHaveBeenCalledWith(newCallsMap); expect(callChangedListener).toHaveBeenCalledWith(newCallsMap);
@ -1165,7 +1165,7 @@ describe("Group Call", function () {
it("handles case where call is missing from the calls map", () => { it("handles case where call is missing from the calls map", () => {
// @ts-ignore // @ts-ignore
groupCall.calls = new Map(); groupCall.calls = new Map();
oldMockCall.emit(CallEvent.Replaced, newMockCall.typed()); oldMockCall.emit(CallEvent.Replaced, newMockCall.typed(), oldMockCall.typed());
expect(oldMockCall.hangup).toHaveBeenCalled(); expect(oldMockCall.hangup).toHaveBeenCalled();
expect(callChangedListener).toHaveBeenCalledWith(newCallsMap); expect(callChangedListener).toHaveBeenCalledWith(newCallsMap);

View File

@ -296,20 +296,34 @@ export interface VoipEvent {
content: Record<string, unknown>; content: Record<string, unknown>;
} }
/**
* These now all have the call object as an argument. Why? Well, to know which call a given event is
* about you have three options:
* 1. Use a closure as the callback that remembers what call it's listening to. This can be
* a pain because you need to pass the listener function again when you remove the listener,
* which might be somewhere else.
* 2. Use not-very-well-known fact that EventEmitter sets 'this' to the emitter object in the
* callback. This doesn't really play well with modern Typescript and eslint and doesn't work
* with our pattern of re-emitting events.
* 3. Pass the object in question as an argument to the callback.
*
* Now that we have group calls which have to deal with multiple call objects, this will
* become more important, and I think methods 1 and 2 are just going to cause issues.
*/
export type CallEventHandlerMap = { export type CallEventHandlerMap = {
[CallEvent.DataChannel]: (channel: RTCDataChannel) => void; [CallEvent.DataChannel]: (channel: RTCDataChannel, call: MatrixCall) => void;
[CallEvent.FeedsChanged]: (feeds: CallFeed[]) => void; [CallEvent.FeedsChanged]: (feeds: CallFeed[], call: MatrixCall) => void;
[CallEvent.Replaced]: (newCall: MatrixCall) => void; [CallEvent.Replaced]: (newCall: MatrixCall, oldCall: MatrixCall) => void;
[CallEvent.Error]: (error: CallError) => void; [CallEvent.Error]: (error: CallError, call: MatrixCall) => void;
[CallEvent.RemoteHoldUnhold]: (onHold: boolean) => void; [CallEvent.RemoteHoldUnhold]: (onHold: boolean, call: MatrixCall) => void;
[CallEvent.LocalHoldUnhold]: (onHold: boolean) => void; [CallEvent.LocalHoldUnhold]: (onHold: boolean, call: MatrixCall) => void;
[CallEvent.LengthChanged]: (length: number) => void; [CallEvent.LengthChanged]: (length: number, call: MatrixCall) => void;
[CallEvent.State]: (state: CallState, oldState?: CallState) => void; [CallEvent.State]: (state: CallState, oldState: CallState, call: MatrixCall) => void;
[CallEvent.Hangup]: (call: MatrixCall) => void; [CallEvent.Hangup]: (call: MatrixCall) => void;
[CallEvent.AssertedIdentityChanged]: () => void; [CallEvent.AssertedIdentityChanged]: (call: MatrixCall) => void;
/* @deprecated */ /* @deprecated */
[CallEvent.HoldUnhold]: (onHold: boolean) => void; [CallEvent.HoldUnhold]: (onHold: boolean) => void;
[CallEvent.SendVoipEvent]: (event: VoipEvent) => void; [CallEvent.SendVoipEvent]: (event: VoipEvent, call: MatrixCall) => void;
}; };
// The key of the transceiver map (purpose + media type, separated by ':') // The key of the transceiver map (purpose + media type, separated by ':')
@ -459,7 +473,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
*/ */
public createDataChannel(label: string, options: RTCDataChannelInit | undefined): RTCDataChannel { public createDataChannel(label: string, options: RTCDataChannelInit | undefined): RTCDataChannel {
const dataChannel = this.peerConn!.createDataChannel(label, options); const dataChannel = this.peerConn!.createDataChannel(label, options);
this.emit(CallEvent.DataChannel, dataChannel); this.emit(CallEvent.DataChannel, dataChannel, this);
return dataChannel; return dataChannel;
} }
@ -494,7 +508,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
private set state(state: CallState) { private set state(state: CallState) {
const oldState = this._state; const oldState = this._state;
this._state = state; this._state = state;
this.emit(CallEvent.State, state, oldState); this.emit(CallEvent.State, state, oldState, this);
} }
public get type(): CallType { public get type(): CallType {
@ -684,7 +698,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
}), }),
); );
this.emit(CallEvent.FeedsChanged, this.feeds); this.emit(CallEvent.FeedsChanged, this.feeds, this);
logger.info( logger.info(
`Call ${this.callId} pushRemoteFeed() pushed stream (streamId=${stream.id}, active=${stream.active}, purpose=${purpose})`, `Call ${this.callId} pushRemoteFeed() pushed stream (streamId=${stream.id}, active=${stream.active}, purpose=${purpose})`,
@ -732,7 +746,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
}), }),
); );
this.emit(CallEvent.FeedsChanged, this.feeds); this.emit(CallEvent.FeedsChanged, this.feeds, this);
logger.info( logger.info(
`Call ${this.callId} pushRemoteFeedWithoutMetadata() pushed stream (streamId=${stream.id}, active=${stream.active})`, `Call ${this.callId} pushRemoteFeedWithoutMetadata() pushed stream (streamId=${stream.id}, active=${stream.active})`,
@ -832,7 +846,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
`Call ${this.callId} pushLocalFeed() pushed stream (id=${callFeed.stream.id}, active=${callFeed.stream.active}, purpose=${callFeed.purpose})`, `Call ${this.callId} pushLocalFeed() pushed stream (id=${callFeed.stream.id}, active=${callFeed.stream.active}, purpose=${callFeed.purpose})`,
); );
this.emit(CallEvent.FeedsChanged, this.feeds); this.emit(CallEvent.FeedsChanged, this.feeds, this);
} }
/** /**
@ -869,7 +883,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
} }
this.feeds = []; this.feeds = [];
this.emit(CallEvent.FeedsChanged, this.feeds); this.emit(CallEvent.FeedsChanged, this.feeds, this);
} }
private deleteFeedByStream(stream: MediaStream): void { private deleteFeedByStream(stream: MediaStream): void {
@ -886,7 +900,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
private deleteFeed(feed: CallFeed): void { private deleteFeed(feed: CallFeed): void {
feed.dispose(); feed.dispose();
this.feeds.splice(this.feeds.indexOf(feed), 1); this.feeds.splice(this.feeds.indexOf(feed), 1);
this.emit(CallEvent.FeedsChanged, this.feeds); this.emit(CallEvent.FeedsChanged, this.feeds, this);
} }
// The typescript definitions have this type as 'any' :( // The typescript definitions have this type as 'any' :(
@ -1117,7 +1131,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
} }
} }
this.successor = newCall; this.successor = newCall;
this.emit(CallEvent.Replaced, newCall); this.emit(CallEvent.Replaced, newCall, this);
this.hangup(CallErrorCode.Replaced, true); this.hangup(CallErrorCode.Replaced, true);
} }
@ -1188,6 +1202,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
this.emit( this.emit(
CallEvent.Error, CallEvent.Error,
new CallError(CallErrorCode.NoUserMedia, "Failed to get camera access: ", <Error>error), new CallError(CallErrorCode.NoUserMedia, "Failed to get camera access: ", <Error>error),
this,
); );
} }
} }
@ -1513,7 +1528,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
this.updateMuteStatus(); this.updateMuteStatus();
this.sendMetadataUpdate(); this.sendMetadataUpdate();
this.emit(CallEvent.RemoteHoldUnhold, this.remoteOnHold); this.emit(CallEvent.RemoteHoldUnhold, this.remoteOnHold, this);
} }
/** /**
@ -1638,7 +1653,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
code = CallErrorCode.UnknownDevices; code = CallErrorCode.UnknownDevices;
message = "Unknown devices present in the room"; message = "Unknown devices present in the room";
} }
this.emit(CallEvent.Error, new CallError(code, message, <Error>error)); this.emit(CallEvent.Error, new CallError(code, message, <Error>error), this);
throw error; throw error;
} }
@ -1987,7 +2002,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
const newLocalOnHold = this.isLocalOnHold(); const newLocalOnHold = this.isLocalOnHold();
if (prevLocalOnHold !== newLocalOnHold) { if (prevLocalOnHold !== newLocalOnHold) {
this.emit(CallEvent.LocalHoldUnhold, newLocalOnHold); this.emit(CallEvent.LocalHoldUnhold, newLocalOnHold, this);
// also this one for backwards compat // also this one for backwards compat
this.emit(CallEvent.HoldUnhold, newLocalOnHold); this.emit(CallEvent.HoldUnhold, newLocalOnHold);
} }
@ -2018,7 +2033,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
id: content.asserted_identity.id, id: content.asserted_identity.id,
displayName: content.asserted_identity.display_name, displayName: content.asserted_identity.display_name,
}; };
this.emit(CallEvent.AssertedIdentityChanged); this.emit(CallEvent.AssertedIdentityChanged, this);
} }
public callHasEnded(): boolean { public callHasEnded(): boolean {
@ -2040,6 +2055,12 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
private async wrappedGotLocalOffer(): Promise<void> { private async wrappedGotLocalOffer(): Promise<void> {
this.makingOffer = true; this.makingOffer = true;
try { try {
// XXX: in what situations do we believe gotLocalOffer actually throws? It appears
// to handle most of its exceptions itself and terminate the call. I'm not entirely
// sure it would ever throw, so I can't add a test for these lines.
// Also the tense is different between "gotLocalOffer" and "getLocalOfferFailed" so
// it's not entirely clear whether getLocalOfferFailed is just misnamed or whether
// they've been cross-polinated somehow at some point.
await this.gotLocalOffer(); await this.gotLocalOffer();
} catch (e) { } catch (e) {
this.getLocalOfferFailed(e as Error); this.getLocalOfferFailed(e as Error);
@ -2134,7 +2155,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
message = "Unknown devices present in the room"; message = "Unknown devices present in the room";
} }
this.emit(CallEvent.Error, new CallError(code, message, <Error>error)); this.emit(CallEvent.Error, new CallError(code, message, <Error>error), this);
this.terminate(CallParty.Local, code, false); this.terminate(CallParty.Local, code, false);
// no need to carry on & send the candidate queue, but we also // no need to carry on & send the candidate queue, but we also
@ -2158,7 +2179,11 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
private getLocalOfferFailed = (err: Error): void => { private getLocalOfferFailed = (err: Error): void => {
logger.error(`Call ${this.callId} getLocalOfferFailed() running`, err); logger.error(`Call ${this.callId} getLocalOfferFailed() running`, err);
this.emit(CallEvent.Error, new CallError(CallErrorCode.LocalOfferFailed, "Failed to get local offer!", err)); this.emit(
CallEvent.Error,
new CallError(CallErrorCode.LocalOfferFailed, "Failed to get local offer!", err),
this,
);
this.terminate(CallParty.Local, CallErrorCode.LocalOfferFailed, false); this.terminate(CallParty.Local, CallErrorCode.LocalOfferFailed, false);
}; };
@ -2177,6 +2202,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
"Couldn't start capturing media! Is your microphone set up and " + "does this app have permission?", "Couldn't start capturing media! Is your microphone set up and " + "does this app have permission?",
err, err,
), ),
this,
); );
this.terminate(CallParty.Local, CallErrorCode.NoUserMedia, false); this.terminate(CallParty.Local, CallErrorCode.NoUserMedia, false);
}; };
@ -2200,7 +2226,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
this.callStartTime = Date.now(); this.callStartTime = Date.now();
this.callLengthInterval = setInterval(() => { this.callLengthInterval = setInterval(() => {
this.emit(CallEvent.LengthChanged, Math.round((Date.now() - this.callStartTime!) / 1000)); this.emit(CallEvent.LengthChanged, Math.round((Date.now() - this.callStartTime!) / 1000), this);
}, CALL_LENGTH_INTERVAL); }, CALL_LENGTH_INTERVAL);
} }
} else if (this.peerConn?.iceConnectionState == "failed") { } else if (this.peerConn?.iceConnectionState == "failed") {
@ -2267,7 +2293,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
}; };
private onDataChannel = (ev: RTCDataChannelEvent): void => { private onDataChannel = (ev: RTCDataChannelEvent): void => {
this.emit(CallEvent.DataChannel, ev.channel); this.emit(CallEvent.DataChannel, ev.channel, this);
}; };
/** /**
@ -2380,13 +2406,17 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
[ToDeviceMessageId]: uuidv4(), [ToDeviceMessageId]: uuidv4(),
}; };
this.emit(CallEvent.SendVoipEvent, { this.emit(
type: "toDevice", CallEvent.SendVoipEvent,
eventType, {
userId: this.invitee || this.getOpponentMember()?.userId, type: "toDevice",
opponentDeviceId: this.opponentDeviceId, eventType,
content, userId: this.invitee || this.getOpponentMember()?.userId,
}); opponentDeviceId: this.opponentDeviceId,
content,
},
this,
);
const userId = this.invitee || this.getOpponentMember()!.userId; const userId = this.invitee || this.getOpponentMember()!.userId;
if (this.client.getUseE2eForGroupCall()) { if (this.client.getUseE2eForGroupCall()) {
@ -2414,13 +2444,17 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
); );
} }
} else { } else {
this.emit(CallEvent.SendVoipEvent, { this.emit(
type: "sendEvent", CallEvent.SendVoipEvent,
eventType, {
roomId: this.roomId, type: "sendEvent",
content: realContent, eventType,
userId: this.invitee || this.getOpponentMember()?.userId, roomId: this.roomId,
}); content: realContent,
userId: this.invitee || this.getOpponentMember()?.userId,
},
this,
);
await this.client.sendEvent(this.roomId!, eventType, realContent); await this.client.sendEvent(this.roomId!, eventType, realContent);
} }
@ -2669,7 +2703,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
const code = CallErrorCode.SignallingFailed; const code = CallErrorCode.SignallingFailed;
const message = "Signalling failed"; const message = "Signalling failed";
this.emit(CallEvent.Error, new CallError(code, message, <Error>error)); this.emit(CallEvent.Error, new CallError(code, message, <Error>error), this);
this.hangup(code, false); this.hangup(code, false);
return; return;

View File

@ -40,6 +40,11 @@ export enum GroupCallTerminationReason {
CallEnded = "call_ended", CallEnded = "call_ended",
} }
/**
* Because event names are just strings, they do need
* to be unique over all event types of event emitter.
* Some objects could emit more then one set of events.
*/
export enum GroupCallEvent { export enum GroupCallEvent {
GroupCallStateChanged = "group_call_state_changed", GroupCallStateChanged = "group_call_state_changed",
ActiveSpeakerChanged = "active_speaker_changed", ActiveSpeakerChanged = "active_speaker_changed",
@ -49,7 +54,7 @@ export enum GroupCallEvent {
LocalScreenshareStateChanged = "local_screenshare_state_changed", LocalScreenshareStateChanged = "local_screenshare_state_changed",
LocalMuteStateChanged = "local_mute_state_changed", LocalMuteStateChanged = "local_mute_state_changed",
ParticipantsChanged = "participants_changed", ParticipantsChanged = "participants_changed",
Error = "error", Error = "group_call_error",
} }
export type GroupCallEventHandlerMap = { export type GroupCallEventHandlerMap = {