/* 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 { mocked, Mocked } from "jest-mock"; import { screen, render, act, cleanup } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MatrixClient, PendingEventOrdering, Room, MatrixEvent, RoomStateEvent } from "matrix-js-sdk/src/matrix"; import { Widget, ClientWidgetApi } from "matrix-widget-api"; import { UserEvent } from "@testing-library/user-event/dist/types/setup/setup"; import type { RoomMember } from "matrix-js-sdk/src/matrix"; import { useMockedCalls, MockedCall, mkRoomMember, stubClient, setupAsyncStoreWithClient, resetAsyncStoreWithClient, wrapInMatrixClientContext, wrapInSdkContext, mkRoomCreateEvent, mockPlatformPeg, flushPromises, useMockMediaDevices, } from "../../test-utils"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { CallStore } from "../../../src/stores/CallStore"; import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore"; import { PipContainer as UnwrappedPipContainer } from "../../../src/components/structures/PipContainer"; import ActiveWidgetStore from "../../../src/stores/ActiveWidgetStore"; import DMRoomMap from "../../../src/utils/DMRoomMap"; import defaultDispatcher from "../../../src/dispatcher/dispatcher"; import { Action } from "../../../src/dispatcher/actions"; import { ViewRoomPayload } from "../../../src/dispatcher/payloads/ViewRoomPayload"; import { TestSdkContext } from "../../TestSdkContext"; import { VoiceBroadcastInfoState, VoiceBroadcastPlaybacksStore, VoiceBroadcastPreRecording, VoiceBroadcastPreRecordingStore, VoiceBroadcastRecording, VoiceBroadcastRecordingsStore, } from "../../../src/voice-broadcast"; 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"; jest.mock("../../../src/stores/OwnProfileStore", () => ({ OwnProfileStore: { instance: { isProfileInfoFetched: true, removeListener: jest.fn(), getHttpAvatarUrl: jest.fn().mockReturnValue("http://avatar_url"), }, }, })); describe("PipContainer", () => { useMockedCalls(); jest.spyOn(HTMLMediaElement.prototype, "play").mockImplementation(async () => {}); let user: UserEvent; let sdkContext: TestSdkContext; let client: Mocked; let room: Room; let room2: Room; let alice: RoomMember; let voiceBroadcastRecordingsStore: VoiceBroadcastRecordingsStore; let voiceBroadcastPreRecordingStore: VoiceBroadcastPreRecordingStore; let voiceBroadcastPlaybacksStore: VoiceBroadcastPlaybacksStore; const actFlushPromises = async () => { await act(async () => { await flushPromises(); }); }; beforeEach(async () => { useMockMediaDevices(); user = userEvent.setup(); stubClient(); client = mocked(MatrixClientPeg.safeGet()); client.getUserId.mockReturnValue("@alice:example.org"); client.getSafeUserId.mockReturnValue("@alice:example.org"); DMRoomMap.makeShared(client); room = new Room("!1:example.org", client, "@alice:example.org", { pendingEventOrdering: PendingEventOrdering.Detached, }); alice = mkRoomMember(room.roomId, "@alice:example.org"); room2 = new Room("!2:example.com", client, "@alice:example.org", { pendingEventOrdering: PendingEventOrdering.Detached, }); client.getRoom.mockImplementation((roomId: string) => { if (roomId === room.roomId) return room; if (roomId === room2.roomId) return room2; return null; }); client.getRooms.mockReturnValue([room, room2]); client.reEmitter.reEmit(room, [RoomStateEvent.Events]); room.currentState.setStateEvents([mkRoomCreateEvent(alice.userId, room.roomId)]); jest.spyOn(room, "getMember").mockImplementation((userId) => (userId === alice.userId ? alice : null)); room2.currentState.setStateEvents([mkRoomCreateEvent(alice.userId, room2.roomId)]); await Promise.all( [CallStore.instance, WidgetMessagingStore.instance].map((store) => setupAsyncStoreWithClient(store, client), ), ); sdkContext = new TestSdkContext(); // @ts-ignore PipContainer uses SDKContext in the constructor SdkContextClass.instance = sdkContext; voiceBroadcastRecordingsStore = new VoiceBroadcastRecordingsStore(); voiceBroadcastPreRecordingStore = new VoiceBroadcastPreRecordingStore(); voiceBroadcastPlaybacksStore = new VoiceBroadcastPlaybacksStore(voiceBroadcastRecordingsStore); sdkContext.client = client; sdkContext._VoiceBroadcastRecordingsStore = voiceBroadcastRecordingsStore; sdkContext._VoiceBroadcastPreRecordingStore = voiceBroadcastPreRecordingStore; sdkContext._VoiceBroadcastPlaybacksStore = voiceBroadcastPlaybacksStore; }); afterEach(async () => { cleanup(); await Promise.all([CallStore.instance, WidgetMessagingStore.instance].map(resetAsyncStoreWithClient)); client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]); jest.clearAllMocks(); }); const renderPip = () => { const PipContainer = wrapInMatrixClientContext(wrapInSdkContext(UnwrappedPipContainer, sdkContext)); render(); }; const viewRoom = (roomId: string) => { defaultDispatcher.dispatch( { action: Action.ViewRoom, room_id: roomId, metricsTrigger: undefined, }, true, ); }; 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"); const widget = new Widget(call.widget); WidgetStore.instance.addVirtualWidget(call.widget, room.roomId); WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, { stop: () => {}, } as unknown as ClientWidgetApi); await act(async () => { await call.start(); ActiveWidgetStore.instance.setWidgetPersistence(widget.id, room.roomId, true); }); await fn(call); cleanup(); call.destroy(); ActiveWidgetStore.instance.destroyPersistentWidget(widget.id, room.roomId); WidgetStore.instance.removeVirtualWidget(widget.id, room.roomId); }; const withWidget = async (fn: () => Promise): Promise => { act(() => ActiveWidgetStore.instance.setWidgetPersistence("1", room.roomId, true)); await fn(); cleanup(); ActiveWidgetStore.instance.destroyPersistentWidget("1", room.roomId); }; const makeVoiceBroadcastInfoStateEvent = (): MatrixEvent => { return mkVoiceBroadcastInfoStateEvent( room.roomId, VoiceBroadcastInfoState.Started, alice.userId, client.getDeviceId() || "", ); }; const setUpVoiceBroadcastRecording = () => { const infoEvent = makeVoiceBroadcastInfoStateEvent(); const voiceBroadcastRecording = new VoiceBroadcastRecording(infoEvent, client); voiceBroadcastRecordingsStore.setCurrent(voiceBroadcastRecording); }; const setUpVoiceBroadcastPreRecording = () => { const voiceBroadcastPreRecording = new VoiceBroadcastPreRecording( room, alice, client, voiceBroadcastPlaybacksStore, voiceBroadcastRecordingsStore, ); voiceBroadcastPreRecordingStore.setCurrent(voiceBroadcastPreRecording); }; const setUpRoomViewStore = () => { sdkContext._RoomViewStore = new RoomViewStore(defaultDispatcher, sdkContext); }; const mkVoiceBroadcast = (room: Room): MatrixEvent => { const infoEvent = makeVoiceBroadcastInfoStateEvent(); room.currentState.setStateEvents([infoEvent]); defaultDispatcher.dispatch( { action: "MatrixActions.RoomState.events", event: infoEvent, state: room.currentState, lastStateEvent: null, }, true, ); return infoEvent; }; it("hides if there's no content", () => { renderPip(); expect(screen.queryByRole("complementary")).toBeNull(); }); it("shows an active call with back and leave buttons", async () => { renderPip(); await withCall(async (call) => { screen.getByRole("complementary"); // The return button should jump to the call 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, view_call: true, metricsTrigger: expect.any(String), }); 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 back button when viewing the room", async () => { setUpRoomViewStore(); viewRoom(room.roomId); const widget = WidgetStore.instance.addVirtualWidget( { id: "1", creatorUserId: "@alice:example.org", type: WidgetType.CUSTOM.preferred, url: "https://example.org", name: "Example widget", }, room.roomId, ); renderPip(); await withWidget(async () => { screen.getByRole("complementary"); // The return button should maximize the widget const moveSpy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer"); await user.click(await screen.findByRole("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 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:example.org", type: WidgetType.JITSI.preferred, url: "https://meet.example.org", name: "Jitsi example", }, room.roomId, ); renderPip(); await withWidget(async () => { screen.getByRole("complementary"); // The return button should view the room const dispatcherSpy = jest.fn(); const dispatcherRef = defaultDispatcher.register(dispatcherSpy); await user.click(await screen.findByRole("button", { name: "Back" })); expect(dispatcherSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, room_id: room.roomId, metricsTrigger: expect.any(String), }); 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", () => { beforeEach(async () => { setUpVoiceBroadcastPreRecording(); setUpVoiceBroadcastRecording(); renderPip(); await actFlushPromises(); }); it("should render the voice broadcast recording PiP", () => { // check for the „Live“ badge to be present expect(screen.queryByText("Live")).toBeInTheDocument(); }); it("and a call it should show both, the call and the recording", async () => { await withCall(async () => { // Broadcast: Check for the „Live“ badge to be present expect(screen.queryByText("Live")).toBeInTheDocument(); // Call: Check for the „Leave“ button to be present screen.getByRole("button", { name: "Leave" }); }); }); }); describe("when there is a voice broadcast playback and pre-recording", () => { beforeEach(async () => { mkVoiceBroadcast(room); setUpVoiceBroadcastPreRecording(); renderPip(); await actFlushPromises(); }); it("should render the voice broadcast pre-recording PiP", () => { // check for the „Go live“ button expect(screen.queryByText("Go live")).toBeInTheDocument(); }); }); describe("when there is a voice broadcast pre-recording", () => { beforeEach(async () => { setUpVoiceBroadcastPreRecording(); renderPip(); await actFlushPromises(); }); it("should render the voice broadcast pre-recording PiP", () => { // check for the „Go live“ button expect(screen.queryByText("Go live")).toBeInTheDocument(); }); }); describe("when listening to a voice broadcast in a room and then switching to another room", () => { beforeEach(async () => { setUpRoomViewStore(); viewRoom(room.roomId); mkVoiceBroadcast(room); await actFlushPromises(); expect(voiceBroadcastPlaybacksStore.getCurrent()).toBeTruthy(); await voiceBroadcastPlaybacksStore.getCurrent()?.start(); viewRoom(room2.roomId); renderPip(); }); it("should render the small voice broadcast playback PiP", () => { // check for the „pause voice broadcast“ button expect(screen.getByLabelText("pause voice broadcast")).toBeInTheDocument(); // check for the absence of the „30s forward“ button expect(screen.queryByLabelText("30s forward")).not.toBeInTheDocument(); }); }); describe("when viewing a room with a live voice broadcast", () => { let startEvent!: MatrixEvent; beforeEach(async () => { setUpRoomViewStore(); viewRoom(room.roomId); startEvent = mkVoiceBroadcast(room); renderPip(); await actFlushPromises(); }); it("should render the voice broadcast playback pip", () => { // check for the „resume voice broadcast“ button expect(screen.queryByLabelText("play voice broadcast")).toBeInTheDocument(); }); describe("and the broadcast stops", () => { beforeEach(async () => { const stopEvent = mkVoiceBroadcastInfoStateEvent( room.roomId, VoiceBroadcastInfoState.Stopped, alice.userId, client.getDeviceId() || "", startEvent, ); await act(async () => { room.currentState.setStateEvents([stopEvent]); defaultDispatcher.dispatch( { action: "MatrixActions.RoomState.events", event: stopEvent, state: room.currentState, lastStateEvent: stopEvent, }, true, ); await flushPromises(); }); }); it("should not render the voice broadcast playback pip", () => { // check for the „resume voice broadcast“ button expect(screen.queryByLabelText("play voice broadcast")).not.toBeInTheDocument(); }); }); describe("and leaving the room", () => { beforeEach(async () => { await act(async () => { viewRoom(room2.roomId); await flushPromises(); }); }); it("should not render the voice broadcast playback pip", () => { // check for the „resume voice broadcast“ button expect(screen.queryByLabelText("play voice broadcast")).not.toBeInTheDocument(); }); }); }); });