Compare commits

...

4 Commits

Author SHA1 Message Date
Robin
7d7a9ee847 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.
2025-03-14 12:52:16 -04:00
Robin
5df083f009 Stop sending call notification events
Since I'd like Element Web to stop doing any more inspection than it needs to into the MatrixRTC session state, I suggest we move all responsibility for sending MatrixRTC events to Element Call.
2025-03-13 17:04:42 -04:00
Robin
991ce70209 Remove unused layout tracking code 2025-03-13 15:38:52 -04:00
Robin
60de81b824 Remove the unused 'preload' option 2025-03-13 15:38:52 -04:00
6 changed files with 147 additions and 564 deletions

View File

@@ -23,18 +23,15 @@ import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { NamespacedValue } from "matrix-js-sdk/src/NamespacedValue";
import { type IWidgetApiRequest, type ClientWidgetApi, type IWidgetData } from "matrix-widget-api";
import {
MatrixRTCSession,
type MatrixRTCSession,
MatrixRTCSessionEvent,
type CallMembership,
MatrixRTCSessionManagerEvents,
type ICallNotifyContent,
} from "matrix-js-sdk/src/matrixrtc";
import type EventEmitter from "events";
import type { IApp } from "../stores/WidgetStore";
import SdkConfig, { DEFAULTS } from "../SdkConfig";
import SettingsStore from "../settings/SettingsStore";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler";
import { timeout } from "../utils/promise";
import WidgetUtils from "../utils/WidgetUtils";
import { WidgetType } from "../widgets/WidgetType";
@@ -45,7 +42,6 @@ import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../stores/ActiveWidge
import { getCurrentLanguage } from "../languageHandler";
import { PosthogAnalytics } from "../PosthogAnalytics";
import { UPDATE_EVENT } from "../stores/AsyncStore";
import { getJoinedNonFunctionalMembers } from "../utils/room/getJoinedNonFunctionalMembers";
import { isVideoRoom } from "../utils/video-rooms";
import { FontWatcher } from "../settings/watchers/FontWatcher";
import { type JitsiCallMemberContent, JitsiCallMemberEventType } from "../call-types";
@@ -85,15 +81,9 @@ export enum ConnectionState {
export const isConnected = (state: ConnectionState): boolean =>
state === ConnectionState.Connected || state === ConnectionState.Disconnecting;
export enum Layout {
Tile = "tile",
Spotlight = "spotlight",
}
export enum CallEvent {
ConnectionState = "connection_state",
Participants = "participants",
Layout = "layout",
Close = "close",
Destroy = "destroy",
}
@@ -104,7 +94,6 @@ interface CallEventHandlerMap {
participants: Map<RoomMember, Set<string>>,
prevParticipants: Map<RoomMember, Set<string>>,
) => void;
[CallEvent.Layout]: (layout: Layout) => void;
[CallEvent.Close]: () => void;
[CallEvent.Destroy]: () => void;
}
@@ -202,18 +191,6 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
*/
public abstract clean(): Promise<void>;
/**
* Contacts the widget to connect to the call or prompt the user to connect to the call.
* @param {MediaDeviceInfo | null} audioInput The audio input to use, or
* null to start muted.
* @param {MediaDeviceInfo | null} audioInput The video input to use, or
* null to start muted.
*/
protected abstract performConnection(
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void>;
/**
* Contacts the widget to disconnect from the call.
*/
@@ -221,28 +198,10 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
/**
* Starts the communication between the widget and the call.
* The call then waits for the necessary requirements to actually perform the connection
* or connects right away depending on the call type. (Jitsi, Legacy, ElementCall...)
* It uses the media devices set in MediaDeviceHandler.
* The widget associated with the call must be active
* for this to succeed.
* The widget associated with the call must be active for this to succeed.
* Only call this if the call state is: ConnectionState.Disconnected.
*/
public async start(): Promise<void> {
const { [MediaDeviceKindEnum.AudioInput]: audioInputs, [MediaDeviceKindEnum.VideoInput]: videoInputs } =
(await MediaDeviceHandler.getDevices())!;
let audioInput: MediaDeviceInfo | null = null;
if (!MediaDeviceHandler.startWithAudioMuted) {
const deviceId = MediaDeviceHandler.getAudioInput();
audioInput = audioInputs.find((d) => d.deviceId === deviceId) ?? audioInputs[0] ?? null;
}
let videoInput: MediaDeviceInfo | null = null;
if (!MediaDeviceHandler.startWithVideoMuted) {
const deviceId = MediaDeviceHandler.getVideoInput();
videoInput = videoInputs.find((d) => d.deviceId === deviceId) ?? videoInputs[0] ?? null;
}
const messagingStore = WidgetMessagingStore.instance;
this.messaging = messagingStore.getMessagingForUid(this.widgetUid) ?? null;
if (!this.messaging) {
@@ -263,13 +222,23 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
throw new Error(`Failed to bind call widget in room ${this.roomId}: ${e}`);
}
}
await this.performConnection(audioInput, videoInput);
}
protected setConnected(): void {
this.room.on(RoomEvent.MyMembership, this.onMyMembership);
window.addEventListener("beforeunload", this.beforeUnload);
this.connectionState = ConnectionState.Connected;
}
/**
* Manually marks the call as disconnected.
*/
protected setDisconnected(): void {
this.room.off(RoomEvent.MyMembership, this.onMyMembership);
window.removeEventListener("beforeunload", this.beforeUnload);
this.connectionState = ConnectionState.Disconnected;
}
/**
* Disconnects the user from the call.
*/
@@ -282,15 +251,6 @@ export abstract class Call extends TypedEventEmitter<CallEvent, CallEventHandler
this.close();
}
/**
* Manually marks the call as disconnected.
*/
public setDisconnected(): void {
this.room.off(RoomEvent.MyMembership, this.onMyMembership);
window.removeEventListener("beforeunload", this.beforeUnload);
this.connectionState = ConnectionState.Disconnected;
}
/**
* Stops further communication with the widget and tells the UI to close.
*/
@@ -476,66 +436,10 @@ export class JitsiCall extends Call {
});
}
protected async performConnection(
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void> {
// Ensure that the messaging doesn't get stopped while we're waiting for responses
const dontStopMessaging = new Promise<void>((resolve, reject) => {
const messagingStore = WidgetMessagingStore.instance;
const listener = (uid: string): void => {
if (uid === this.widgetUid) {
cleanup();
reject(new Error("Messaging stopped"));
}
};
const done = (): void => {
cleanup();
resolve();
};
const cleanup = (): void => {
messagingStore.off(WidgetMessagingStoreEvent.StopMessaging, listener);
this.off(CallEvent.ConnectionState, done);
};
messagingStore.on(WidgetMessagingStoreEvent.StopMessaging, listener);
this.on(CallEvent.ConnectionState, done);
});
// Empirically, it's possible for Jitsi Meet to crash instantly at startup,
// sending a hangup event that races with the rest of this method, so we need
// to add the hangup listener now rather than later
public async start(): Promise<void> {
await super.start();
this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
// Actually perform the join
const response = waitForEvent(
this.messaging!,
`action:${ElementWidgetActions.JoinCall}`,
(ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
return true;
},
);
const request = this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
audioInput: audioInput?.label ?? null,
videoInput: videoInput?.label ?? null,
});
try {
await Promise.race([Promise.all([request, response]), dontStopMessaging]);
} catch (e) {
// If it timed out, clean up our advance preparations
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
if (this.messaging!.transport.ready) {
// The messaging still exists, which means Jitsi might still be going in the background
this.messaging!.transport.send(ElementWidgetActions.HangupCall, { force: true });
}
throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);
}
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.on(ActiveWidgetStoreEvent.Undock, this.onUndock);
}
@@ -558,18 +462,17 @@ export class JitsiCall extends Call {
}
}
public setDisconnected(): void {
// During tests this.messaging can be undefined
this.messaging?.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
public close(): void {
this.messaging!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Dock, this.onDock);
ActiveWidgetStore.instance.off(ActiveWidgetStoreEvent.Undock, this.onUndock);
super.setDisconnected();
super.close();
}
public destroy(): void {
this.room.off(RoomStateEvent.Update, this.onRoomState);
this.on(CallEvent.ConnectionState, this.onConnectionState);
this.off(CallEvent.ConnectionState, this.onConnectionState);
if (this.participantsExpirationTimer !== null) {
clearTimeout(this.participantsExpirationTimer);
this.participantsExpirationTimer = null;
@@ -621,27 +524,21 @@ export class JitsiCall extends Call {
await this.messaging!.transport.send(ElementWidgetActions.SpotlightLayout, {});
};
private readonly onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
this.setConnected();
};
private readonly onHangup = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
// If we're already in the middle of a client-initiated disconnection,
// ignore the event
if (this.connectionState === ConnectionState.Disconnecting) return;
ev.preventDefault();
// In case this hangup is caused by Jitsi Meet crashing at startup,
// wait for the connection event in order to avoid racing
if (this.connectionState === ConnectionState.Disconnected) {
await waitForEvent(this, CallEvent.ConnectionState);
}
this.messaging!.transport.reply(ev.detail, {}); // ack
this.setDisconnected();
this.close();
// In video rooms we immediately want to restart the call after hangup
// The lobby will be shown again and it connects to all signals from Jitsi.
if (isVideoRoom(this.room)) {
this.start();
}
if (!isVideoRoom(this.room)) this.close();
};
}
@@ -658,14 +555,6 @@ export class ElementCall extends Call {
private settingsStoreCallEncryptionWatcher?: string;
private terminationTimer?: number;
private _layout = Layout.Tile;
public get layout(): Layout {
return this._layout;
}
protected set layout(value: Layout) {
this._layout = value;
this.emit(CallEvent.Layout, value);
}
public get presented(): boolean {
return super.presented;
@@ -688,7 +577,6 @@ export class ElementCall extends Call {
const params = new URLSearchParams({
embed: "true", // We're embedding EC within another application
// Template variables are used, so that this can be configured using the widget data.
preload: "$preload", // We want it to load in the background.
skipLobby: "$skipLobby", // Skip the lobby in case we show a lobby component of our own.
returnToLobby: "$returnToLobby", // Returns to the lobby (instead of blank screen) when the call ends. (For video rooms)
perParticipantE2EE: "$perParticipantE2EE",
@@ -728,17 +616,13 @@ export class ElementCall extends Call {
}
// Creates a new widget if there isn't any widget of typ Call in this room.
// Defaults for creating a new widget are: skipLobby = false, preload = false
// Defaults for creating a new widget are: skipLobby = false
// When there is already a widget the current widget configuration will be used or can be overwritten
// by passing the according parameters (skipLobby, preload).
//
// `preload` is deprecated. We used it for optimizing EC by using a custom EW call lobby and preloading the iframe.
// now it should always be false.
// by passing the according parameters (skipLobby).
private static createOrGetCallWidget(
roomId: string,
client: MatrixClient,
skipLobby: boolean | undefined,
preload: boolean | undefined,
returnToLobby: boolean | undefined,
): IApp {
const ecWidget = WidgetStore.instance.getApps(roomId).find((app) => WidgetType.CALL.matches(app.type));
@@ -749,9 +633,6 @@ export class ElementCall extends Call {
if (skipLobby !== undefined) {
overwrites.skipLobby = skipLobby;
}
if (preload !== undefined) {
overwrites.preload = preload;
}
if (returnToLobby !== undefined) {
overwrites.returnToLobby = returnToLobby;
}
@@ -776,7 +657,6 @@ export class ElementCall extends Call {
{},
{
skipLobby: skipLobby ?? false,
preload: preload ?? false,
returnToLobby: returnToLobby ?? false,
},
),
@@ -842,7 +722,6 @@ export class ElementCall extends Call {
room.roomId,
room.client,
undefined,
undefined,
isVideoRoom(room),
);
return new ElementCall(session, availableOrCreatedWidget, room.client);
@@ -852,99 +731,41 @@ export class ElementCall extends Call {
}
public static create(room: Room, skipLobby = false): void {
ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, false, isVideoRoom(room));
ElementCall.createOrGetCallWidget(room.roomId, room.client, skipLobby, isVideoRoom(room));
}
protected async sendCallNotify(): Promise<void> {
const room = this.room;
const existingOtherRoomCallMembers = MatrixRTCSession.callMembershipsForRoom(room).filter(
// filter all memberships where the application is m.call and the call_id is ""
(m) => {
const isRoomCallMember = m.application === "m.call" && m.callId === "";
const isThisDevice = m.deviceId === this.client.deviceId;
return isRoomCallMember && !isThisDevice;
},
);
const memberCount = getJoinedNonFunctionalMembers(room).length;
if (!isVideoRoom(room) && existingOtherRoomCallMembers.length === 0) {
// send ringing event
const content: ICallNotifyContent = {
"application": "m.call",
"m.mentions": { user_ids: [], room: true },
"notify_type": memberCount == 2 ? "ring" : "notify",
"call_id": "",
};
await room.client.sendEvent(room.roomId, EventType.CallNotify, content);
}
}
protected async performConnection(
audioInput: MediaDeviceInfo | null,
videoInput: MediaDeviceInfo | null,
): Promise<void> {
// The JoinCall action is only send if the widget is waiting for it.
if (this.widget.data?.preload) {
try {
await this.messaging!.transport.send(ElementWidgetActions.JoinCall, {
audioInput: audioInput?.label ?? null,
videoInput: videoInput?.label ?? null,
});
} catch (e) {
throw new Error(`Failed to join call in room ${this.roomId}: ${e}`);
}
}
this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout);
this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout);
public async start(): Promise<void> {
await super.start();
this.messaging!.on(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.messaging!.once(`action:${ElementWidgetActions.Close}`, this.onClose);
this.messaging!.on(`action:${ElementWidgetActions.Close}`, this.onClose);
this.messaging!.on(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute);
// TODO: if the widget informs us when the join button is clicked (widget action), so we can
// - set state to connecting
// - send call notify
const session = this.client.matrixRTC.getActiveRoomSession(this.room);
if (session) {
await waitForEvent(
session,
MatrixRTCSessionEvent.MembershipsChanged,
(_, newMemberships: CallMembership[]) =>
newMemberships.some((m) => m.sender === this.client.getUserId()),
false, // allow user to wait as long as they want (no timeout)
);
} else {
await waitForEvent(
this.client.matrixRTC,
MatrixRTCSessionManagerEvents.SessionStarted,
(roomId: string, session: MatrixRTCSession) =>
this.session.callId === session.callId && roomId === this.roomId,
false, // allow user to wait as long as they want (no timeout)
);
}
this.sendCallNotify();
}
protected async performDisconnection(): Promise<void> {
const response = waitForEvent(
this.messaging!,
`action:${ElementWidgetActions.HangupCall}`,
(ev: CustomEvent<IWidgetApiRequest>) => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
return true;
},
);
const request = this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
try {
await this.messaging!.transport.send(ElementWidgetActions.HangupCall, {});
await waitForEvent(
this.session,
MatrixRTCSessionEvent.MembershipsChanged,
(_, newMemberships: CallMembership[]) =>
!newMemberships.some((m) => m.sender === this.client.getUserId()),
);
await Promise.all([request, response]);
} catch (e) {
throw new Error(`Failed to hangup call in room ${this.roomId}: ${e}`);
}
}
public setDisconnected(): void {
this.messaging!.off(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout);
this.messaging!.off(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout);
public close(): void {
this.messaging!.off(`action:${ElementWidgetActions.JoinCall}`, this.onJoin);
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.messaging!.off(`action:${ElementWidgetActions.Close}`, this.onClose);
this.messaging!.off(`action:${ElementWidgetActions.DeviceMute}`, this.onDeviceMute);
super.setDisconnected();
super.close();
}
public destroy(): void {
@@ -966,15 +787,6 @@ export class ElementCall extends Call {
if (this.session.memberships.length === 0 && !this.presented && !this.room.isCallRoom()) this.destroy();
};
/**
* Sets the call's layout.
* @param layout The layout to switch to.
*/
public async setLayout(layout: Layout): Promise<void> {
const action = layout === Layout.Tile ? ElementWidgetActions.TileLayout : ElementWidgetActions.SpotlightLayout;
await this.messaging!.transport.send(action, {});
}
private readonly onMembershipChanged = (): void => this.updateParticipants();
private updateParticipants(): void {
@@ -1000,15 +812,20 @@ export class ElementCall extends Call {
this.messaging!.transport.reply(ev.detail, {}); // ack
};
private readonly onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
this.setConnected();
};
private readonly onHangup = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
// If we're already in the middle of a client-initiated disconnection,
// ignore the event
if (this.connectionState === ConnectionState.Disconnecting) return;
ev.preventDefault();
this.messaging!.transport.reply(ev.detail, {}); // ack
this.setDisconnected();
// In video rooms we immediately want to reconnect after hangup
// This starts the lobby again and connects to all signals from EC.
if (isVideoRoom(this.room)) {
this.start();
}
};
private readonly onClose = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
@@ -1018,18 +835,6 @@ export class ElementCall extends Call {
this.close();
};
private readonly onTileLayout = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
ev.preventDefault();
this.layout = Layout.Tile;
this.messaging!.transport.reply(ev.detail, {}); // ack
};
private readonly onSpotlightLayout = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
ev.preventDefault();
this.layout = Layout.Spotlight;
this.messaging!.transport.reply(ev.detail, {}); // ack
};
public clean(): Promise<void> {
return Promise.resolve();
}

View File

@@ -79,7 +79,6 @@ export class MockedCall extends Call {
// No action needed for any of the following methods since this is just a mock
public async clean(): Promise<void> {}
// Public to allow spying
public async performConnection(): Promise<void> {}
public async performDisconnection(): Promise<void> {}
public destroy() {

View File

@@ -151,7 +151,7 @@ describe("CallEvent", () => {
}),
);
defaultDispatcher.unregister(dispatcherRef);
await act(() => call.start());
act(() => call.setConnectionState(ConnectionState.Connected));
// Test that the leave button works
fireEvent.click(screen.getByRole("button", { name: "Leave" }));

View File

@@ -46,6 +46,7 @@ import { UIComponent } from "../../../../../src/settings/UIFeature";
import { MessagePreviewStore } from "../../../../../src/stores/room-list/MessagePreviewStore";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { ConnectionState } from "../../../../../src/models/Call";
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
@@ -215,7 +216,7 @@ describe("RoomTile", () => {
it("tracks connection state", async () => {
renderRoomTile();
screen.getByText("Video");
await act(() => call.start());
act(() => call.setConnectionState(ConnectionState.Connected));
screen.getByText("Joined");
await act(() => call.disconnect());
screen.getByText("Video");

View File

@@ -34,7 +34,6 @@ import type { Mocked } from "jest-mock";
import type { ClientWidgetApi } from "matrix-widget-api";
import {
type JitsiCallMemberContent,
Layout,
Call,
CallEvent,
ConnectionState,
@@ -42,7 +41,6 @@ import {
ElementCall,
} from "../../../src/models/Call";
import { stubClient, mkEvent, mkRoomMember, setupAsyncStoreWithClient, mockPlatformPeg } from "../../test-utils";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../src/MediaDeviceHandler";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import WidgetStore from "../../../src/stores/WidgetStore";
import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore";
@@ -52,18 +50,6 @@ import SettingsStore from "../../../src/settings/SettingsStore";
import { PosthogAnalytics } from "../../../src/PosthogAnalytics";
import { type SettingKey } from "../../../src/settings/Settings.tsx";
jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
[MediaDeviceKindEnum.AudioInput]: [
{ deviceId: "1", groupId: "1", kind: "audioinput", label: "Headphones", toJSON: () => {} },
],
[MediaDeviceKindEnum.VideoInput]: [
{ deviceId: "2", groupId: "2", kind: "videoinput", label: "Built-in webcam", toJSON: () => {} },
],
[MediaDeviceKindEnum.AudioOutput]: [],
});
jest.spyOn(MediaDeviceHandler, "getAudioInput").mockReturnValue("1");
jest.spyOn(MediaDeviceHandler, "getVideoInput").mockReturnValue("2");
const enabledSettings = new Set(["feature_group_calls", "feature_video_rooms", "feature_element_call_video_rooms"]);
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName): any => enabledSettings.has(settingName) || undefined,
@@ -136,14 +122,7 @@ const cleanUpClientRoomAndStores = (client: MatrixClient, room: Room) => {
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
};
const setUpWidget = (
call: Call,
): {
widget: Widget;
messaging: Mocked<ClientWidgetApi>;
audioMutedSpy: jest.SpyInstance<boolean, []>;
videoMutedSpy: jest.SpyInstance<boolean, []>;
} => {
const setUpWidget = (call: Call): { widget: Widget; messaging: Mocked<ClientWidgetApi> } => {
call.widget.data = { ...call.widget, skipLobby: true };
const widget = new Widget(call.widget);
@@ -161,23 +140,45 @@ const setUpWidget = (
} as unknown as Mocked<ClientWidgetApi>;
WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging);
const audioMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithAudioMuted", "get");
const videoMutedSpy = jest.spyOn(MediaDeviceHandler, "startWithVideoMuted", "get");
return { widget, messaging, audioMutedSpy, videoMutedSpy };
return { widget, messaging };
};
const cleanUpCallAndWidget = (
call: Call,
widget: Widget,
audioMutedSpy: jest.SpyInstance<boolean, []>,
videoMutedSpy: jest.SpyInstance<boolean, []>,
) => {
async function connect(call: Call, messaging: Mocked<ClientWidgetApi>, startWidget = true): Promise<void> {
async function sessionConnect() {
await new Promise<void>((r) => {
setTimeout(() => r(), 400);
});
messaging.emit(`action:${ElementWidgetActions.JoinCall}`, new CustomEvent("widgetapirequest", {}));
}
async function runTimers() {
jest.advanceTimersByTime(500);
jest.advanceTimersByTime(500);
}
sessionConnect();
await Promise.all([...(startWidget ? [call.start()] : []), runTimers()]);
}
async function disconnect(call: Call, messaging: Mocked<ClientWidgetApi>): Promise<void> {
async function sessionDisconnect() {
await new Promise<void>((r) => {
setTimeout(() => r(), 400);
});
messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
}
async function runTimers() {
jest.advanceTimersByTime(500);
jest.advanceTimersByTime(500);
}
sessionDisconnect();
const promise = call.disconnect();
runTimers();
await promise;
}
const cleanUpCallAndWidget = (call: Call, widget: Widget) => {
call.destroy();
jest.clearAllMocks();
WidgetMessagingStore.instance.stopMessaging(widget, call.roomId);
audioMutedSpy.mockRestore();
videoMutedSpy.mockRestore();
};
describe("JitsiCall", () => {
@@ -221,8 +222,6 @@ describe("JitsiCall", () => {
let call: JitsiCall;
let widget: Widget;
let messaging: Mocked<ClientWidgetApi>;
let audioMutedSpy: jest.SpyInstance<boolean, []>;
let videoMutedSpy: jest.SpyInstance<boolean, []>;
beforeEach(async () => {
jest.useFakeTimers();
@@ -233,7 +232,7 @@ describe("JitsiCall", () => {
if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall;
({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call));
({ widget, messaging } = setUpWidget(call));
mocked(messaging.transport).send.mockImplementation(async (action, data): Promise<any> => {
if (action === ElementWidgetActions.JoinCall) {
@@ -251,102 +250,37 @@ describe("JitsiCall", () => {
});
});
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
afterEach(() => cleanUpCallAndWidget(call, widget));
it("connects muted", async () => {
it("connects", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
audioMutedSpy.mockReturnValue(true);
videoMutedSpy.mockReturnValue(true);
await call.start();
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
audioInput: null,
videoInput: null,
});
});
it("connects unmuted", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
audioMutedSpy.mockReturnValue(false);
videoMutedSpy.mockReturnValue(false);
await call.start();
expect(call.connectionState).toBe(ConnectionState.Connected);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.JoinCall, {
audioInput: "Headphones",
videoInput: "Built-in webcam",
});
});
it("waits for messaging when connecting", async () => {
it("waits for messaging when starting", async () => {
// Temporarily remove the messaging to simulate connecting while the
// widget is still initializing
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
const connect = call.start();
const startup = call.start();
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
await connect;
await startup;
await connect(call, messaging, false);
expect(call.connectionState).toBe(ConnectionState.Connected);
});
it("doesn't stop messaging when connecting", async () => {
// Temporarily remove the messaging to simulate connecting while the
// widget is still initializing
jest.useFakeTimers();
const oldSendMock = messaging.transport.send;
mocked(messaging.transport).send.mockImplementation(async (action: string): Promise<any> => {
if (action === ElementWidgetActions.JoinCall) {
await new Promise((resolve) => setTimeout(resolve, 100));
messaging.emit(
`action:${ElementWidgetActions.JoinCall}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
}
});
expect(call.connectionState).toBe(ConnectionState.Disconnected);
const connect = call.start();
async function runTimers() {
jest.advanceTimersByTime(500);
jest.advanceTimersByTime(1000);
}
async function runStopMessaging() {
await new Promise((resolve) => setTimeout(resolve, 1000));
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
}
runStopMessaging();
runTimers();
let connectError;
try {
await connect;
} catch (e) {
console.log(e);
connectError = e;
}
expect(connectError).toBeDefined();
// const connect2 = await connect;
// expect(connect2).toThrow();
messaging.transport.send = oldSendMock;
jest.useRealTimers();
});
it("fails to connect if the widget returns an error", async () => {
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
await expect(call.start()).rejects.toBeDefined();
});
it("fails to disconnect if the widget returns an error", async () => {
await call.start();
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
await connect(call, messaging);
mocked(messaging.transport).send.mockRejectedValue(new Error("never!"));
await expect(call.disconnect()).rejects.toBeDefined();
});
it("handles remote disconnection", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.start();
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
const callback = jest.fn();
@@ -354,7 +288,6 @@ describe("JitsiCall", () => {
call.on(CallEvent.ConnectionState, callback);
messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
messaging.emit(`action:${ElementWidgetActions.Close}`, new CustomEvent("widgetapirequest", {}));
await waitFor(() => {
expect(callback).toHaveBeenNthCalledWith(1, ConnectionState.Disconnected, ConnectionState.Connected);
});
@@ -364,14 +297,14 @@ describe("JitsiCall", () => {
it("disconnects", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.start();
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
await call.disconnect();
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("disconnects when we leave the room", async () => {
await call.start();
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, KnownMembership.Leave);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
@@ -379,14 +312,14 @@ describe("JitsiCall", () => {
it("reconnects after disconnect in video rooms", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await call.start();
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
await call.disconnect();
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("remains connected if we stay in the room", async () => {
await call.start();
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, KnownMembership.Join);
expect(call.connectionState).toBe(ConnectionState.Connected);
@@ -412,7 +345,7 @@ describe("JitsiCall", () => {
// Now, stub out client.sendStateEvent so we can test our local echo
client.sendStateEvent.mockReset();
await call.start();
await connect(call, messaging);
expect(call.participants).toEqual(
new Map([
[alice, new Set(["alices_device"])],
@@ -425,8 +358,8 @@ describe("JitsiCall", () => {
});
it("updates room state when connecting and disconnecting", async () => {
await connect(call, messaging);
const now1 = Date.now();
await call.start();
await waitFor(
() =>
expect(
@@ -453,7 +386,7 @@ describe("JitsiCall", () => {
});
it("repeatedly updates room state while connected", async () => {
await call.start();
await connect(call, messaging);
await waitFor(
() =>
expect(client.sendStateEvent).toHaveBeenLastCalledWith(
@@ -483,7 +416,7 @@ describe("JitsiCall", () => {
const onConnectionState = jest.fn();
call.on(CallEvent.ConnectionState, onConnectionState);
await call.start();
await connect(call, messaging);
await call.disconnect();
expect(onConnectionState.mock.calls).toEqual([
[ConnectionState.Connected, ConnectionState.Disconnected],
@@ -498,7 +431,7 @@ describe("JitsiCall", () => {
const onParticipants = jest.fn();
call.on(CallEvent.Participants, onParticipants);
await call.start();
await connect(call, messaging);
await call.disconnect();
expect(onParticipants.mock.calls).toEqual([
[new Map([[alice, new Set(["alices_device"])]]), new Map()],
@@ -511,7 +444,7 @@ describe("JitsiCall", () => {
});
it("switches to spotlight layout when the widget becomes a PiP", async () => {
await call.start();
await connect(call, messaging);
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Undock);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
ActiveWidgetStore.instance.emit(ActiveWidgetStoreEvent.Dock);
@@ -555,7 +488,7 @@ describe("JitsiCall", () => {
});
it("doesn't clean up valid devices", async () => {
await call.start();
await connect(call, messaging);
await client.sendStateEvent(
room.roomId,
JitsiCall.MEMBER_EVENT_TYPE,
@@ -620,47 +553,6 @@ describe("ElementCall", () => {
jest.spyOn(room, "getJoinedMembers").mockReturnValue(memberIds.map((id) => ({ userId: id }) as RoomMember));
}
const callConnectProcedure = async (call: ElementCall, startWidget = true): Promise<void> => {
async function sessionConnect() {
await new Promise<void>((r) => {
setTimeout(() => r(), 400);
});
client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, call.roomId, {
sessionId: undefined,
} as unknown as MatrixRTCSession);
call.session?.emit(
MatrixRTCSessionEvent.MembershipsChanged,
[],
[{ sender: client.getUserId() } as CallMembership],
);
}
async function runTimers() {
jest.advanceTimersByTime(500);
jest.advanceTimersByTime(500);
}
sessionConnect();
await Promise.all([...(startWidget ? [call.start()] : []), runTimers()]);
};
const callDisconnectionProcedure: (call: ElementCall) => Promise<void> = async (call) => {
async function sessionDisconnect() {
await new Promise<void>((r) => {
setTimeout(() => r(), 400);
});
client.matrixRTC.emit(MatrixRTCSessionManagerEvents.SessionStarted, call.roomId, {
sessionId: undefined,
} as unknown as MatrixRTCSession);
call.session?.emit(MatrixRTCSessionEvent.MembershipsChanged, [], []);
}
async function runTimers() {
jest.advanceTimersByTime(500);
jest.advanceTimersByTime(500);
}
sessionDisconnect();
const promise = call.disconnect();
runTimers();
await promise;
};
beforeEach(() => {
jest.useFakeTimers();
({ client, room, alice } = setUpClientRoomAndStores());
@@ -835,8 +727,6 @@ describe("ElementCall", () => {
let call: ElementCall;
let widget: Widget;
let messaging: Mocked<ClientWidgetApi>;
let audioMutedSpy: jest.SpyInstance<boolean, []>;
let videoMutedSpy: jest.SpyInstance<boolean, []>;
beforeEach(async () => {
jest.useFakeTimers();
@@ -847,34 +737,28 @@ describe("ElementCall", () => {
if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall;
({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call));
({ widget, messaging } = setUpWidget(call));
});
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
afterEach(() => cleanUpCallAndWidget(call, widget));
// TODO refactor initial device configuration to use the EW settings.
// Add tests for passing EW device configuration to the widget.
it("waits for messaging when connecting", async () => {
it("waits for messaging when starting", async () => {
// Temporarily remove the messaging to simulate connecting while the
// widget is still initializing
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
const connect = callConnectProcedure(call);
const startup = call.start();
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
await connect;
await startup;
await connect(call, messaging, false);
expect(call.connectionState).toBe(ConnectionState.Connected);
});
it("fails to connect if the widget returns an error", async () => {
// we only send a JoinCall action if the widget is preloading
call.widget.data = { ...call.widget, preload: true };
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
await expect(call.start()).rejects.toBeDefined();
});
it("fails to disconnect if the widget returns an error", async () => {
await callConnectProcedure(call);
await connect(call, messaging);
mocked(messaging.transport).send.mockRejectedValue(new Error("never!!1! >:("));
await expect(call.disconnect()).rejects.toBeDefined();
});
@@ -882,7 +766,7 @@ describe("ElementCall", () => {
it("handles remote disconnection", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await callConnectProcedure(call);
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
@@ -892,62 +776,35 @@ describe("ElementCall", () => {
it("disconnects", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await callConnectProcedure(call);
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
await callDisconnectionProcedure(call);
await disconnect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("disconnects when we leave the room", async () => {
await callConnectProcedure(call);
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, KnownMembership.Leave);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("remains connected if we stay in the room", async () => {
await callConnectProcedure(call);
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
room.emit(RoomEvent.MyMembership, room, KnownMembership.Join);
expect(call.connectionState).toBe(ConnectionState.Connected);
});
it("disconnects if the widget dies", async () => {
await callConnectProcedure(call);
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
expect(call.connectionState).toBe(ConnectionState.Disconnected);
});
it("tracks layout", async () => {
await callConnectProcedure(call);
expect(call.layout).toBe(Layout.Tile);
messaging.emit(
`action:${ElementWidgetActions.SpotlightLayout}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
expect(call.layout).toBe(Layout.Spotlight);
messaging.emit(
`action:${ElementWidgetActions.TileLayout}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
expect(call.layout).toBe(Layout.Tile);
});
it("sets layout", async () => {
await callConnectProcedure(call);
await call.setLayout(Layout.Spotlight);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
await call.setLayout(Layout.Tile);
expect(messaging.transport.send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {});
});
it("acknowledges mute_device widget action", async () => {
await callConnectProcedure(call);
await connect(call, messaging);
const preventDefault = jest.fn();
const mockEv = {
preventDefault,
@@ -963,8 +820,8 @@ describe("ElementCall", () => {
const onConnectionState = jest.fn();
call.on(CallEvent.ConnectionState, onConnectionState);
await callConnectProcedure(call);
await callDisconnectionProcedure(call);
await connect(call, messaging);
await disconnect(call, messaging);
expect(onConnectionState.mock.calls).toEqual([
[ConnectionState.Connected, ConnectionState.Disconnected],
[ConnectionState.Disconnecting, ConnectionState.Connected],
@@ -985,29 +842,11 @@ describe("ElementCall", () => {
call.off(CallEvent.Participants, onParticipants);
});
it("emits events when layout changes", async () => {
await callConnectProcedure(call);
const onLayout = jest.fn();
call.on(CallEvent.Layout, onLayout);
messaging.emit(
`action:${ElementWidgetActions.SpotlightLayout}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
messaging.emit(
`action:${ElementWidgetActions.TileLayout}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
expect(onLayout.mock.calls).toEqual([[Layout.Spotlight], [Layout.Tile]]);
call.off(CallEvent.Layout, onLayout);
});
it("ends the call immediately if the session ended", async () => {
await callConnectProcedure(call);
await connect(call, messaging);
const onDestroy = jest.fn();
call.on(CallEvent.Destroy, onDestroy);
await callDisconnectionProcedure(call);
await disconnect(call, messaging);
// this will be called automatically
// disconnect -> widget sends state event -> session manager notices no-one left
client.matrixRTC.emit(
@@ -1043,39 +882,12 @@ describe("ElementCall", () => {
roomSpy.mockRestore();
addWidgetSpy.mockRestore();
});
it("sends notify event on connect in a room with more than two members", async () => {
const sendEventSpy = jest.spyOn(room.client, "sendEvent");
ElementCall.create(room);
await callConnectProcedure(Call.get(room) as ElementCall);
expect(sendEventSpy).toHaveBeenCalledWith("!1:example.org", "org.matrix.msc4075.call.notify", {
"application": "m.call",
"call_id": "",
"m.mentions": { room: true, user_ids: [] },
"notify_type": "notify",
});
});
it("sends ring on create in a DM (two participants) room", async () => {
setRoomMembers(["@user:example.com", "@user2:example.com"]);
const sendEventSpy = jest.spyOn(room.client, "sendEvent");
ElementCall.create(room);
await callConnectProcedure(Call.get(room) as ElementCall);
expect(sendEventSpy).toHaveBeenCalledWith("!1:example.org", "org.matrix.msc4075.call.notify", {
"application": "m.call",
"call_id": "",
"m.mentions": { room: true, user_ids: [] },
"notify_type": "ring",
});
});
});
describe("instance in a video room", () => {
let call: ElementCall;
let widget: Widget;
let messaging: Mocked<ClientWidgetApi>;
let audioMutedSpy: jest.SpyInstance<boolean, []>;
let videoMutedSpy: jest.SpyInstance<boolean, []>;
beforeEach(async () => {
jest.useFakeTimers();
@@ -1088,64 +900,29 @@ describe("ElementCall", () => {
if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall;
({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call));
({ widget, messaging } = setUpWidget(call));
});
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
afterEach(() => cleanUpCallAndWidget(call, widget));
it("doesn't end the call when the last participant leaves", async () => {
await callConnectProcedure(call);
await connect(call, messaging);
const onDestroy = jest.fn();
call.on(CallEvent.Destroy, onDestroy);
await callDisconnectionProcedure(call);
await disconnect(call, messaging);
expect(onDestroy).not.toHaveBeenCalled();
call.off(CallEvent.Destroy, onDestroy);
});
it("connect to call with ongoing session", async () => {
// Mock membership getter used by `roomSessionForRoom`.
// This makes sure the roomSession will not be empty.
jest.spyOn(MatrixRTCSession, "callMembershipsForRoom").mockImplementation(() => [
{ fakeVal: "fake membership", getMsUntilExpiry: () => 1000 } as unknown as CallMembership,
]);
// Create ongoing session
const roomSession = MatrixRTCSession.roomSessionForRoom(client, room);
const roomSessionEmitSpy = jest.spyOn(roomSession, "emit");
// Make sure the created session ends up in the call.
// `getActiveRoomSession` will be used during `call.connect`
// `getRoomSession` will be used during `Call.get`
client.matrixRTC.getActiveRoomSession.mockImplementation(() => {
return roomSession;
});
client.matrixRTC.getRoomSession.mockImplementation(() => {
return roomSession;
});
ElementCall.create(room);
const call = Call.get(room);
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
expect(call.session).toBe(roomSession);
await callConnectProcedure(call);
expect(roomSessionEmitSpy).toHaveBeenCalledWith(
"memberships_changed",
[],
[{ sender: "@alice:example.org" }],
);
expect(call.connectionState).toBe(ConnectionState.Connected);
call.destroy();
});
it("handles remote disconnection and reconnect right after", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await callConnectProcedure(call);
await connect(call, messaging);
expect(call.connectionState).toBe(ConnectionState.Connected);
messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
messaging.emit(`action:${ElementWidgetActions.Close}`, new CustomEvent("widgetapirequest", {}));
// We should now be able to reconnect without manually starting the widget
expect(call.connectionState).toBe(ConnectionState.Disconnected);
await callConnectProcedure(call, false);
await connect(call, messaging, false);
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connected), { interval: 5 });
});
});

View File

@@ -28,6 +28,7 @@ import "../../../../../src/stores/room-list/RoomListStore"; // must be imported
import { Algorithm } from "../../../../../src/stores/room-list/algorithms/Algorithm";
import { CallStore } from "../../../../../src/stores/CallStore";
import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore";
import { ConnectionState } from "../../../../../src/models/Call";
describe("Algorithm", () => {
useMockedCalls();
@@ -83,7 +84,7 @@ describe("Algorithm", () => {
MockedCall.create(roomWithCall, "1");
const call = CallStore.instance.getCall(roomWithCall.roomId);
if (call === null) throw new Error("Failed to create call");
if (!(call instanceof MockedCall)) throw new Error("Failed to create call");
const widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, roomWithCall.roomId, {
@@ -93,7 +94,7 @@ describe("Algorithm", () => {
// End of setup
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);
await call.start();
call.setConnectionState(ConnectionState.Connected);
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([roomWithCall, room]);
await call.disconnect();
expect(algorithm.getOrderedRooms()[DefaultTagID.Untagged]).toEqual([room, roomWithCall]);