diff --git a/src/UrlParams.test.ts b/src/UrlParams.test.ts index 1078df861..65be8ae85 100644 --- a/src/UrlParams.test.ts +++ b/src/UrlParams.test.ts @@ -339,6 +339,50 @@ describe("UrlParams", () => { callIntent: "audio", }); }); + + it("accepts start_call_dm_voice", () => { + expect( + computeUrlParams( + "?intent=start_call_dm_voice&widgetId=1234&parentUrl=parent.org", + ), + ).toMatchObject({ + ...startNewCallDefaults("desktop"), + // A DM rings the other side and waits for them, whichever platform + sendNotificationType: "ring", + autoLeaveWhenOthersLeft: true, + waitForCallPickup: true, + callIntent: "audio", + }); + }); + + it("accepts join_existing_dm", () => { + expect( + computeUrlParams( + "?intent=join_existing_dm&widgetId=1234&parentUrl=parent.org", + ), + ).toMatchObject({ + ...joinExistingCallDefaults("desktop"), + // Straight in: the other side is already waiting + skipLobby: true, + autoLeaveWhenOthersLeft: true, + waitForCallPickup: false, + callIntent: "video", + }); + }); + + it("accepts join_existing_dm_voice", () => { + expect( + computeUrlParams( + "?intent=join_existing_dm_voice&widgetId=1234&parentUrl=parent.org", + ), + ).toMatchObject({ + ...joinExistingCallDefaults("desktop"), + skipLobby: true, + autoLeaveWhenOthersLeft: true, + waitForCallPickup: false, + callIntent: "audio", + }); + }); }); describe("skipLobby", () => { diff --git a/src/room/CallView.test.tsx b/src/room/CallView.test.tsx index 522a41616..8ac920b84 100644 --- a/src/room/CallView.test.tsx +++ b/src/room/CallView.test.tsx @@ -36,6 +36,8 @@ import userEvent, { import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container"; import { useState } from "react"; import { TooltipProvider } from "@vector-im/compound-web"; +import { Subject } from "rxjs"; +import { Room as LivekitRoom } from "livekit-client"; import { prefetchSounds } from "../soundUtils"; import { useAudioContext } from "../useAudioContext"; @@ -54,8 +56,10 @@ import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary"; import { type HostBridge, HostBridgeProvider, + type HostRequest, nullHostBridge, } from "../HostBridge"; +import { type JoinCallData } from "../widget"; import { MatrixRTCTransportMissingError } from "../utils/errors"; import { ProcessorProvider } from "../livekit/TrackProcessorContext"; import { MediaDevicesContext } from "../MediaDevicesContext"; @@ -139,6 +143,8 @@ function createCallView( joined = true, options: { withErrorBoundary?: boolean; + /** Wait for the host to say when to join, rather than joining at once. */ + preload?: boolean; } = {}, ): { rtcSession: MatrixRTCSession; @@ -176,7 +182,7 @@ function createCallView( client={client} isPasswordlessUser={false} confineToRoom={false} - preload={false} + preload={options.preload ?? false} // Straight into the (mocked) call, past the lobby skipLobby rtcSession={rtcSession.asMockedSession()} @@ -283,6 +289,26 @@ test("Should ask the host to close when all other left and play a sound", async await waitFor(() => expect(close).toHaveBeenCalledOnce()); }, 80000); +test("Waits for the host to say when to join, when preloaded", async () => { + // Nothing to match device names against; the host names none anyway + vi.spyOn(LivekitRoom, "getLocalDevices").mockResolvedValue([]); + const join$ = new Subject>(); + const hostBridge: HostBridge = { ...nullHostBridge, join$ }; + + createCallView(hostBridge, false, { preload: true }); + await flushPromises(); + // Past the lobby, but not in the call: the host has not asked yet + expect(screen.queryByText("Leave")).toBeNull(); + + const reply = vi.fn(); + act(() => + join$.next({ data: { audioInput: null, videoInput: null }, reply }), + ); + // Then in the call, and the host told so + await waitFor(() => expect(reply).toHaveBeenCalledOnce()); + expect(screen.getByText("Leave")).toBeInTheDocument(); +}); + test("Should not ask the host to close when auto leave due to error", async () => { const user = userEvent.setup(); diff --git a/src/room/KnockLobbyView.test.tsx b/src/room/KnockLobbyView.test.tsx new file mode 100644 index 000000000..a513952b7 --- /dev/null +++ b/src/room/KnockLobbyView.test.tsx @@ -0,0 +1,95 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { TooltipProvider } from "@vector-im/compound-web"; +import { type MatrixClient, type RoomSummary } from "matrix-js-sdk"; + +import { KnockLobbyView } from "./KnockLobbyView"; +import { LeaveToHomeProvider } from "../LeaveToHomeContext"; +import { MediaDevicesContext } from "../MediaDevicesContext"; +import { type ProcessorState } from "../livekit/TrackProcessorContext"; +import { mockMediaDevices } from "../utils/test"; + +vi.mock("@livekit/components-react", () => ({ + usePreviewTracks: (): unknown[] => [], +})); + +vi.mock("../livekit/TrackProcessorContext", () => ({ + useTrackProcessor: (): ProcessorState => ({ + supported: false, + processor: undefined, + }), + useTrackProcessorSync: (): void => {}, +})); + +vi.mock("react-use-measure", () => ({ + default: (): [() => void, object] => [(): void => {}, {}], +})); + +vi.mock("../settings/SettingsModal", () => ({ + SettingsModal: (): null => null, + defaultSettingsTab: "general", +})); + +const client = { + getUserId: () => "@user:example.org", + getDeviceId: () => "DEVICE", +} as Partial as MatrixClient; + +// What peeking at a room we are not in tells us about it +const roomSummary = { + room_id: "!room:example.org", + name: "Knock Room", + "im.nheko.summary.encryption": "m.megolm.v1.aes-sha2", +} as Partial as RoomSummary; + +function renderKnockLobby(knock: (() => void) | null): void { + render( + + + + + + + , + ); +} + +describe("KnockLobbyView", () => { + it("offers to ask to join, with what it knows of the room", async () => { + const knock = vi.fn(); + renderKnockLobby(knock); + + // The mute state arrives asynchronously, and the lobby with it + const button = await screen.findByTestId("lobby_joinCall"); + expect(button).toHaveTextContent("Request to join call"); + expect(button).toBeEnabled(); + expect(screen.getByText("Knock Room")).toBeInTheDocument(); + + await userEvent.setup().click(button); + expect(knock).toHaveBeenCalledOnce(); + }); + + it("waits once it has asked", async () => { + renderKnockLobby(null); + + const button = await screen.findByTestId("lobby_joinCall"); + expect(button).toHaveTextContent("Request sent!"); + // Compound's button keeps focusable, saying so through ARIA instead + expect(button).toHaveAttribute("aria-disabled", "true"); + }); +}); diff --git a/src/state/MuteStates.test.ts b/src/state/MuteStates.test.ts index 239f0381f..efccd70be 100644 --- a/src/state/MuteStates.test.ts +++ b/src/state/MuteStates.test.ts @@ -6,15 +6,22 @@ Please see LICENSE in the repository root for full details. */ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, NEVER, Subject } from "rxjs"; import { logger } from "matrix-js-sdk/lib/logger"; -import { nullHostBridge } from "../HostBridge"; +import { + type DeviceMuteRequest, + type DeviceMuteState, + type HostBridge, + type HostRequest, + nullHostBridge, +} from "../HostBridge"; import { MuteStates, MuteState } from "./MuteStates"; import { type AudioOutputDeviceLabel, type DeviceLabel, type MediaDevice, + type SelectedAudioInputDevice, type SelectedAudioOutputDevice, type SelectedDevice, } from "./MediaDevices"; @@ -221,6 +228,80 @@ describe("MuteStates", () => { }; } + function aAudioInput(): MediaDevice { + return { + available$: constant( + new Map([ + ["mic0", { type: "name", name: "Built-in Microphone" }], + ]), + ), + selected$: constant({ id: "mic0", hardwareDeviceChange$: NEVER }), + select(): void {}, + }; + } + + test("keeps the host informed and applies its mute requests", async () => { + const deviceMute$ = new Subject< + HostRequest + >(); + const notifyDeviceMute = vi.fn(async (): Promise => {}); + const hostBridge: HostBridge = { + ...nullHostBridge, + notifyDeviceMute, + deviceMute$, + }; + const muteStates = new MuteStates( + testScope, + mockMediaDevices({ + audioInput: aAudioInput(), + videoInput: aVideoInput(), + }), + { audioEnabled: true, videoEnabled: false }, + hostBridge, + ); + await flushPromises(); + + // The host hears the state we started in + expect(notifyDeviceMute).toHaveBeenLastCalledWith({ + audio_enabled: true, + video_enabled: false, + }); + + // The host asks for the camera on, saying nothing about the microphone, + // which is left as it is + const reply = vi.fn(); + deviceMute$.next({ data: { video_enabled: true }, reply }); + await flushPromises(); + expect(reply).toHaveBeenCalledExactlyOnceWith({ + audio_enabled: true, + video_enabled: true, + }); + expect(muteStates.audio.enabled$.value).toBe(true); + expect(muteStates.video.enabled$.value).toBe(true); + expect(notifyDeviceMute).toHaveBeenLastCalledWith({ + audio_enabled: true, + video_enabled: true, + }); + + // Then for everything off + const replyAgain = vi.fn(); + deviceMute$.next({ + data: { audio_enabled: false, video_enabled: false }, + reply: replyAgain, + }); + await flushPromises(); + expect(replyAgain).toHaveBeenCalledExactlyOnceWith({ + audio_enabled: false, + video_enabled: false, + }); + expect(muteStates.audio.enabled$.value).toBe(false); + expect(muteStates.video.enabled$.value).toBe(false); + expect(notifyDeviceMute).toHaveBeenLastCalledWith({ + audio_enabled: false, + video_enabled: false, + }); + }); + test("should mute camera when in earpiece mode", async () => { const audioOutputDevice = aAudioOutputDevices();