New group call experience: Starting and ending calls (#9318)

* Create m.room calls in video rooms, and m.prompt calls otherwise

* Terminate a call when the last person leaves

* Hook up the room header button to a unified CallView component

* Write more tests
This commit is contained in:
Robin 2022-09-27 07:54:51 -04:00 committed by GitHub
parent 54b79c7667
commit ace6591f43
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 695 additions and 512 deletions

View file

@ -1,117 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { render, screen, act, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { mocked, Mocked } from "jest-mock";
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { Widget } from "matrix-widget-api";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import type { ClientWidgetApi } from "matrix-widget-api";
import type { Call } from "../../../src/models/Call";
import {
stubClient,
mkRoomMember,
wrapInMatrixClientContext,
useMockedCalls,
MockedCall,
setupAsyncStoreWithClient,
} from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { VideoRoomView as UnwrappedVideoRoomView } from "../../../src/components/structures/VideoRoomView";
import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore";
import { CallStore } from "../../../src/stores/CallStore";
import { ConnectionState } from "../../../src/models/Call";
const VideoRoomView = wrapInMatrixClientContext(UnwrappedVideoRoomView);
describe("VideoRoomView", () => {
useMockedCalls();
Object.defineProperty(navigator, "mediaDevices", {
value: {
enumerateDevices: async () => [],
getUserMedia: () => null,
},
});
let client: Mocked<MatrixClient>;
let room: Room;
let call: Call;
let widget: Widget;
let alice: RoomMember;
beforeEach(() => {
stubClient();
client = mocked(MatrixClientPeg.get());
room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
alice = mkRoomMember(room.roomId, "@alice:example.org");
jest.spyOn(room, "getMember").mockImplementation(userId => userId === alice.userId ? alice : null);
client.getRoom.mockImplementation(roomId => roomId === room.roomId ? room : null);
client.getRooms.mockReturnValue([room]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
setupAsyncStoreWithClient(CallStore.instance, client);
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
MockedCall.create(room, "1");
call = CallStore.instance.get(room.roomId);
if (call === null) throw new Error("Failed to create call");
widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
stop: () => {},
} as unknown as ClientWidgetApi);
});
afterEach(() => {
cleanup();
call.destroy();
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
});
const renderView = async (): Promise<void> => {
render(<VideoRoomView room={room} resizing={false} />);
await act(() => Promise.resolve()); // Let effects settle
};
it("calls clean on mount", async () => {
const cleanSpy = jest.spyOn(call, "clean");
await renderView();
expect(cleanSpy).toHaveBeenCalled();
});
it("shows lobby and keeps widget loaded when disconnected", async () => {
await renderView();
screen.getByRole("button", { name: "Join" });
screen.getAllByText(/\bwidget\b/i);
});
it("only shows widget when connected", async () => {
await renderView();
fireEvent.click(screen.getByRole("button", { name: "Join" }));
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connected));
expect(screen.queryByRole("button", { name: "Join" })).toBe(null);
screen.getAllByText(/\bwidget\b/i);
});
});

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from "react";
import { zip } from "lodash";
import { render, screen, act, fireEvent, waitFor } from "@testing-library/react";
import { render, screen, act, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { mocked, Mocked } from "jest-mock";
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
@ -28,14 +28,18 @@ import type { ClientWidgetApi } from "matrix-widget-api";
import {
stubClient,
mkRoomMember,
MockedCall,
wrapInMatrixClientContext,
useMockedCalls,
MockedCall,
setupAsyncStoreWithClient,
} from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { CallLobby } from "../../../../src/components/views/voip/CallLobby";
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);
describe("CallLobby", () => {
useMockedCalls();
@ -49,8 +53,6 @@ describe("CallLobby", () => {
let client: Mocked<MatrixClient>;
let room: Room;
let call: MockedCall;
let widget: Widget;
let alice: RoomMember;
beforeEach(() => {
@ -71,61 +73,122 @@ describe("CallLobby", () => {
setupAsyncStoreWithClient(CallStore.instance, client);
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
MockedCall.create(room, "1");
call = CallStore.instance.get(room.roomId) as MockedCall;
widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
stop: () => {},
} as unknown as ClientWidgetApi);
});
afterEach(() => {
call.destroy();
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
});
const renderLobby = async (): Promise<void> => {
render(<CallLobby room={room} call={call} />);
const renderView = async (): Promise<void> => {
render(<CallView room={room} resizing={false} waitForCall={false} />);
await act(() => Promise.resolve()); // Let effects settle
};
it("tracks participants", async () => {
const bob = mkRoomMember(room.roomId, "@bob:example.org");
const carol = mkRoomMember(room.roomId, "@carol:example.org");
describe("with an existing call", () => {
let call: MockedCall;
let widget: Widget;
const expectAvatars = (userIds: string[]) => {
const avatars = screen.queryAllByRole("button", { name: "Avatar" });
expect(userIds.length).toBe(avatars.length);
beforeEach(() => {
MockedCall.create(room, "1");
const maybeCall = CallStore.instance.get(room.roomId);
if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call");
call = maybeCall;
for (const [userId, avatar] of zip(userIds, avatars)) {
fireEvent.focus(avatar!);
screen.getByRole("tooltip", { name: userId });
}
};
widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
stop: () => {},
} as unknown as ClientWidgetApi);
});
await renderLobby();
expect(screen.queryByLabelText(/joined/)).toBe(null);
expectAvatars([]);
afterEach(() => {
cleanup(); // Unmount before we do any cleanup that might update the component
call.destroy();
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
});
act(() => { call.participants = new Set([alice]); });
screen.getByText("1 person joined");
expectAvatars([alice.userId]);
it("calls clean on mount", async () => {
const cleanSpy = jest.spyOn(call, "clean");
await renderView();
expect(cleanSpy).toHaveBeenCalled();
});
act(() => { call.participants = new Set([alice, bob, carol]); });
screen.getByText("3 people joined");
expectAvatars([alice.userId, bob.userId, carol.userId]);
it("shows lobby and keeps widget loaded when disconnected", async () => {
await renderView();
screen.getByRole("button", { name: "Join" });
screen.getAllByText(/\bwidget\b/i);
});
act(() => { call.participants = new Set(); });
expect(screen.queryByLabelText(/joined/)).toBe(null);
expectAvatars([]);
it("only shows widget when connected", async () => {
await renderView();
fireEvent.click(screen.getByRole("button", { name: "Join" }));
await waitFor(() => expect(call.connectionState).toBe(ConnectionState.Connected));
expect(screen.queryByRole("button", { name: "Join" })).toBe(null);
screen.getAllByText(/\bwidget\b/i);
});
it("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: "Avatar" });
expect(userIds.length).toBe(avatars.length);
for (const [userId, avatar] of zip(userIds, avatars)) {
fireEvent.focus(avatar!);
screen.getByRole("tooltip", { name: userId });
}
};
await renderView();
expect(screen.queryByLabelText(/joined/)).toBe(null);
expectAvatars([]);
act(() => { call.participants = new Set([alice]); });
screen.getByText("1 person joined");
expectAvatars([alice.userId]);
act(() => { call.participants = new Set([alice, bob, carol]); });
screen.getByText("3 people joined");
expectAvatars([alice.userId, bob.userId, carol.userId]);
act(() => { call.participants = new Set(); });
expect(screen.queryByLabelText(/joined/)).toBe(null);
expectAvatars([]);
});
it("connects to the call when the join button is pressed", async () => {
await renderView();
const connectSpy = jest.spyOn(call, "connect");
fireEvent.click(screen.getByRole("button", { name: "Join" }));
await waitFor(() => expect(connectSpy).toHaveBeenCalled(), { interval: 1 });
});
});
describe("without an existing call", () => {
it("creates and connects to a new call when the join button is pressed", async () => {
await renderView();
expect(Call.get(room)).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Join" }));
await waitFor(() => expect(CallStore.instance.get(room.roomId)).not.toBeNull());
const call = CallStore.instance.get(room.roomId)!;
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);
});
});
describe("device buttons", () => {
it("hide when no devices are available", async () => {
await renderLobby();
await renderView();
expect(screen.queryByRole("button", { name: /microphone/ })).toBe(null);
expect(screen.queryByRole("button", { name: /camera/ })).toBe(null);
});
@ -139,7 +202,7 @@ describe("CallLobby", () => {
toJSON: () => {},
}]);
await renderLobby();
await renderView();
screen.getByRole("button", { name: /camera/ });
expect(screen.queryByRole("button", { name: "Video devices" })).toBe(null);
});
@ -162,20 +225,11 @@ describe("CallLobby", () => {
},
]);
await renderLobby();
await renderView();
screen.getByRole("button", { name: /microphone/ });
fireEvent.click(screen.getByRole("button", { name: "Audio devices" }));
screen.getByRole("menuitem", { name: "Headphones" });
screen.getByRole("menuitem", { name: "Audio input 2" });
});
});
describe("join button", () => {
it("works", async () => {
await renderLobby();
const connectSpy = jest.spyOn(call, "connect");
fireEvent.click(screen.getByRole("button", { name: "Join" }));
await waitFor(() => expect(connectSpy).toHaveBeenCalled(), { interval: 1 });
});
});
});

View file

@ -51,11 +51,12 @@ jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
jest.spyOn(MediaDeviceHandler, "getAudioInput").mockReturnValue("1");
jest.spyOn(MediaDeviceHandler, "getVideoInput").mockReturnValue("2");
jest.spyOn(SettingsStore, "getValue").mockImplementation(settingName =>
settingName === "feature_video_rooms" || settingName === "feature_element_call_video_rooms" ? true : undefined,
const enabledSettings = new Set(["feature_group_calls", "feature_video_rooms", "feature_element_call_video_rooms"]);
jest.spyOn(SettingsStore, "getValue").mockImplementation(
settingName => enabledSettings.has(settingName) || undefined,
);
const setUpClientRoomAndStores = (roomType: RoomType): {
const setUpClientRoomAndStores = (): {
client: Mocked<MatrixClient>;
room: Room;
alice: RoomMember;
@ -68,7 +69,6 @@ const setUpClientRoomAndStores = (roomType: RoomType): {
const room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(room, "getType").mockReturnValue(roomType);
const alice = mkRoomMember(room.roomId, "@alice:example.org");
const bob = mkRoomMember(room.roomId, "@bob:example.org");
@ -165,7 +165,8 @@ describe("JitsiCall", () => {
let carol: RoomMember;
beforeEach(() => {
({ client, room, alice, bob, carol } = setUpClientRoomAndStores(RoomType.ElementVideo));
({ client, room, alice, bob, carol } = setUpClientRoomAndStores());
jest.spyOn(room, "getType").mockReturnValue(RoomType.ElementVideo);
});
afterEach(() => cleanUpClientRoomAndStores(client, room));
@ -191,7 +192,7 @@ describe("JitsiCall", () => {
});
});
describe("instance", () => {
describe("instance in a video room", () => {
let call: JitsiCall;
let widget: Widget;
let messaging: Mocked<ClientWidgetApi>;
@ -542,7 +543,7 @@ describe("ElementCall", () => {
let carol: RoomMember;
beforeEach(() => {
({ client, room, alice, bob, carol } = setUpClientRoomAndStores(RoomType.UnstableCall));
({ client, room, alice, bob, carol } = setUpClientRoomAndStores());
});
afterEach(() => cleanUpClientRoomAndStores(client, room));
@ -569,7 +570,7 @@ describe("ElementCall", () => {
});
});
describe("instance", () => {
describe("instance in a non-video room", () => {
let call: ElementCall;
let widget: Widget;
let messaging: Mocked<ClientWidgetApi>;
@ -590,6 +591,10 @@ describe("ElementCall", () => {
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
it("has intent m.prompt", () => {
expect(call.groupCall.getContent()["m.intent"]).toBe("m.prompt");
});
it("connects muted", async () => {
expect(call.connectionState).toBe(ConnectionState.Disconnected);
audioMutedSpy.mockReturnValue(true);
@ -747,6 +752,59 @@ describe("ElementCall", () => {
expect(events).toEqual([new Set([alice]), new Set()]);
});
it("ends the call immediately if we're the last participant to leave", async () => {
await call.connect();
const onDestroy = jest.fn();
call.on(CallEvent.Destroy, onDestroy);
await call.disconnect();
expect(onDestroy).toHaveBeenCalled();
call.off(CallEvent.Destroy, onDestroy);
});
it("ends the call after a random delay if the last participant leaves without ending it", async () => {
// Bob connects
await client.sendStateEvent(
room.roomId,
ElementCall.MEMBER_EVENT_TYPE.name,
{
"m.expires_ts": 1000 * 60 * 10,
"m.calls": [{
"m.call_id": call.groupCall.getStateKey()!,
"m.devices": [{ device_id: "bobweb", session_id: "1", feeds: [] }],
}],
},
bob.userId,
);
const onDestroy = jest.fn();
call.on(CallEvent.Destroy, onDestroy);
// Bob disconnects
await client.sendStateEvent(
room.roomId,
ElementCall.MEMBER_EVENT_TYPE.name,
{
"m.expires_ts": 1000 * 60 * 10,
"m.calls": [{
"m.call_id": call.groupCall.getStateKey()!,
"m.devices": [],
}],
},
bob.userId,
);
// Nothing should happen for at least a second, to give Bob a chance
// to end the call on his own
jest.advanceTimersByTime(1000);
expect(onDestroy).not.toHaveBeenCalled();
// Within 10 seconds, our client should end the call on behalf of Bob
jest.advanceTimersByTime(9000);
expect(onDestroy).toHaveBeenCalled();
call.off(CallEvent.Destroy, onDestroy);
});
describe("clean", () => {
const aliceWeb: IMyDevice = {
device_id: "aliceweb",
@ -848,4 +906,40 @@ describe("ElementCall", () => {
});
});
});
describe("instance in a video room", () => {
let call: ElementCall;
let widget: Widget;
let audioMutedSpy: jest.SpyInstance<boolean, []>;
let videoMutedSpy: jest.SpyInstance<boolean, []>;
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(0);
jest.spyOn(room, "getType").mockReturnValue(RoomType.UnstableCall);
await ElementCall.create(room);
const maybeCall = ElementCall.get(room);
if (maybeCall === null) throw new Error("Failed to create call");
call = maybeCall;
({ widget, audioMutedSpy, videoMutedSpy } = setUpWidget(call));
});
afterEach(() => cleanUpCallAndWidget(call, widget, audioMutedSpy, videoMutedSpy));
it("has intent m.room", () => {
expect(call.groupCall.getContent()["m.intent"]).toBe("m.room");
});
it("doesn't end the call when the last participant leaves", async () => {
await call.connect();
const onDestroy = jest.fn();
call.on(CallEvent.Destroy, onDestroy);
await call.disconnect();
expect(onDestroy).not.toHaveBeenCalled();
call.off(CallEvent.Destroy, onDestroy);
});
});
});

View file

@ -19,7 +19,7 @@ import { MatrixWidgetType } from "matrix-widget-api";
import type { Room } from "matrix-js-sdk/src/models/room";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { mkEvent } from "./test-utils";
import { Call } from "../../src/models/Call";
import { Call, ElementCall, JitsiCall } from "../../src/models/Call";
export class MockedCall extends Call {
private static EVENT_TYPE = "org.example.mocked_call";
@ -91,4 +91,6 @@ 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");
};