/* Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ 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(); }); }); }); });