Show error screens in group calls (#29254)

* Avoid destroying calls until they are hidden from the UI

We often want calls to exist even when no more participants are left in the MatrixRTC session. So, we should avoid destroying calls as long as they're being presented in the UI; this means that the user has an intent to either join the call or continue looking at an error screen, and we shouldn't interrupt that interaction.

The RoomViewStore is now what takes care of creating and destroying calls, rather than the CallView. In general it seems kinda impossible to safely create and destroy model objects from React lifecycle hooks, so moving this responsibility to a store seemed appropriate and resolves existing issues with calls in React strict mode.

* Wait for a close action before closing a call

This creates a distinction between the user hanging up and the widget being ready to close, which is useful for allowing Element Call to show error screens when disconnected from the call, for example.

* Don't expect a 'close' action in video rooms

These use the returnToLobby option and are expected to remain visible when the user leaves the call.
This commit is contained in:
Robin
2025-03-05 15:41:26 -05:00
committed by GitHub
parent e9a3625bd6
commit aa996010b4
23 changed files with 344 additions and 364 deletions

View File

@@ -109,5 +109,5 @@ export class MockedCall extends Call {
export const useMockedCalls = () => {
Call.get = (room) => MockedCall.get(room);
JitsiCall.create = async (room) => MockedCall.create(room, "1");
ElementCall.create = async (room) => MockedCall.create(room, "1");
ElementCall.create = (room) => MockedCall.create(room, "1");
};

View File

@@ -80,7 +80,6 @@ export function getRoomContext(room: Room, override: Partial<IRoomState>): IRoom
canSelfRedact: false,
resizing: false,
narrow: false,
activeCall: null,
msc3946ProcessDynamicPredecessor: false,
canAskToJoin: false,
promptAskToJoin: false,

View File

@@ -76,6 +76,15 @@ import { SearchScope } from "../../../../src/Searching";
import { MEGOLM_ENCRYPTION_ALGORITHM } from "../../../../src/utils/crypto";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import { type ViewUserPayload } from "../../../../src/dispatcher/payloads/ViewUserPayload.ts";
import { CallStore } from "../../../../src/stores/CallStore.ts";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../../src/MediaDeviceHandler.ts";
// Used by group calls
jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
[MediaDeviceKindEnum.AudioInput]: [],
[MediaDeviceKindEnum.VideoInput]: [],
[MediaDeviceKindEnum.AudioOutput]: [],
});
describe("RoomView", () => {
let cli: MockedObject<MatrixClient>;
@@ -98,6 +107,7 @@ describe("RoomView", () => {
rooms = new Map();
rooms.set(room.roomId, room);
cli.getRoom.mockImplementation((roomId: string | undefined) => rooms.get(roomId || "") || null);
cli.getRooms.mockImplementation(() => [...rooms.values()]);
// Re-emit certain events on the mocked client
room.on(RoomEvent.Timeline, (...args) => cli.emit(RoomEvent.Timeline, ...args));
room.on(RoomEvent.TimelineReset, (...args) => cli.emit(RoomEvent.TimelineReset, ...args));
@@ -371,6 +381,7 @@ describe("RoomView", () => {
describe("video rooms", () => {
beforeEach(async () => {
await setupAsyncStoreWithClient(CallStore.instance, MatrixClientPeg.safeGet());
// Make it a video room
room.isElementVideoRoom = () => true;
await SettingsStore.setValue("feature_video_rooms", null, SettingLevel.DEVICE, true);

View File

@@ -2006,6 +2006,41 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</div>
</div>
</header>
<div
class="mx_CallView"
role="main"
>
<div
class="mx_AppTile"
id="vY7Q4uEh9K38QgU2PomxwKpa"
>
<div
class="mx_AppTileBody mx_AppTileBody--large mx_AppTileBody--loading mx_AppTileBody--call"
>
<div
class="mx_AppTileBody_fadeInSpinner"
>
<div
class="mx_Spinner"
>
<div
class="mx_Spinner_Msg"
>
Loading…
</div>
 
<div
aria-label="Loading…"
class="mx_Spinner_icon"
data-testid="spinner"
role="progressbar"
style="width: 32px; height: 32px;"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div
class="mx_RightPanel_ResizeWrapper"

View File

@@ -35,6 +35,7 @@ import {
} from "jest-matrix-react";
import { type ViewRoomOpts } from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
import { mocked } from "jest-mock";
import userEvent from "@testing-library/user-event";
import { filterConsole, stubClient } from "../../../../../test-utils";
import RoomHeader from "../../../../../../src/components/views/rooms/RoomHeader/RoomHeader";
@@ -106,13 +107,15 @@ describe("RoomHeader", () => {
});
it("opens the room summary", async () => {
const user = userEvent.setup();
const { container } = render(<RoomHeader room={room} />, getWrapper());
fireEvent.click(getByText(container, ROOM_ID));
await user.click(getByText(container, ROOM_ID));
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomSummary });
});
it("shows a face pile for rooms", async () => {
const user = userEvent.setup();
const members = [
{
userId: "@me:example.org",
@@ -161,33 +164,36 @@ describe("RoomHeader", () => {
const facePile = getByLabelText(document.body, "4 members");
expect(facePile).toHaveTextContent("4");
fireEvent.click(facePile);
await user.click(facePile);
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.MemberList });
});
it("has room info icon that opens the room info panel", async () => {
const user = userEvent.setup();
const { getAllByRole } = render(<RoomHeader room={room} />, getWrapper());
const infoButton = getAllByRole("button", { name: "Room info" })[1];
fireEvent.click(infoButton);
await user.click(infoButton);
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomSummary });
});
it("opens the thread panel", async () => {
const user = userEvent.setup();
render(<RoomHeader room={room} />, getWrapper());
fireEvent.click(getByLabelText(document.body, "Threads"));
await user.click(getByLabelText(document.body, "Threads"));
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.ThreadPanel });
});
it("opens the notifications panel", async () => {
const user = userEvent.setup();
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string): any => {
if (name === "feature_notifications") return true;
});
render(<RoomHeader room={room} />, getWrapper());
fireEvent.click(getByLabelText(document.body, "Notifications"));
await user.click(getByLabelText(document.body, "Notifications"));
expect(setCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.NotificationPanel });
});
@@ -274,6 +280,7 @@ describe("RoomHeader", () => {
});
it("you can call when you're two in the room", async () => {
const user = userEvent.setup();
mockRoomMembers(room, 2);
render(<RoomHeader room={room} />, getWrapper());
@@ -284,10 +291,10 @@ describe("RoomHeader", () => {
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
fireEvent.click(voiceButton);
await user.click(voiceButton);
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice);
fireEvent.click(videoButton);
await user.click(videoButton);
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
});
@@ -332,6 +339,7 @@ describe("RoomHeader", () => {
});
it("renders only the video call element", async () => {
const user = userEvent.setup();
mockRoomMembers(room, 3);
jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true });
// allow element calls
@@ -344,9 +352,9 @@ describe("RoomHeader", () => {
const videoCallButton = screen.getByRole("button", { name: "Video call" });
expect(videoCallButton).not.toHaveAttribute("aria-disabled", "true");
const dispatcherSpy = jest.spyOn(dispatcher, "dispatch");
const dispatcherSpy = jest.spyOn(dispatcher, "dispatch").mockImplementation();
fireEvent.click(videoCallButton);
await user.click(videoCallButton);
expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true }));
});
@@ -366,7 +374,8 @@ describe("RoomHeader", () => {
expect(screen.getByRole("button", { name: "Ongoing call" })).toHaveAttribute("aria-disabled", "true");
});
it("clicking on ongoing (unpinned) call re-pins it", () => {
it("clicking on ongoing (unpinned) call re-pins it", async () => {
const user = userEvent.setup();
mockRoomMembers(room, 3);
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature == UIFeature.Widgets);
// allow calls
@@ -386,7 +395,7 @@ describe("RoomHeader", () => {
const videoButton = screen.getByRole("button", { name: "Video call" });
expect(videoButton).not.toHaveAttribute("aria-disabled", "true");
fireEvent.click(videoButton);
await user.click(videoButton);
expect(spy).toHaveBeenCalledWith(room, widget, Container.Top);
});
@@ -463,6 +472,7 @@ describe("RoomHeader", () => {
});
it("calls using legacy or jitsi", async () => {
const user = userEvent.setup();
mockRoomMembers(room, 2);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
if (key === "im.vector.modular.widgets") return true;
@@ -476,14 +486,15 @@ describe("RoomHeader", () => {
expect(videoButton).not.toHaveAttribute("aria-disabled", "true");
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
fireEvent.click(voiceButton);
await user.click(voiceButton);
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Voice);
fireEvent.click(videoButton);
await user.click(videoButton);
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
});
it("calls using legacy or jitsi for large rooms", async () => {
const user = userEvent.setup();
mockRoomMembers(room, 3);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
@@ -497,11 +508,12 @@ describe("RoomHeader", () => {
expect(videoButton).not.toHaveAttribute("aria-disabled", "true");
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall");
fireEvent.click(videoButton);
await user.click(videoButton);
expect(placeCallSpy).toHaveBeenLastCalledWith(room.roomId, CallType.Video);
});
it("calls using element call for large rooms", async () => {
const user = userEvent.setup();
mockRoomMembers(room, 3);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockImplementation((key) => {
@@ -514,8 +526,8 @@ describe("RoomHeader", () => {
const videoButton = screen.getByRole("button", { name: "Video call" });
expect(videoButton).not.toHaveAttribute("aria-disabled", "true");
const dispatcherSpy = jest.spyOn(dispatcher, "dispatch");
fireEvent.click(videoButton);
const dispatcherSpy = jest.spyOn(dispatcher, "dispatch").mockImplementation();
await user.click(videoButton);
expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true }));
});
@@ -750,10 +762,11 @@ describe("RoomHeader", () => {
});
it("should open room settings when clicking the room avatar", async () => {
const user = userEvent.setup();
render(<RoomHeader room={room} />, getWrapper());
const dispatcherSpy = jest.spyOn(dispatcher, "dispatch");
fireEvent.click(getByLabelText(document.body, "Open room settings"));
await user.click(getByLabelText(document.body, "Open room settings"));
expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ action: "open_room_settings" }));
});
});

View File

@@ -43,7 +43,8 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
</div>
</button>
<button
aria-labelledby=":r16c:"
aria-disabled="true"
aria-label="There's no one here to call"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
@@ -51,9 +52,10 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
style="--cpd-icon-button-size: 100%; --cpd-color-icon-tertiary: var(--cpd-color-icon-disabled);"
>
<svg
aria-labelledby=":r166:"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
@@ -61,7 +63,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
/>
</svg>
</div>
@@ -69,7 +71,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
<button
aria-disabled="true"
aria-label="There's no one here to call"
aria-labelledby=":r16h:"
aria-labelledby=":r16b:"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
@@ -94,7 +96,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
</button>
<button
aria-label="Threads"
aria-labelledby=":r16m:"
aria-labelledby=":r16g:"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
@@ -120,7 +122,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
</button>
<button
aria-label="Room info"
aria-labelledby=":r16r:"
aria-labelledby=":r16l:"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"

View File

@@ -46,7 +46,6 @@ 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(),
@@ -216,41 +215,10 @@ describe("RoomTile", () => {
it("tracks connection state", async () => {
renderRoomTile();
screen.getByText("Video");
let completeWidgetLoading: () => void = () => {};
const widgetLoadingCompleted = new Promise<void>((resolve) => (completeWidgetLoading = resolve));
// Insert an await point in the connection method so we can inspect
// the intermediate connecting state
let completeConnection: () => void = () => {};
const connectionCompleted = new Promise<void>((resolve) => (completeConnection = resolve));
let completeLobby: () => void = () => {};
const lobbyCompleted = new Promise<void>((resolve) => (completeLobby = resolve));
jest.spyOn(call, "performConnection").mockImplementation(async () => {
call.setConnectionState(ConnectionState.WidgetLoading);
await widgetLoadingCompleted;
call.setConnectionState(ConnectionState.Lobby);
await lobbyCompleted;
call.setConnectionState(ConnectionState.Connecting);
await connectionCompleted;
});
await Promise.all([
(async () => {
await screen.findByText("Loading…");
completeWidgetLoading();
await screen.findByText("Lobby");
completeLobby();
await screen.findByText("Joining…");
completeConnection();
await screen.findByText("Joined");
})(),
call.start(),
]);
await Promise.all([screen.findByText("Video"), call.disconnect()]);
await act(() => call.start());
screen.getByText("Joined");
await act(() => call.disconnect());
screen.getByText("Video");
});
it("tracks participants", () => {

View File

@@ -73,7 +73,6 @@ describe("<SendMessageComposer/>", () => {
canSelfRedact: false,
resizing: false,
narrow: false,
activeCall: null,
msc3946ProcessDynamicPredecessor: false,
canAskToJoin: false,
promptAskToJoin: false,

View File

@@ -7,8 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { zip } from "lodash";
import { render, screen, act, fireEvent, waitFor, cleanup } from "jest-matrix-react";
import { render, screen, act, cleanup } from "jest-matrix-react";
import { mocked, type Mocked } from "jest-mock";
import {
type MatrixClient,
@@ -33,7 +32,6 @@ import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import { CallView as _CallView } from "../../../../../src/components/views/voip/CallView";
import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore";
import { CallStore } from "../../../../../src/stores/CallStore";
import { Call, ConnectionState } from "../../../../../src/models/Call";
const CallView = wrapInMatrixClientContext(_CallView);
@@ -44,6 +42,8 @@ describe("CallView", () => {
let client: Mocked<MatrixClient>;
let room: Room;
let alice: RoomMember;
let call: MockedCall;
let widget: Widget;
beforeEach(() => {
useMockMediaDevices();
@@ -63,117 +63,43 @@ describe("CallView", () => {
setupAsyncStoreWithClient(CallStore.instance, client);
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
MockedCall.create(room, "1");
const maybeCall = CallStore.instance.getCall(room.roomId);
if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call");
call = maybeCall;
widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
stop: () => {},
} as unknown as ClientWidgetApi);
});
afterEach(() => {
cleanup(); // Unmount before we do any cleanup that might update the component
call.destroy();
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
});
const renderView = async (skipLobby = false, role: string | undefined = undefined): Promise<void> => {
render(<CallView room={room} resizing={false} waitForCall={false} skipLobby={skipLobby} role={role} />);
render(<CallView room={room} resizing={false} skipLobby={skipLobby} role={role} onClose={() => {}} />);
await act(() => Promise.resolve()); // Let effects settle
};
describe("with an existing call", () => {
let call: MockedCall;
let widget: Widget;
beforeEach(() => {
MockedCall.create(room, "1");
const maybeCall = CallStore.instance.getCall(room.roomId);
if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call");
call = maybeCall;
widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
stop: () => {},
} as unknown as ClientWidgetApi);
});
afterEach(() => {
cleanup(); // Unmount before we do any cleanup that might update the component
call.destroy();
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
});
it("accepts an accessibility role", async () => {
await renderView(undefined, "main");
screen.getByRole("main");
});
it("calls clean on mount", async () => {
const cleanSpy = jest.spyOn(call, "clean");
await renderView();
expect(cleanSpy).toHaveBeenCalled();
});
/**
* TODO: Fix I do not understand this test
*/
it.skip("tracks participants", async () => {
const bob = mkRoomMember(room.roomId, "@bob:example.org");
const carol = mkRoomMember(room.roomId, "@carol:example.org");
const expectAvatars = (userIds: string[]) => {
const avatars = screen.queryAllByRole("button", { name: "Profile picture" });
expect(userIds.length).toBe(avatars.length);
for (const [userId, avatar] of zip(userIds, avatars)) {
fireEvent.focus(avatar!);
screen.getAllByRole("tooltip", { name: userId });
}
};
await renderView();
expect(screen.queryByLabelText(/joined/)).toBe(null);
expectAvatars([]);
act(() => {
call.participants = new Map([[alice, new Set(["a"])]]);
});
screen.getByText("1 person joined");
expectAvatars([alice.userId]);
act(() => {
call.participants = new Map([
[alice, new Set(["a"])],
[bob, new Set(["b1", "b2"])],
[carol, new Set(["c"])],
]);
});
screen.getByText("4 people joined");
expectAvatars([alice.userId, bob.userId, bob.userId, carol.userId]);
act(() => {
call.participants = new Map();
});
expect(screen.queryByLabelText(/joined/)).toBe(null);
expectAvatars([]);
});
it("automatically connects to the call when skipLobby is true", async () => {
const connectSpy = jest.spyOn(call, "start");
await renderView(true);
await waitFor(() => expect(connectSpy).toHaveBeenCalled(), { interval: 1 });
});
it("accepts an accessibility role", async () => {
await renderView(undefined, "main");
screen.getByRole("main");
});
describe("without an existing call", () => {
it("creates and connects to a new call when the join button is pressed", async () => {
expect(Call.get(room)).toBeNull();
await renderView(true);
await waitFor(() => expect(CallStore.instance.getCall(room.roomId)).not.toBeNull());
const call = CallStore.instance.getCall(room.roomId)!;
it("calls clean on mount", async () => {
const cleanSpy = jest.spyOn(call, "clean");
await renderView();
expect(cleanSpy).toHaveBeenCalled();
});
const widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
stop: () => {},
} as unknown as ClientWidgetApi);
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connected));
cleanup(); // Unmount before we do any cleanup that might update the component
call.destroy();
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
});
it("updates the call's skipLobby parameter", async () => {
await renderView(true);
expect(call.widget.data?.skipLobby).toBe(true);
});
});

View File

@@ -235,16 +235,16 @@ describe("JitsiCall", () => {
({ widget, messaging, audioMutedSpy, videoMutedSpy } = setUpWidget(call));
mocked(messaging.transport).send.mockImplementation(async (action: string): Promise<any> => {
mocked(messaging.transport).send.mockImplementation(async (action, data): Promise<any> => {
if (action === ElementWidgetActions.JoinCall) {
messaging.emit(
`action:${ElementWidgetActions.JoinCall}`,
new CustomEvent("widgetapirequest", { detail: {} }),
new CustomEvent("widgetapirequest", { detail: { data } }),
);
} else if (action === ElementWidgetActions.HangupCall) {
messaging.emit(
`action:${ElementWidgetActions.HangupCall}`,
new CustomEvent("widgetapirequest", { detail: {} }),
new CustomEvent("widgetapirequest", { detail: { data } }),
);
}
return {};
@@ -286,8 +286,6 @@ describe("JitsiCall", () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
const connect = call.start();
expect(call.connectionState).toBe(ConnectionState.WidgetLoading);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
await connect;
expect(call.connectionState).toBe(ConnectionState.Connected);
@@ -310,7 +308,6 @@ describe("JitsiCall", () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
const connect = call.start();
expect(call.connectionState).toBe(ConnectionState.WidgetLoading);
async function runTimers() {
jest.advanceTimersByTime(500);
jest.advanceTimersByTime(1000);
@@ -356,18 +353,10 @@ describe("JitsiCall", () => {
call.on(CallEvent.ConnectionState, callback);
messaging.emit(
`action:${ElementWidgetActions.HangupCall}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
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);
expect(callback).toHaveBeenNthCalledWith(
2,
ConnectionState.WidgetLoading,
ConnectionState.Disconnected,
);
expect(callback).toHaveBeenNthCalledWith(3, ConnectionState.Connecting, ConnectionState.WidgetLoading);
});
// in video rooms we expect the call to immediately reconnect
call.off(CallEvent.ConnectionState, callback);
@@ -497,10 +486,7 @@ describe("JitsiCall", () => {
await call.start();
await call.disconnect();
expect(onConnectionState.mock.calls).toEqual([
[ConnectionState.WidgetLoading, ConnectionState.Disconnected],
[ConnectionState.Connecting, ConnectionState.WidgetLoading],
[ConnectionState.Lobby, ConnectionState.Connecting],
[ConnectionState.Connected, ConnectionState.Lobby],
[ConnectionState.Connected, ConnectionState.Disconnected],
[ConnectionState.Disconnecting, ConnectionState.Connected],
[ConnectionState.Disconnected, ConnectionState.Disconnecting],
]);
@@ -634,7 +620,7 @@ describe("ElementCall", () => {
jest.spyOn(room, "getJoinedMembers").mockReturnValue(memberIds.map((id) => ({ userId: id }) as RoomMember));
}
const callConnectProcedure: (call: ElementCall) => Promise<void> = async (call) => {
const callConnectProcedure = async (call: ElementCall, startWidget = true): Promise<void> => {
async function sessionConnect() {
await new Promise<void>((r) => {
setTimeout(() => r(), 400);
@@ -653,9 +639,7 @@ describe("ElementCall", () => {
jest.advanceTimersByTime(500);
}
sessionConnect();
const promise = call.start();
runTimers();
await promise;
await Promise.all([...(startWidget ? [call.start()] : []), runTimers()]);
};
const callDisconnectionProcedure: (call: ElementCall) => Promise<void> = async (call) => {
async function sessionDisconnect() {
@@ -683,6 +667,7 @@ describe("ElementCall", () => {
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
cleanUpClientRoomAndStores(client, room);
});
@@ -693,7 +678,7 @@ describe("ElementCall", () => {
});
it("finds calls", async () => {
await ElementCall.create(room);
ElementCall.create(room);
expect(Call.get(room)).toBeInstanceOf(ElementCall);
Call.get(room)?.destroy();
});
@@ -728,7 +713,7 @@ describe("ElementCall", () => {
};
document.documentElement.style.fontSize = "12px";
await ElementCall.create(room);
ElementCall.create(room);
const call = Call.get(room);
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
@@ -741,7 +726,7 @@ describe("ElementCall", () => {
it("passes ICE fallback preference through widget URL", async () => {
// Test with the preference set to false
await ElementCall.create(room);
ElementCall.create(room);
const call1 = Call.get(room);
if (!(call1 instanceof ElementCall)) throw new Error("Failed to create call");
@@ -780,7 +765,7 @@ describe("ElementCall", () => {
}
return undefined;
});
await ElementCall.create(room);
ElementCall.create(room);
const call = Call.get(room);
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
@@ -798,7 +783,7 @@ describe("ElementCall", () => {
}
return undefined;
});
await ElementCall.create(room);
ElementCall.create(room);
const call = Call.get(room);
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
@@ -820,7 +805,7 @@ describe("ElementCall", () => {
: originalGetValue(name, roomId, excludeDefault);
}
};
await ElementCall.create(room);
ElementCall.create(room);
const call = Call.get(room);
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
@@ -837,7 +822,7 @@ describe("ElementCall", () => {
}
return undefined;
});
await ElementCall.create(room);
ElementCall.create(room);
const call = Call.get(room);
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
@@ -857,7 +842,7 @@ describe("ElementCall", () => {
jest.useFakeTimers();
jest.setSystemTime(0);
await ElementCall.create(room, true);
ElementCall.create(room, true);
const maybeCall = ElementCall.get(room);
if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall;
@@ -876,9 +861,6 @@ describe("ElementCall", () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
const connect = callConnectProcedure(call);
expect(call.connectionState).toBe(ConnectionState.WidgetLoading);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, messaging);
await connect;
expect(call.connectionState).toBe(ConnectionState.Connected);
@@ -903,10 +885,8 @@ describe("ElementCall", () => {
await callConnectProcedure(call);
expect(call.connectionState).toBe(ConnectionState.Connected);
messaging.emit(
`action:${ElementWidgetActions.HangupCall}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
messaging.emit(`action:${ElementWidgetActions.HangupCall}`, new CustomEvent("widgetapirequest", {}));
messaging.emit(`action:${ElementWidgetActions.Close}`, new CustomEvent("widgetapirequest", {}));
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Disconnected), { interval: 5 });
});
@@ -986,9 +966,7 @@ describe("ElementCall", () => {
await callConnectProcedure(call);
await callDisconnectionProcedure(call);
expect(onConnectionState.mock.calls).toEqual([
[ConnectionState.WidgetLoading, ConnectionState.Disconnected],
[ConnectionState.Connecting, ConnectionState.WidgetLoading],
[ConnectionState.Connected, ConnectionState.Connecting],
[ConnectionState.Connected, ConnectionState.Disconnected],
[ConnectionState.Disconnecting, ConnectionState.Connected],
[ConnectionState.Disconnected, ConnectionState.Disconnecting],
]);
@@ -1068,7 +1046,7 @@ describe("ElementCall", () => {
it("sends notify event on connect in a room with more than two members", async () => {
const sendEventSpy = jest.spyOn(room.client, "sendEvent");
await ElementCall.create(room);
ElementCall.create(room);
await callConnectProcedure(Call.get(room) as ElementCall);
expect(sendEventSpy).toHaveBeenCalledWith("!1:example.org", "org.matrix.msc4075.call.notify", {
"application": "m.call",
@@ -1081,7 +1059,7 @@ describe("ElementCall", () => {
setRoomMembers(["@user:example.com", "@user2:example.com"]);
const sendEventSpy = jest.spyOn(room.client, "sendEvent");
await ElementCall.create(room);
ElementCall.create(room);
await callConnectProcedure(Call.get(room) as ElementCall);
expect(sendEventSpy).toHaveBeenCalledWith("!1:example.org", "org.matrix.msc4075.call.notify", {
"application": "m.call",
@@ -1105,7 +1083,7 @@ describe("ElementCall", () => {
jest.spyOn(room, "getType").mockReturnValue(RoomType.UnstableCall);
await ElementCall.create(room);
ElementCall.create(room);
const maybeCall = ElementCall.get(room);
if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall;
@@ -1144,7 +1122,7 @@ describe("ElementCall", () => {
return roomSession;
});
await ElementCall.create(room);
ElementCall.create(room);
const call = Call.get(room);
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
expect(call.session).toBe(roomSession);
@@ -1163,12 +1141,12 @@ describe("ElementCall", () => {
await callConnectProcedure(call);
expect(call.connectionState).toBe(ConnectionState.Connected);
messaging.emit(
`action:${ElementWidgetActions.HangupCall}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
// We want the call to be connecting after the hangup.
waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connecting), { interval: 5 });
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 waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connected), { interval: 5 });
});
});
describe("create call", () => {
@@ -1180,7 +1158,7 @@ describe("ElementCall", () => {
{ application: "m.call", callId: "" } as unknown as CallMembership,
]);
const sendEventSpy = jest.spyOn(room.client, "sendEvent");
await ElementCall.create(room);
ElementCall.create(room);
expect(sendEventSpy).not.toHaveBeenCalled();
});
});

View File

@@ -13,10 +13,16 @@ import {
RoomViewLifecycle,
type ViewRoomOpts,
} from "@matrix-org/react-sdk-module-api/lib/lifecycles/RoomViewLifecycle";
import EventEmitter from "events";
import { RoomViewStore } from "../../../src/stores/RoomViewStore";
import { Action } from "../../../src/dispatcher/actions";
import { getMockClientWithEventEmitter, untilDispatch, untilEmission } from "../../test-utils";
import {
getMockClientWithEventEmitter,
setupAsyncStoreWithClient,
untilDispatch,
untilEmission,
} from "../../test-utils";
import SettingsStore from "../../../src/settings/SettingsStore";
import { SlidingSyncManager } from "../../../src/SlidingSyncManager";
import { PosthogAnalytics } from "../../../src/PosthogAnalytics";
@@ -33,6 +39,10 @@ import { type CancelAskToJoinPayload } from "../../../src/dispatcher/payloads/Ca
import { type JoinRoomErrorPayload } from "../../../src/dispatcher/payloads/JoinRoomErrorPayload";
import { type SubmitAskToJoinPayload } from "../../../src/dispatcher/payloads/SubmitAskToJoinPayload";
import { ModuleRunner } from "../../../src/modules/ModuleRunner";
import { type IApp } from "../../../src/utils/WidgetUtils-types";
import { CallStore } from "../../../src/stores/CallStore";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../src/MediaDeviceHandler";
jest.mock("../../../src/Modal");
@@ -60,6 +70,12 @@ jest.mock("../../../src/audio/VoiceRecording", () => ({
}),
}));
jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
[MediaDeviceKindEnum.AudioInput]: [],
[MediaDeviceKindEnum.VideoInput]: [],
[MediaDeviceKindEnum.AudioOutput]: [],
});
jest.mock("../../../src/utils/DMRoomMap", () => {
const mock = {
getUserIdForRoomId: jest.fn(),
@@ -72,7 +88,21 @@ jest.mock("../../../src/utils/DMRoomMap", () => {
};
});
jest.mock("../../../src/stores/WidgetStore");
jest.mock("../../../src/stores/WidgetStore", () => {
// This mock needs to use a real EventEmitter; require is the only way to import that in a hoisted block
// eslint-disable-next-line @typescript-eslint/no-require-imports
const EventEmitter = require("events");
const apps: IApp[] = [];
const instance = new (class extends EventEmitter {
getApps() {
return apps;
}
addVirtualWidget(app: IApp) {
apps.push(app);
}
})();
return { instance };
});
jest.mock("../../../src/stores/widgets/WidgetLayoutStore");
describe("RoomViewStore", function () {
@@ -82,10 +112,12 @@ describe("RoomViewStore", function () {
// we need to change the alias to ensure cache misses as the cache exists
// through all tests.
let alias = "#somealias2:aser.ver";
const getRooms = jest.fn();
const mockClient = getMockClientWithEventEmitter({
joinRoom: jest.fn(),
getRoom: jest.fn(),
getRoomIdForAlias: jest.fn(),
getRooms,
isGuest: jest.fn(),
getUserId: jest.fn().mockReturnValue(userId),
getSafeUserId: jest.fn().mockReturnValue(userId),
@@ -97,9 +129,18 @@ describe("RoomViewStore", function () {
knockRoom: jest.fn(),
leave: jest.fn(),
setRoomAccountData: jest.fn(),
getAccountData: jest.fn(),
matrixRTC: new (class extends EventEmitter {
getRoomSession() {
return new (class extends EventEmitter {
memberships = [];
})();
}
})(),
});
const room = new Room(roomId, mockClient, userId);
const room2 = new Room(roomId2, mockClient, userId);
getRooms.mockReturnValue([room, room2]);
const viewCall = async (): Promise<void> => {
dis.dispatch<ViewRoomPayload>({
@@ -301,6 +342,7 @@ describe("RoomViewStore", function () {
});
it("when viewing a call without a broadcast, it should not raise an error", async () => {
await setupAsyncStoreWithClient(CallStore.instance, MatrixClientPeg.safeGet());
await viewCall();
});