+ ),
+ [onBackClick, roomName, widgetId, room, movePersistedElement, call, onLeaveClick, widget],
+ );
+
+ return (
+
+ {content}
+
+ );
+};
diff --git a/src/hooks/useCall.ts b/src/hooks/useCall.ts
index b90533f0ac3d..03bee56b9cc9 100644
--- a/src/hooks/useCall.ts
+++ b/src/hooks/useCall.ts
@@ -33,6 +33,11 @@ export const useCall = (roomId: string): Call | null => {
return call;
};
+export const useCallForWidget = (widgetId: string, roomId: string): Call | null => {
+ const call = useCall(roomId);
+ return call?.widget.id === widgetId ? call : null;
+};
+
export const useConnectionState = (call: Call): ConnectionState =>
useTypedEventEmitterState(
call,
diff --git a/test/components/structures/PictureInPictureDragger-test.tsx b/test/components/structures/PictureInPictureDragger-test.tsx
new file mode 100644
index 000000000000..9474b3c11530
--- /dev/null
+++ b/test/components/structures/PictureInPictureDragger-test.tsx
@@ -0,0 +1,44 @@
+/*
+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 } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import PictureInPictureDragger from "../../../src/components/structures/PictureInPictureDragger";
+
+test("PictureInPictureDragger doesn't leak drag events to children as clicks", async () => {
+ const clickSpy = jest.fn();
+ render(
+
+ {({ onStartMoving }) => (
+
+ Hello
+
+ )}
+ ,
+ );
+ const target = screen.getByText("Hello");
+
+ // A click without a drag motion should go through
+ await userEvent.pointer([{ keys: "[MouseLeft>]", target }, { keys: "[/MouseLeft]" }]);
+ expect(clickSpy).toHaveBeenCalled();
+
+ // A drag motion should not trigger a click
+ clickSpy.mockClear();
+ await userEvent.pointer([{ keys: "[MouseLeft>]", target }, { coords: { x: 60, y: 60 } }, "[/MouseLeft]"]);
+ expect(clickSpy).not.toHaveBeenCalled();
+});
diff --git a/test/components/structures/PipContainer-test.tsx b/test/components/structures/PipContainer-test.tsx
index c4bfaa23feae..16610b5ec4ea 100644
--- a/test/components/structures/PipContainer-test.tsx
+++ b/test/components/structures/PipContainer-test.tsx
@@ -16,12 +16,14 @@ limitations under the License.
import React from "react";
import { mocked, Mocked } from "jest-mock";
-import { screen, render, act, cleanup, fireEvent, waitFor } from "@testing-library/react";
+import { screen, render, act, cleanup } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
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, ClientWidgetApi } from "matrix-widget-api";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
+import { UserEvent } from "@testing-library/user-event/dist/types/setup/setup";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import {
@@ -34,6 +36,7 @@ import {
wrapInMatrixClientContext,
wrapInSdkContext,
mkRoomCreateEvent,
+ mockPlatformPeg,
} from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { CallStore } from "../../../src/stores/CallStore";
@@ -56,11 +59,17 @@ import {
import { mkVoiceBroadcastInfoStateEvent } from "../../voice-broadcast/utils/test-utils";
import { RoomViewStore } from "../../../src/stores/RoomViewStore";
import { IRoomStateEventsActionPayload } from "../../../src/actions/MatrixActionCreators";
+import { Container, WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
+import WidgetStore from "../../../src/stores/WidgetStore";
+import { WidgetType } from "../../../src/widgets/WidgetType";
+import { SdkContextClass } from "../../../src/contexts/SDKContext";
+import { ElementWidgetActions } from "../../../src/stores/widgets/ElementWidgetActions";
describe("PipContainer", () => {
useMockedCalls();
jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => {});
+ let user: UserEvent;
let sdkContext: TestSdkContext;
let client: Mocked;
let room: Room;
@@ -71,6 +80,8 @@ describe("PipContainer", () => {
let voiceBroadcastPlaybacksStore: VoiceBroadcastPlaybacksStore;
beforeEach(async () => {
+ user = userEvent.setup();
+
stubClient();
client = mocked(MatrixClientPeg.get());
DMRoomMap.makeShared();
@@ -103,6 +114,8 @@ describe("PipContainer", () => {
);
sdkContext = new TestSdkContext();
+ // @ts-ignore PipContainer uses SDKContext in the constructor
+ SdkContextClass.instance = sdkContext;
voiceBroadcastRecordingsStore = new VoiceBroadcastRecordingsStore();
voiceBroadcastPreRecordingStore = new VoiceBroadcastPreRecordingStore();
voiceBroadcastPlaybacksStore = new VoiceBroadcastPlaybacksStore();
@@ -124,7 +137,7 @@ describe("PipContainer", () => {
render();
};
- const viewRoom = (roomId: string) =>
+ const viewRoom = (roomId: string) => {
defaultDispatcher.dispatch(
{
action: Action.ViewRoom,
@@ -133,8 +146,9 @@ describe("PipContainer", () => {
},
true,
);
+ };
- const withCall = async (fn: () => Promise): Promise => {
+ const withCall = async (fn: (call: MockedCall) => Promise): Promise => {
MockedCall.create(room, "1");
const call = CallStore.instance.getCall(room.roomId);
if (!(call instanceof MockedCall)) throw new Error("Failed to create call");
@@ -149,16 +163,16 @@ describe("PipContainer", () => {
ActiveWidgetStore.instance.setWidgetPersistence(widget.id, room.roomId, true);
});
- await fn();
+ await fn(call);
cleanup();
call.destroy();
ActiveWidgetStore.instance.destroyPersistentWidget(widget.id, room.roomId);
};
- const withWidget = (fn: () => void): void => {
+ const withWidget = async (fn: () => Promise): Promise => {
act(() => ActiveWidgetStore.instance.setWidgetPersistence("1", room.roomId, true));
- fn();
+ await fn();
cleanup();
ActiveWidgetStore.instance.destroyPersistentWidget("1", room.roomId);
};
@@ -190,7 +204,7 @@ describe("PipContainer", () => {
};
const setUpRoomViewStore = () => {
- new RoomViewStore(defaultDispatcher, sdkContext);
+ sdkContext._RoomViewStore = new RoomViewStore(defaultDispatcher, sdkContext);
};
const mkVoiceBroadcast = (room: Room): MatrixEvent => {
@@ -213,54 +227,102 @@ describe("PipContainer", () => {
expect(screen.queryByRole("complementary")).toBeNull();
});
- it("shows an active call with a maximise button", async () => {
+ it("shows an active call with back and leave buttons", async () => {
renderPip();
- await withCall(async () => {
+ await withCall(async (call) => {
screen.getByRole("complementary");
- screen.getByText(room.roomId);
- expect(screen.queryByRole("button", { name: "Pin" })).toBeNull();
- expect(screen.queryByRole("button", { name: /return/i })).toBeNull();
- // The maximise button should jump to the call
+ // The return button should jump to the call
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
- fireEvent.click(screen.getByRole("button", { name: "Fill screen" }));
- await waitFor(() =>
- expect(dispatcherSpy).toHaveBeenCalledWith({
- action: Action.ViewRoom,
- room_id: room.roomId,
- view_call: true,
- }),
- );
+ await user.click(screen.getByRole("button", { name: "Back" }));
+ expect(dispatcherSpy).toHaveBeenCalledWith({
+ action: Action.ViewRoom,
+ room_id: room.roomId,
+ view_call: true,
+ });
defaultDispatcher.unregister(dispatcherRef);
+
+ // The leave button should disconnect from the call
+ const disconnectSpy = jest.spyOn(call, "disconnect");
+ await user.click(screen.getByRole("button", { name: "Leave" }));
+ expect(disconnectSpy).toHaveBeenCalled();
});
});
- it("shows a persistent widget with pin and maximise buttons when viewing the room", () => {
+ it("shows a persistent widget with back button when viewing the room", async () => {
+ setUpRoomViewStore();
viewRoom(room.roomId);
+ const widget = WidgetStore.instance.addVirtualWidget(
+ {
+ id: "1",
+ creatorUserId: "@alice:exaxmple.org",
+ type: WidgetType.CUSTOM.preferred,
+ url: "https://example.org",
+ name: "Example widget",
+ },
+ room.roomId,
+ );
renderPip();
- withWidget(() => {
+ await withWidget(async () => {
screen.getByRole("complementary");
- screen.getByText(room.roomId);
- screen.getByRole("button", { name: "Pin" });
- screen.getByRole("button", { name: "Fill screen" });
- expect(screen.queryByRole("button", { name: /return/i })).toBeNull();
+
+ // The return button should maximize the widget
+ const moveSpy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer");
+ await user.click(screen.getByRole("button", { name: "Back" }));
+ expect(moveSpy).toHaveBeenCalledWith(room, widget, Container.Center);
+
+ expect(screen.queryByRole("button", { name: "Leave" })).toBeNull();
});
+
+ WidgetStore.instance.removeVirtualWidget("1", room.roomId);
});
- it("shows a persistent widget with a return button when not viewing the room", () => {
+ it("shows a persistent Jitsi widget with back and leave buttons when not viewing the room", async () => {
+ mockPlatformPeg({ supportsJitsiScreensharing: () => true });
+ setUpRoomViewStore();
viewRoom(room2.roomId);
+ const widget = WidgetStore.instance.addVirtualWidget(
+ {
+ id: "1",
+ creatorUserId: "@alice:exaxmple.org",
+ type: WidgetType.JITSI.preferred,
+ url: "https://meet.example.org",
+ name: "Jitsi example",
+ },
+ room.roomId,
+ );
renderPip();
- withWidget(() => {
+ await withWidget(async () => {
screen.getByRole("complementary");
- screen.getByText(room.roomId);
- expect(screen.queryByRole("button", { name: "Pin" })).toBeNull();
- expect(screen.queryByRole("button", { name: "Fill screen" })).toBeNull();
- screen.getByRole("button", { name: /return/i });
+
+ // The return button should view the room
+ const dispatcherSpy = jest.fn();
+ const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
+ await user.click(screen.getByRole("button", { name: "Back" }));
+ expect(dispatcherSpy).toHaveBeenCalledWith({
+ action: Action.ViewRoom,
+ room_id: room.roomId,
+ });
+ defaultDispatcher.unregister(dispatcherRef);
+
+ // The leave button should hangup the call
+ const sendSpy = jest
+ .fn<
+ ReturnType,
+ Parameters
+ >()
+ .mockResolvedValue({});
+ const mockMessaging = { transport: { send: sendSpy }, stop: () => {} } as unknown as ClientWidgetApi;
+ WidgetMessagingStore.instance.storeMessaging(new Widget(widget), room.roomId, mockMessaging);
+ await user.click(screen.getByRole("button", { name: "Leave" }));
+ expect(sendSpy).toHaveBeenCalledWith(ElementWidgetActions.HangupCall, {});
});
+
+ WidgetStore.instance.removeVirtualWidget("1", room.roomId);
});
describe("when there is a voice broadcast recording and pre-recording", () => {