Skip to content

Commit ce323d4

Browse files
committed
Watch for a 'join' action to know when the call is connected
Previously we were watching for changes to the room state to know when you become connected to a call. However, the room state might not change if you had a stuck membership event prior to re-joining the call. It's going to be more reliable to watch for the 'join' action that Element Call sends, and use that to track the connection state.
1 parent 5df083f commit ce323d4

File tree

2 files changed

+137
-387
lines changed

2 files changed

+137
-387
lines changed

src/models/Call.ts

Lines changed: 56 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import type EventEmitter from "events";
3232
import type { IApp } from "../stores/WidgetStore";
3333
import SdkConfig, { DEFAULTS } from "../SdkConfig";
3434
import SettingsStore from "../settings/SettingsStore";
35-
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler";
3635
import { timeout } from "../utils/promise";
3736
import WidgetUtils from "../utils/WidgetUtils";
3837
import { WidgetType } from "../widgets/WidgetType";
@@ -192,47 +191,17 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
192191
*/
193192
public abstract clean(): Promise<void>;
194193

195-
/**
196-
* Contacts the widget to connect to the call or prompt the user to connect to the call.
197-
* @param {MediaDeviceInfo | null} audioInput The audio input to use, or
198-
* null to start muted.
199-
* @param {MediaDeviceInfo | null} audioInput The video input to use, or
200-
* null to start muted.
201-
*/
202-
protected abstract performConnection(
203-
audioInput: MediaDeviceInfo | null,
204-
videoInput: MediaDeviceInfo | null,
205-
): Promise<void>;
206-
207194
/**
208195
* Contacts the widget to disconnect from the call.
209196
*/
210197
protected abstract performDisconnection(): Promise<void>;
211198

212199
/**
213200
* Starts the communication between the widget and the call.
214-
* The call then waits for the necessary requirements to actually perform the connection
215-
* or connects right away depending on the call type. (Jitsi, Legacy, ElementCall...)
216-
* It uses the media devices set in MediaDeviceHandler.
217-
* The widget associated with the call must be active
218-
* for this to succeed.
201+
* The widget associated with the call must be active for this to succeed.
219202
* Only call this if the call state is: ConnectionState.Disconnected.
220203
*/
221204
public async start(): Promise<void> {
222-
const { [MediaDeviceKindEnum.AudioInput]: audioInputs, [MediaDeviceKindEnum.VideoInput]: videoInputs } =
223-
(await MediaDeviceHandler.getDevices())!;
224-
225-
let audioInput: MediaDeviceInfo | null = null;
226-
if (!MediaDeviceHandler.startWithAudioMuted) {
227-
const deviceId = MediaDeviceHandler.getAudioInput();
228-
audioInput = audioInputs.find((d) => d.deviceId === deviceId) ?? audioInputs[0] ?? null;
229-
}
230-
let videoInput: MediaDeviceInfo | null = null;
231-
if (!MediaDeviceHandler.startWithVideoMuted) {
232-
const deviceId = MediaDeviceHandler.getVideoInput();
233-
videoInput = videoInputs.find((d) => d.deviceId === deviceId) ?? videoInputs[0] ?? null;
234-
}
235-
236205
const messagingStore = WidgetMessagingStore.instance;
237206
this.messaging = messagingStore.getMessagingForUid(this.widgetUid) ?? null;
238207
if (!this.messaging) {
@@ -253,13 +222,23 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
253222
throw new Error(`Failed to bind call widget in room ${this.roomId}: ${e}`);
254223
}
255224
}
256-
await this.performConnection(audioInput, videoInput);
225+
}
257226

227+
protected setConnected(): void {
258228
this.room.on(RoomEvent.MyMembership, this.onMyMembership);
259229
window.addEventListener("beforeunload", this.beforeUnload);
260230
this.connectionState = ConnectionState.Connected;
261231
}
262232

233+
/**
234+
* Manually marks the call as disconnected.
235+
*/
236+
protected setDisconnected(): void {
237+
this.room.off(RoomEvent.MyMembership, this.onMyMembership);
238+
window.removeEventListener("beforeunload", this.beforeUnload);
239+
this.connectionState = ConnectionState.Disconnected;
240+
}
241+
263242
/**
264243
* Disconnects the user from the call.
265244
*/
@@ -272,15 +251,6 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
272251
this.close();
273252
}
274253

275-
/**
276-
* Manually marks the call as disconnected.
277-
*/
278-
public setDisconnected(): void {
279-
this.room.off(RoomEvent.MyMembership, this.onMyMembership);
280-
window.removeEventListener("beforeunload", this.beforeUnload);
281-
this.connectionState = ConnectionState.Disconnected;
282-
}
283-
284254
/**
285255
* Stops further communication with the widget and tells the UI to close.
286256
*/
@@ -466,66 +436,10 @@ export class JitsiCall extends Call {
466436
});
467437
}
468438

469-
protected async performConnection(
470-
audioInput: MediaDeviceInfo | null,
471-
videoInput: MediaDeviceInfo | null,
472-
): Promise<void> {
473-
// Ensure that the messaging doesn't get stopped while we're waiting for responses
474-
const dontStopMessaging = new Promise<void>((resolve, reject) => {
475-
const messagingStore = WidgetMessagingStore.instance;
476-
477-
const listener = (uid: string): void => {
478-
if (uid === this.widgetUid) {
479-
cleanup();
480-
reject(new Error("Messaging stopped"));
481-
}
482-
};
483-
const done = (): void => {
484-
cleanup();
485-
resolve();
486-
};
487-
const cleanup = (): void => {
488-
messagingStore.off(WidgetMessagingStoreEvent.StopMessaging, listener);
489-
this.off(CallEvent.ConnectionState, done);
490-
};
491-
492-
messagingStore.on(WidgetMessagingStoreEvent.StopMessaging, listener);
493-
this.on(CallEvent.ConnectionState, done);
494-
});
495-
496-
// Empirically, it's possible for Jitsi Meet to crash instantly at startup,
497-
// sending a hangup event that races with the rest of this method, so we need
498-
// to add the hangup listener now rather than later
439+
public async start(): Promise<void> {
440+
await super.start();
441+
this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
499442
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
500-
501-
// Actually perform the join
502-
const response = waitForEvent(
503-
this.messaging!,
504-
`action:${ElementWidgetActions.JoinCall}`,
505-
(ev: CustomEvent<IWidgetApiRequest>) => {
506-
ev.preventDefault();
507-
this.messaging!.transport.reply(ev.detail, {}); // ack
508-
return true;
509-
},
510-
);
511-
const request = this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
512-
audioInput: audioInput?.label ?? null,
513-
videoInput: videoInput?.label ?? null,
514-
});
515-
try {
516-
await Promise.race([Promise.all([request, response]), dontStopMessaging]);
517-
} catch (e) {
518-
// If it timed out, clean up our advance preparations
519-
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
520-
521-
if (this.messaging!.transport.ready) {
522-
// The messaging still exists, which means Jitsi might still be going in the background
523-
this.messaging!.transport.send(ElementWidgetActions.HangupCall, { force: true });
524-
}
525-
526-
throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);
527-
}
528-
529443
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock);
530444
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock);
531445
}
@@ -548,18 +462,17 @@ export class JitsiCall extends Call {
548462
}
549463
}
550464

551-
public setDisconnected(): void {
552-
// During tests this.messaging can be undefined
553-
this.messaging?.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
465+
public close(): void {
466+
this.messaging!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
467+
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
554468
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock);
555469
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock);
556-
557-
super.setDisconnected();
470+
super.close();
558471
}
559472

560473
public destroy(): void {
561474
this.room.off(RoomStateEvent.Update, this.onRoomState);
562-
this.on(CallEvent.ConnectionState, this.onConnectionState);
475+
this.off(CallEvent.ConnectionState, this.onConnectionState);
563476
if (this.participantsExpirationTimer !== null) {
564477
clearTimeout(this.participantsExpirationTimer);
565478
this.participantsExpirationTimer = null;
@@ -611,27 +524,21 @@ export class JitsiCall extends Call {
611524
await this.messaging!.transport.send(ElementWidgetActions.SpotlightLayout, {});
612525
};
613526

527+
private readonly onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
528+
ev.preventDefault();
529+
this.messaging!.transport.reply(ev.detail, {}); // ack
530+
this.setConnected();
531+
};
532+
614533
private readonly onHangup = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
615534
// If we're already in the middle of a client-initiated disconnection,
616535
// ignore the event
617536
if (this.connectionState === ConnectionState.Disconnecting) return;
618537

619538
ev.preventDefault();
620-
621-
// In case this hangup is caused by Jitsi Meet crashing at startup,
622-
// wait for the connection event in order to avoid racing
623-
if (this.connectionState === ConnectionState.Disconnected) {
624-
await waitForEvent(this, CallEvent.ConnectionState);
625-
}
626-
627539
this.messaging!.transport.reply(ev.detail, {}); // ack
628540
this.setDisconnected();
629-
this.close();
630-
// In video rooms we immediately want to restart the call after hangup
631-
// The lobby will be shown again and it connects to all signals from Jitsi.
632-
if (isVideoRoom(this.room)) {
633-
this.start();
634-
}
541+
if (!isVideoRoom(this.room)) this.close();
635542
};
636543
}
637544

@@ -827,55 +734,38 @@ export class ElementCall extends Call {
827734
ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, isVideoRoom(room));
828735
}
829736

830-
protected async performConnection(
831-
audioInput: MediaDeviceInfo | null,
832-
videoInput: MediaDeviceInfo | null,
833-
): Promise<void> {
737+
public async start(): Promise<void> {
738+
await super.start();
739+
this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
834740
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
835-
this.messaging!.once(`action:${ElementWidgetActions.Close}`, this.onClose);
741+
this.messaging!.on(`action:${ElementWidgetActions.Close}`, this.onClose);
836742
this.messaging!.on(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute);
837-
838-
// TODO: if the widget informs us when the join button is clicked (widget action), so we can
839-
// - set state to connecting
840-
// - send call notify
841-
const session = this.client.matrixRTC.getActiveRoomSession(this.room);
842-
if (session) {
843-
await waitForEvent(
844-
session,
845-
MatrixRTCSessionEvent.MembershipsChanged,
846-
(_, newMemberships: CallMembership[]) =>
847-
newMemberships.some((m) => m.sender === this.client.getUserId()),
848-
false, // allow user to wait as long as they want (no timeout)
849-
);
850-
} else {
851-
await waitForEvent(
852-
this.client.matrixRTC,
853-
MatrixRTCSessionManagerEvents.SessionStarted,
854-
(roomId: string, session: MatrixRTCSession) =>
855-
this.session.callId === session.callId && roomId === this.roomId,
856-
false, // allow user to wait as long as they want (no timeout)
857-
);
858-
}
859743
}
860744

861745
protected async performDisconnection(): Promise<void> {
746+
const response = waitForEvent(
747+
this.messaging!,
748+
`action:${ElementWidgetActions.HangupCall}`,
749+
(ev: CustomEvent<IWidgetApiRequest>) => {
750+
ev.preventDefault();
751+
this.messaging!.transport.reply(ev.detail, {}); // ack
752+
return true;
753+
},
754+
);
755+
const request = this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
862756
try {
863-
await this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
864-
await waitForEvent(
865-
this.session,
866-
MatrixRTCSessionEvent.MembershipsChanged,
867-
(_, newMemberships: CallMembership[]) =>
868-
!newMemberships.some((m) => m.sender === this.client.getUserId()),
869-
);
757+
await Promise.all([request, response]);
870758
} catch (e) {
871759
throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`);
872760
}
873761
}
874762

875-
public setDisconnected(): void {
763+
public close(): void {
764+
this.messaging!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
876765
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
766+
this.messaging!.off(`action:${ElementWidgetActions.Close}`, this.onClose);
877767
this.messaging!.off(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute);
878-
super.setDisconnected();
768+
super.close();
879769
}
880770

881771
public destroy(): void {
@@ -922,15 +812,20 @@ export class ElementCall extends Call {
922812
this.messaging!.transport.reply(ev.detail, {}); // ack
923813
};
924814

815+
private readonly onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
816+
ev.preventDefault();
817+
this.messaging!.transport.reply(ev.detail, {}); // ack
818+
this.setConnected();
819+
};
820+
925821
private readonly onHangup = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
822+
// If we're already in the middle of a client-initiated disconnection,
823+
// ignore the event
824+
if (this.connectionState === ConnectionState.Disconnecting) return;
825+
926826
ev.preventDefault();
927827
this.messaging!.transport.reply(ev.detail, {}); // ack
928828
this.setDisconnected();
929-
// In video rooms we immediately want to reconnect after hangup
930-
// This starts the lobby again and connects to all signals from EC.
931-
if (isVideoRoom(this.room)) {
932-
this.start();
933-
}
934829
};
935830

936831
private readonly onClose = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {

0 commit comments

Comments
 (0)