Start voice call in PiP (not in fullscreen) (#34055)
* start voice call in pip
* Update jest.config.ts
* fix "join" button to use correct intent
* Add regression test for join button
* Update RoomHeader-test.tsx
* fmt
* Add playwright test
* Fix expected intent on "join" button press
* Add additional pip check to exising:
`should be able to join a ${callType} call in progress` test
* Update RoomHeader-test.tsx
This commit is contained in:
@@ -217,8 +217,23 @@ test.describe("Element Call", () => {
|
|||||||
const hash = new URLSearchParams(url.hash.slice(1));
|
const hash = new URLSearchParams(url.hash.slice(1));
|
||||||
assertCommonCallParameters(url.searchParams, hash, user, room);
|
assertCommonCallParameters(url.searchParams, hash, user, room);
|
||||||
|
|
||||||
expect(hash.get("intent")).toEqual("join_existing");
|
const expectedIntent = callType === "voice" ? "join_existing_voice" : "join_existing";
|
||||||
|
expect(hash.get("intent")).toEqual(expectedIntent);
|
||||||
expect(hash.get("skipLobby")).toEqual(null);
|
expect(hash.get("skipLobby")).toEqual(null);
|
||||||
|
|
||||||
|
// pip layout check
|
||||||
|
switch (callType) {
|
||||||
|
case "voice": {
|
||||||
|
const pipContainer = page.getByTestId("widget-pip-container");
|
||||||
|
await expect(pipContainer).toBeVisible();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "video": {
|
||||||
|
const pipContainer = page.getByTestId("widget-pip-container");
|
||||||
|
await expect(pipContainer).not.toBeVisible();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -336,6 +351,22 @@ test.describe("Element Call", () => {
|
|||||||
expect(hash.get("skipLobby")).toEqual("true");
|
expect(hash.get("skipLobby")).toEqual("true");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should start a voice call in PiP", async ({ page, user, room, app }) => {
|
||||||
|
await app.viewRoomById(room.roomId);
|
||||||
|
await expect(page.getByText("Bob joined the room")).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Voice call" }).click();
|
||||||
|
await page.getByRole("menuitem", { name: "Element Call" }).click();
|
||||||
|
|
||||||
|
const frameUrlStr = await page.locator("iframe").getAttribute("src");
|
||||||
|
await expect(frameUrlStr).toBeDefined();
|
||||||
|
|
||||||
|
// The call should be presented in the picture-in-picture container, right in the room we started it
|
||||||
|
// from, rather than taking over the room view.
|
||||||
|
const pipContainer = page.getByTestId("widget-pip-container");
|
||||||
|
await expect(pipContainer).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test("should be able to join a call in progress", async ({ page, user, bot, room, app }) => {
|
test("should be able to join a call in progress", async ({ page, user, bot, room, app }) => {
|
||||||
await app.viewRoomById(room.roomId);
|
await app.viewRoomById(room.roomId);
|
||||||
await expect(page.getByText("Bob joined the room")).toBeVisible();
|
await expect(page.getByText("Bob joined the room")).toBeVisible();
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ function RoomHeaderButtons({
|
|||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
size="md"
|
size="md"
|
||||||
onClick={videoClick}
|
onClick={activeCallSessionType === CallType.Video ? videoClick : voiceClick}
|
||||||
// If we know this is a voice session, show the voice call. All other kinds of call are video calls.
|
// If we know this is a voice session, show the voice call. All other kinds of call are video calls.
|
||||||
Icon={activeCallSessionType === CallType.Voice ? VoiceCallIcon : VideoCallIcon}
|
Icon={activeCallSessionType === CallType.Voice ? VoiceCallIcon : VideoCallIcon}
|
||||||
className="mx_RoomHeader_join_button"
|
className="mx_RoomHeader_join_button"
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { setMarkedUnreadState } from "../utils/notifications";
|
|||||||
import { ConnectionState, ElementCall } from "../models/Call";
|
import { ConnectionState, ElementCall } from "../models/Call";
|
||||||
import { isVideoRoom } from "../utils/video-rooms";
|
import { isVideoRoom } from "../utils/video-rooms";
|
||||||
import { ModuleApi } from "../modules/Api";
|
import { ModuleApi } from "../modules/Api";
|
||||||
|
import ActiveWidgetStore from "./ActiveWidgetStore";
|
||||||
|
|
||||||
const NUM_JOIN_RETRY = 5;
|
const NUM_JOIN_RETRY = 5;
|
||||||
|
|
||||||
@@ -377,6 +378,12 @@ export class RoomViewStore extends EventEmitter {
|
|||||||
ElementCall.create(room);
|
ElementCall.create(room);
|
||||||
call = CallStore.instance.getCall(payload.room_id)!;
|
call = CallStore.instance.getCall(payload.room_id)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom case where we start voice calls in pip
|
||||||
|
if (payload.voiceOnly ?? false) {
|
||||||
|
viewingCall = false;
|
||||||
|
ActiveWidgetStore.instance.setWidgetPersistence(call.widget.id, room.roomId, true);
|
||||||
|
}
|
||||||
call.presented = true;
|
call.presented = true;
|
||||||
// Immediately start the call. This will connect to all required widget events
|
// Immediately start the call. This will connect to all required widget events
|
||||||
// and allow the widget to show the lobby.
|
// and allow the widget to show the lobby.
|
||||||
@@ -401,7 +408,7 @@ export class RoomViewStore extends EventEmitter {
|
|||||||
roomLoadError: null,
|
roomLoadError: null,
|
||||||
viaServers: payload.via_servers,
|
viaServers: payload.via_servers,
|
||||||
wasContextSwitch: payload.context_switch,
|
wasContextSwitch: payload.context_switch,
|
||||||
viewingCall: payload.view_call ?? false,
|
viewingCall,
|
||||||
});
|
});
|
||||||
// set this room as the room subscription. We need to await for it as this will fetch
|
// set this room as the room subscription. We need to await for it as this will fetch
|
||||||
// all room state for this room, which is required before we get the state below.
|
// all room state for this room, which is required before we get the state below.
|
||||||
|
|||||||
@@ -609,6 +609,30 @@ describe("RoomHeader", () => {
|
|||||||
expect(joinButton).not.toHaveAttribute("aria-disabled", "true");
|
expect(joinButton).not.toHaveAttribute("aria-disabled", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clicking the join button of an ongoing video call joins as a video call", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockRoomMembers(room, 3);
|
||||||
|
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3, CallType.Video, true));
|
||||||
|
render(<RoomHeader room={room} />, getWrapper());
|
||||||
|
|
||||||
|
const dispatcherSpy = jest.spyOn(dispatcher, "dispatch").mockImplementation();
|
||||||
|
await user.click(getByLabelText(document.body, "Join video call"));
|
||||||
|
|
||||||
|
expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true, voiceOnly: false }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clicking the join button of an ongoing voice call joins as a voice call", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
mockRoomMembers(room, 3);
|
||||||
|
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(createMockCall(ROOM_ID, 3, CallType.Voice, true));
|
||||||
|
render(<RoomHeader room={room} />, getWrapper());
|
||||||
|
|
||||||
|
const dispatcherSpy = jest.spyOn(dispatcher, "dispatch").mockImplementation();
|
||||||
|
await user.click(getByLabelText(document.body, "Join voice call"));
|
||||||
|
|
||||||
|
expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({ view_call: true, voiceOnly: true }));
|
||||||
|
});
|
||||||
|
|
||||||
it("join button is disabled if there is an other ongoing call", async () => {
|
it("join button is disabled if there is an other ongoing call", async () => {
|
||||||
mockRoomMembers(room, 3);
|
mockRoomMembers(room, 3);
|
||||||
// Mock CallStore to return a call with 3 participants
|
// Mock CallStore to return a call with 3 participants
|
||||||
@@ -919,6 +943,7 @@ function createMockCall(
|
|||||||
roomId: string = "!1:example.org",
|
roomId: string = "!1:example.org",
|
||||||
participantCount: number = 0,
|
participantCount: number = 0,
|
||||||
callType: CallType = CallType.Video,
|
callType: CallType = CallType.Video,
|
||||||
|
isElementCall: boolean = false,
|
||||||
): Call {
|
): Call {
|
||||||
const participants = new Map();
|
const participants = new Map();
|
||||||
|
|
||||||
@@ -936,7 +961,7 @@ function createMockCall(
|
|||||||
return {
|
return {
|
||||||
roomId,
|
roomId,
|
||||||
participants,
|
participants,
|
||||||
widget: { id: "test-widget" },
|
widget: { id: "test-widget", type: isElementCall ? "m.call" : undefined },
|
||||||
connectionState: "disconnected",
|
connectionState: "disconnected",
|
||||||
callType,
|
callType,
|
||||||
on: jest.fn(),
|
on: jest.fn(),
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ import { CallStore } from "../../../src/stores/CallStore";
|
|||||||
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
|
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
|
||||||
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../src/MediaDeviceHandler";
|
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../src/MediaDeviceHandler";
|
||||||
import { storeRoomAliasInCache } from "../../../src/RoomAliasCache.ts";
|
import { storeRoomAliasInCache } from "../../../src/RoomAliasCache.ts";
|
||||||
import { type Call } from "../../../src/models/Call.ts";
|
import { type Call, ConnectionState } from "../../../src/models/Call.ts";
|
||||||
|
import ActiveWidgetStore from "../../../src/stores/ActiveWidgetStore";
|
||||||
import { ModuleApi } from "../../../src/modules/Api";
|
import { ModuleApi } from "../../../src/modules/Api";
|
||||||
|
|
||||||
jest.mock("../../../src/Modal");
|
jest.mock("../../../src/Modal");
|
||||||
@@ -430,6 +431,60 @@ describe("RoomViewStore", function () {
|
|||||||
expect(call.presented).toEqual(true);
|
expect(call.presented).toEqual(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens a voice-intent call directly in picture-in-picture rather than maximised", async () => {
|
||||||
|
const call = {
|
||||||
|
presented: false,
|
||||||
|
connectionState: ConnectionState.Disconnected,
|
||||||
|
widget: { id: "!widget:example.org" },
|
||||||
|
start: jest.fn(),
|
||||||
|
} as unknown as Call;
|
||||||
|
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(call);
|
||||||
|
const persistenceSpy = jest.spyOn(ActiveWidgetStore.instance, "setWidgetPersistence");
|
||||||
|
await setupAsyncStoreWithClient(CallStore.instance, MatrixClientPeg.safeGet());
|
||||||
|
|
||||||
|
dis.dispatch<ViewRoomPayload>({
|
||||||
|
action: Action.ViewRoom,
|
||||||
|
room_id: roomId,
|
||||||
|
view_call: true,
|
||||||
|
voiceOnly: true,
|
||||||
|
metricsTrigger: undefined,
|
||||||
|
});
|
||||||
|
await untilDispatch(Action.ViewRoom, dis);
|
||||||
|
|
||||||
|
// The call is started and marked persistent so it renders in the PiP container...
|
||||||
|
expect(call.presented).toEqual(true);
|
||||||
|
expect(persistenceSpy).toHaveBeenCalledWith("!widget:example.org", roomId, true);
|
||||||
|
expect(call.start).toHaveBeenCalledWith(expect.objectContaining({ voiceOnly: true }));
|
||||||
|
// ...but the room is not switched to the maximised call view.
|
||||||
|
expect(roomViewStore.isViewingCall()).toEqual(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a video-intent call maximised in the room", async () => {
|
||||||
|
const call = {
|
||||||
|
presented: false,
|
||||||
|
connectionState: ConnectionState.Disconnected,
|
||||||
|
widget: { id: "!widget:example.org" },
|
||||||
|
start: jest.fn(),
|
||||||
|
} as unknown as Call;
|
||||||
|
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(call);
|
||||||
|
const persistenceSpy = jest.spyOn(ActiveWidgetStore.instance, "setWidgetPersistence");
|
||||||
|
await setupAsyncStoreWithClient(CallStore.instance, MatrixClientPeg.safeGet());
|
||||||
|
|
||||||
|
dis.dispatch<ViewRoomPayload>({
|
||||||
|
action: Action.ViewRoom,
|
||||||
|
room_id: roomId,
|
||||||
|
view_call: true,
|
||||||
|
voiceOnly: false,
|
||||||
|
metricsTrigger: undefined,
|
||||||
|
});
|
||||||
|
await untilDispatch(Action.ViewRoom, dis);
|
||||||
|
|
||||||
|
expect(call.presented).toEqual(true);
|
||||||
|
expect(persistenceSpy).not.toHaveBeenCalled();
|
||||||
|
expect(call.start).toHaveBeenCalledWith(expect.objectContaining({ voiceOnly: false }));
|
||||||
|
expect(roomViewStore.isViewingCall()).toEqual(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("should display an error message when the room is unreachable via the roomId", async () => {
|
it("should display an error message when the room is unreachable via the roomId", async () => {
|
||||||
// When
|
// When
|
||||||
// View and wait for the room
|
// View and wait for the room
|
||||||
|
|||||||
Reference in New Issue
Block a user