Skip to content

Commit 019b88b

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 0fb35ed commit 019b88b

File tree

6 files changed

+143
-392
lines changed

6 files changed

+143
-392
lines changed

src/models/Call.ts

Lines changed: 56 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ import {
3131
import type EventEmitter from "events";
3232
import type { IApp } from "../stores/WidgetStore";
3333
import SettingsStore from "../settings/SettingsStore";
34-
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler";
3534
import { timeout } from "../utils/promise";
3635
import WidgetUtils from "../utils/WidgetUtils";
3736
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

@@ -855,55 +762,38 @@ export class ElementCall extends Call {
855762
ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, isVideoRoom(room));
856763
}
857764

858-
protected async performConnection(
859-
audioInput: MediaDeviceInfo | null,
860-
videoInput: MediaDeviceInfo | null,
861-
): Promise<void> {
765+
public async start(): Promise<void> {
766+
await super.start();
767+
this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
862768
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
863-
this.messaging!.once(`action:${ElementWidgetActions.Close}`, this.onClose);
769+
this.messaging!.on(`action:${ElementWidgetActions.Close}`, this.onClose);
864770
this.messaging!.on(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute);
865-
866-
// TODO: if the widget informs us when the join button is clicked (widget action), so we can
867-
// - set state to connecting
868-
// - send call notify
869-
const session = this.client.matrixRTC.getActiveRoomSession(this.room);
870-
if (session) {
871-
await waitForEvent(
872-
session,
873-
MatrixRTCSessionEvent.MembershipsChanged,
874-
(_, newMemberships: CallMembership[]) =>
875-
newMemberships.some((m) => m.sender === this.client.getUserId()),
876-
false, // allow user to wait as long as they want (no timeout)
877-
);
878-
} else {
879-
await waitForEvent(
880-
this.client.matrixRTC,
881-
MatrixRTCSessionManagerEvents.SessionStarted,
882-
(roomId: string, session: MatrixRTCSession) =>
883-
this.session.callId === session.callId && roomId === this.roomId,
884-
false, // allow user to wait as long as they want (no timeout)
885-
);
886-
}
887771
}
888772

889773
protected async performDisconnection(): Promise<void> {
774+
const response = waitForEvent(
775+
this.messaging!,
776+
`action:${ElementWidgetActions.HangupCall}`,
777+
(ev: CustomEvent<IWidgetApiRequest>) => {
778+
ev.preventDefault();
779+
this.messaging!.transport.reply(ev.detail, {}); // ack
780+
return true;
781+
},
782+
);
783+
const request = this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
890784
try {
891-
await this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
892-
await waitForEvent(
893-
this.session,
894-
MatrixRTCSessionEvent.MembershipsChanged,
895-
(_, newMemberships: CallMembership[]) =>
896-
!newMemberships.some((m) => m.sender === this.client.getUserId()),
897-
);
785+
await Promise.all([request, response]);
898786
} catch (e) {
899787
throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`);
900788
}
901789
}
902790

903-
public setDisconnected(): void {
791+
public close(): void {
792+
this.messaging!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
904793
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
794+
this.messaging!.off(`action:${ElementWidgetActions.Close}`, this.onClose);
905795
this.messaging!.off(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute);
906-
super.setDisconnected();
796+
super.close();
907797
}
908798

909799
public destroy(): void {
@@ -950,15 +840,20 @@ export class ElementCall extends Call {
950840
this.messaging!.transport.reply(ev.detail, {}); // ack
951841
};
952842

843+
private readonly onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
844+
ev.preventDefault();
845+
this.messaging!.transport.reply(ev.detail, {}); // ack
846+
this.setConnected();
847+
};
848+
953849
private readonly onHangup = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
850+
// If we're already in the middle of a client-initiated disconnection,
851+
// ignore the event
852+
if (this.connectionState === ConnectionState.Disconnecting) return;
853+
954854
ev.preventDefault();
955855
this.messaging!.transport.reply(ev.detail, {}); // ack
956856
this.setDisconnected();
957-
// In video rooms we immediately want to reconnect after hangup
958-
// This starts the lobby again and connects to all signals from EC.
959-
if (isVideoRoom(this.room)) {
960-
this.start();
961-
}
962857
};
963858

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

test/test-utils/call.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,6 @@ export class MockedCall extends Call {
7979
// No action needed for any of the following methods since this is just a mock
8080
public async clean(): Promise<void> {}
8181
// Public to allow spying
82-
public async performConnection(): Promise<void> {}
8382
public async performDisconnection(): Promise<void> {}
8483

8584
public destroy() {

test/unit-tests/components/views/messages/CallEvent-test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ describe("CallEvent", () => {
151151
}),
152152
);
153153
defaultDispatcher.unregister(dispatcherRef);
154-
await act(() => call.start());
154+
act(() => call.setConnectionState(ConnectionState.Connected));
155155

156156
// Test that the leave button works
157157
fireEvent.click(screen.getByRole("button", { name: "Leave" }));

test/unit-tests/components/views/rooms/RoomTile-test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { UIComponent } from "../../../../../src/settings/UIFeature";
4646
import { MessagePreviewStore } from "../../../../../src/stores/room-list/MessagePreviewStore";
4747
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
4848
import SettingsStore from "../../../../../src/settings/SettingsStore";
49+
import { ConnectionState } from "../../../../../src/models/Call";
4950

5051
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
5152
shouldShowComponent: jest.fn(),
@@ -215,7 +216,7 @@ describe("RoomTile", () => {
215216
it("tracks connection state", async () => {
216217
renderRoomTile();
217218
screen.getByText("Video");
218-
await act(() => call.start());
219+
act(() => call.setConnectionState(ConnectionState.Connected));
219220
screen.getByText("Joined");
220221
await act(() => call.disconnect());
221222
screen.getByText("Video");

0 commit comments

Comments
 (0)