diff --git a/sdk/main.ts b/sdk/main.ts index a001af65c..55aa4a022 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -116,7 +116,7 @@ export async function createMatrixRTCSdk( logger.info("client created"); // url params - const { roomId } = getUrlParams(); + const { roomId, controlledAudioDevices, callIntent } = getUrlParams(); if (roomId === null) throw Error("could not get roomId from url params"); const room = client.getRoom(roomId); if (room === null) throw Error("could not get room from client"); @@ -128,7 +128,10 @@ export async function createMatrixRTCSdk( const rtcSession = rtcSessionManager.getRoomSession(room); // media devices - const mediaDevices = new MediaDevices(scope); + const mediaDevices = new MediaDevices(scope, { + controlledAudioDevices, + callIntent, + }); const muteStates = new MuteStates(scope, mediaDevices, { audioEnabled: false, videoEnabled: false, @@ -141,7 +144,11 @@ export async function createMatrixRTCSdk( room, mediaDevices, muteStates, - { encryptionSystem: { kind: E2eeType.PER_PARTICIPANT } }, + { + encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, + controlledAudioDevices, + callIntent, + }, of({}), of({}), constant({ supported: false, processor: undefined }), diff --git a/src/App.tsx b/src/App.tsx index 511577dee..238815a7b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,14 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { - type FC, - type JSX, - Suspense, - useEffect, - useMemo, - useState, -} from "react"; +import { type FC, type JSX, Suspense, useEffect, useState } from "react"; import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom"; import * as Sentry from "@sentry/react"; import { TooltipProvider } from "@vector-im/compound-web"; @@ -32,7 +25,6 @@ import { ProcessorProvider } from "./livekit/TrackProcessorContext"; import { type AppViewModel } from "./state/AppViewModel"; import { MediaDevicesContext } from "./MediaDevicesContext"; import { - getUrlParams, HeaderStyle, UrlParamsProvider, useUrlParams, @@ -75,6 +67,12 @@ const ThemeProvider: FC = ({ children }) => { return children; }; +/** Wraps the app in an {@link AppBar}, if the params ask for one. */ +const MaybeAppBar: FC = ({ children }) => { + const { header } = useUrlParams(); + return header === HeaderStyle.AppBar ? {children} : children; +}; + interface Props { vm: AppViewModel; } @@ -91,9 +89,6 @@ export const App: FC = ({ vm }) => { .catch(logger.error); }); - // Since we are outside the router component, we cannot use useUrlParams here - const { header } = useMemo(getUrlParams, []); - const content = loaded ? ( @@ -123,11 +118,7 @@ export const App: FC = ({ vm }) => { - {header === HeaderStyle.AppBar ? ( - {content} - ) : ( - content - )} + {content} diff --git a/src/components/CallFooter.stories.tsx b/src/components/CallFooter.stories.tsx index 667cb6070..3c3f46074 100644 --- a/src/components/CallFooter.stories.tsx +++ b/src/components/CallFooter.stories.tsx @@ -29,7 +29,9 @@ const reactionData = { reactions$: new BehaviorSubject({}), }; -const mediaDevices = new MediaDevices(globalScope); +const mediaDevices = new MediaDevices(globalScope, { + controlledAudioDevices: false, +}); /** * A wrapper component that is used for: diff --git a/src/components/CallFooterViewModel.test.ts b/src/components/CallFooterViewModel.test.ts index 1fc9187ad..9e73393be 100644 --- a/src/components/CallFooterViewModel.test.ts +++ b/src/components/CallFooterViewModel.test.ts @@ -15,6 +15,7 @@ import type { Alignment, Layout } from "../state/layout-types"; import type { SpotlightTileViewModel } from "../state/TileViewModel"; import type { DeviceLabel } from "../state/MediaDevices"; import { createCallFooterViewModel } from "./CallFooterViewModel"; +import { HeaderStyle } from "../UrlParams"; const platformMock = vi.hoisted(() => vi.fn(() => "desktop")); vi.mock("../Platform", () => ({ @@ -105,6 +106,7 @@ describe("createCallFooterViewModel", () => { mockMuteStates(), twoMicsAndOneCamMediaDevices, /* reactionIdentifier */ undefined, + { showControls: true, header: HeaderStyle.Standard }, ); expect(vm.audioOptions$.value).toEqual([]); @@ -126,6 +128,7 @@ describe("createCallFooterViewModel", () => { mockMuteStates(), twoMicsAndOneCamMediaDevices, /* reactionIdentifier */ undefined, + { showControls: true, header: HeaderStyle.Standard }, ); expect(vm.audioOptions$?.value).toEqual([ diff --git a/src/components/CallFooterViewModel.tsx b/src/components/CallFooterViewModel.tsx index 7e391b169..a2ca6c88e 100644 --- a/src/components/CallFooterViewModel.tsx +++ b/src/components/CallFooterViewModel.tsx @@ -19,7 +19,7 @@ import { type Behavior, constant } from "../state/Behavior"; import type { ObservableScope } from "../state/ObservableScope"; import { type MuteStates } from "../state/MuteStates"; import { createStaticViewModel, type ViewModel } from "../state/ViewModel"; -import { getUrlParams, HeaderStyle } from "../UrlParams"; +import { HeaderStyle } from "../UrlParams"; import { platform } from "../Platform"; import { type FooterSnapshot } from "./CallFooter"; @@ -138,6 +138,8 @@ function buildDeviceBehaviors( * @param mediaDevices - Available and selected input devices. * @param reactionIdentifier - The local user's reaction identifier string, or * undefined when reactions are not supported (hides the reaction button). + * @param options - `showControls`: whether the call controls should be shown. + * `header`: the style of header, which decides whether to show the logo. */ export function createCallFooterViewModel( scope: ObservableScope, @@ -145,8 +147,9 @@ export function createCallFooterViewModel( muteStates: MuteStates, mediaDevices: MediaDevices, reactionIdentifier: string | undefined, + options: { showControls: boolean; header: HeaderStyle }, ): ViewModel { - const { showControls, header: headerStyle } = getUrlParams(); + const { showControls, header: headerStyle } = options; const showLogo = headerStyle === HeaderStyle.Standard; const isPip$ = scope.behavior( diff --git a/src/components/MediaMuteAndSwitchButton.stories.tsx b/src/components/MediaMuteAndSwitchButton.stories.tsx index 89c123929..21def4007 100644 --- a/src/components/MediaMuteAndSwitchButton.stories.tsx +++ b/src/components/MediaMuteAndSwitchButton.stories.tsx @@ -14,7 +14,9 @@ import { MediaDevicesContext } from "../MediaDevicesContext"; import { MediaDevices } from "../state/MediaDevices"; import { globalScope } from "../state/ObservableScope"; -const mediaDevices = new MediaDevices(globalScope); +const mediaDevices = new MediaDevices(globalScope, { + controlledAudioDevices: false, +}); const meta = { component: MediaMuteAndSwitchButton, diff --git a/src/main.tsx b/src/main.tsx index 8f64c680a..03c74c94c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -21,6 +21,7 @@ import { init as initRageshake } from "./settings/rageshake"; import { Initializer } from "./initializer"; import { AppViewModel } from "./state/AppViewModel"; import { globalScope } from "./state/ObservableScope"; +import { getUrlParams } from "./UrlParams"; initRageshake().catch((e) => { logger.error("Failed to initialize rageshake", e); @@ -49,9 +50,17 @@ if (fatalError !== null) { Initializer.initBeforeReact() .then(() => { + const { controlledAudioDevices, callIntent } = getUrlParams(); root.render( - + , ); }) diff --git a/src/room/GroupCallView.tsx b/src/room/GroupCallView.tsx index dfeb0866a..57e9205f1 100644 --- a/src/room/GroupCallView.tsx +++ b/src/room/GroupCallView.tsx @@ -54,12 +54,7 @@ import { useRoomAvatar } from "./useRoomAvatar"; import { useRoomName } from "./useRoomName"; import { useJoinRule } from "./useJoinRule"; import { InviteModal } from "./InviteModal"; -import { - getUrlParams, - HeaderStyle, - type UrlParams, - useUrlParams, -} from "../UrlParams"; +import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams"; import { E2eeType } from "../e2ee/e2eeType"; import { useAudioContext } from "../useAudioContext"; import { @@ -406,7 +401,7 @@ export const GroupCallView: FC = ({ } // On a normal user hangup we can shut down and close the widget. But if an // error occurs we should keep the widget open until the user reads it. - if (reason != "error" && !getUrlParams().returnToLobby) { + if (reason != "error" && !returnToLobby) { try { await widget.api.transport.send(ElementWidgetActions.Close, {}); } catch (e) { @@ -425,6 +420,7 @@ export const GroupCallView: FC = ({ rtcSession, isPasswordlessUser, confineToRoom, + returnToLobby, navigate, ], ); diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index a57dcce2b..e291e001b 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -122,8 +122,16 @@ export const ActiveCall: FC = (props) => { rootLogger.info("START CALL VIEW SCOPE"); const scope = new ObservableScope(); const reactionsReader = new ReactionsReader(scope, props.rtcSession); - const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } = - urlParams; + const { + autoLeaveWhenOthersLeft, + waitForCallPickup, + sendNotificationType, + controlledAudioDevices, + header, + showControls, + hideScreensharing, + callIntent, + } = urlParams; const vm = createCallViewModel$( scope, @@ -136,6 +144,12 @@ export const ActiveCall: FC = (props) => { autoLeaveWhenOthersLeft, waitForCallPickup: waitForCallPickup && sendNotificationType === "ring", matrixRTCMode$: matrixRTCModeSetting.value$, + controlledAudioDevices, + header, + showControls, + hideScreensharing, + sendNotificationType, + callIntent, }, reactionsReader.raisedHands$, reactionsReader.reactions$, @@ -172,6 +186,7 @@ export const ActiveCall: FC = (props) => { props.muteStates, mediaDevices, `${props.client.getUserId()}:${props.client.getDeviceId()}`, + { showControls: urlParams.showControls, header: urlParams.header }, ); setFooterVm(footerVm); setDeveloperSettingsVm(createDeveloperSettingsTabViewModel(scope, vm)); diff --git a/src/state/AppViewModel.ts b/src/state/AppViewModel.ts index 7ad91e9dc..3f69515b2 100644 --- a/src/state/AppViewModel.ts +++ b/src/state/AppViewModel.ts @@ -5,17 +5,23 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ -import { MediaDevices } from "./MediaDevices"; +import { type AudioOutputOptions, MediaDevices } from "./MediaDevices"; import { type ObservableScope } from "./ObservableScope"; /** * The top-level state holder for the application. */ export class AppViewModel { - public readonly mediaDevices = new MediaDevices(this.scope); + public readonly mediaDevices = new MediaDevices( + this.scope, + this.audioOutputOptions, + ); // TODO: Move more application logic here. The CallViewModel, at the very // least, ought to be accessible from this object. - public constructor(private readonly scope: ObservableScope) {} + public constructor( + private readonly scope: ObservableScope, + private readonly audioOutputOptions: AudioOutputOptions, + ) {} } diff --git a/src/state/CallViewModel/CallViewModel.test.ts b/src/state/CallViewModel/CallViewModel.test.ts index 181549171..7a27fd3e0 100644 --- a/src/state/CallViewModel/CallViewModel.test.ts +++ b/src/state/CallViewModel/CallViewModel.test.ts @@ -1593,12 +1593,13 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => { it.skip("audio output changes when toggling earpiece mode", () => { withTestScheduler(({ schedule, expectObservable }) => { - getUrlParams.mockReturnValue({ controlledAudioDevices: true }); vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue( of([]), ); - const devices = new MediaDevices(testScope()); + const devices = new MediaDevices(testScope(), { + controlledAudioDevices: true, + }); window.controls.setAvailableAudioDevices([ { id: "speaker", name: "Speaker", isSpeaker: true }, diff --git a/src/state/CallViewModel/CallViewModel.ts b/src/state/CallViewModel/CallViewModel.ts index 09f73d1a6..e9abeae59 100644 --- a/src/state/CallViewModel/CallViewModel.ts +++ b/src/state/CallViewModel/CallViewModel.ts @@ -45,6 +45,8 @@ import { MembershipManagerEvent, type LivekitTransportConfig, type MatrixRTCSession, + type RTCCallIntent, + type RTCNotificationType, } from "matrix-js-sdk/lib/matrixrtc"; import { type IWidgetApiRequest } from "matrix-widget-api"; import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager"; @@ -85,7 +87,7 @@ import { constant, type Behavior } from "../Behavior"; import { E2eeType } from "../../e2ee/e2eeType"; import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider"; import { type MuteStates } from "../MuteStates"; -import { getUrlParams, HeaderStyle } from "../../UrlParams"; +import { HeaderStyle } from "../../UrlParams"; import { type ProcessorState } from "../../livekit/TrackProcessorContext"; import { ElementWidgetActions, widget } from "../../widget"; import { @@ -171,6 +173,23 @@ import { type GridTileViewModel } from "../TileViewModel.ts"; // callMembership -> rtcMembership export interface CallViewModelOptions { encryptionSystem: EncryptionSystem; + /** + * Whether the app hosting Element Call controls the audio output devices, + * rather than the browser. Defaults to false. + */ + controlledAudioDevices?: boolean; + /** The style of header to show. Defaults to {@link HeaderStyle.Standard}. */ + header?: HeaderStyle; + /** Whether the call controls should be shown. Defaults to true. */ + showControls?: boolean; + /** Whether to hide the screen-sharing button. Defaults to false. */ + hideScreensharing?: boolean; + /** + * Whether and what kind of notification to send when joining the call. + */ + sendNotificationType?: RTCNotificationType; + /** The kind of call being placed. */ + callIntent?: RTCCallIntent; autoLeaveWhenOthersLeft?: boolean; /** * If the call is started in a way where we want it to behave like a telephone usecase @@ -435,6 +454,17 @@ export function createCallViewModel$( if (!(userId && deviceId)) throw new UnknownCallError(new Error("userId and deviceId are required")); + // Defaults match what the URL parameters resolve to outside of widget mode, + // so that callers which don't care (chiefly tests) behave as they always have. + const { + controlledAudioDevices = false, + header = HeaderStyle.Standard, + showControls = true, + hideScreensharing = false, + sendNotificationType, + callIntent, + } = options; + const livekitKeyProvider = getE2eeKeyProvider( options.encryptionSystem, matrixRTCSession, @@ -523,7 +553,7 @@ export function createCallViewModel$( mediaDevices, trackProcessorState$, livekitKeyProvider, - getUrlParams().controlledAudioDevices, + controlledAudioDevices, options.livekitRoomFactory, ); @@ -563,6 +593,8 @@ export function createCallViewModel$( encryptMedia: livekitKeyProvider !== undefined, // TODO. This might need to get called again on each change of matrixRTCMode... matrixRTCMode: mode, + sendNotificationType, + callIntent, })), ), ); @@ -592,12 +624,14 @@ export function createCallViewModel$( logger.getChild( "[Publisher " + connection.transport.livekit_service_url + "]", ), + controlledAudioDevices, ); }, connectionManager, matrixRTCSession, localTransport$, roomId: matrixRoom.roomId, + hideScreensharing, logger: logger.getChild(`[${Date.now()}]`), }); @@ -1457,9 +1491,8 @@ export function createCallViewModel$( ), ); - const urlParams = getUrlParams(); const showFooterUrlParams = !( - urlParams.header === HeaderStyle.None && urlParams.showControls === false + header === HeaderStyle.None && showControls === false ); const showFooter$ = scope.behavior( naturallyShowFooter$.pipe( @@ -1778,8 +1811,7 @@ export function createCallViewModel$( return { autoLeave$: autoLeave$, ringingVm$: ringingMedia$, - ringingStatusLocation: - urlParams.header === HeaderStyle.AppBar ? "app_bar" : "tile", + ringingStatusLocation: header === HeaderStyle.AppBar ? "app_bar" : "tile", leave$: leave$, hangup: (): void => userHangup$.next(), join: localMembership.requestJoinAndPublish, diff --git a/src/state/CallViewModel/localMember/LocalMember.test.ts b/src/state/CallViewModel/localMember/LocalMember.test.ts index 9ea6bb72c..2b89d50b5 100644 --- a/src/state/CallViewModel/localMember/LocalMember.test.ts +++ b/src/state/CallViewModel/localMember/LocalMember.test.ts @@ -199,6 +199,7 @@ describe("LocalMembership", () => { rtsSession$: constant(RTCMemberStatus.Connected), }, roomId: "!test-room-id:example.org", + hideScreensharing: false, }; it("throws error on missing RTC config error", () => { diff --git a/src/state/CallViewModel/localMember/LocalMember.ts b/src/state/CallViewModel/localMember/LocalMember.ts index 2f4fde26b..844b66dee 100644 --- a/src/state/CallViewModel/localMember/LocalMember.ts +++ b/src/state/CallViewModel/localMember/LocalMember.ts @@ -20,6 +20,8 @@ import { type LivekitTransport, type LivekitTransportConfig, type MatrixRTCSession, + type RTCCallIntent, + type RTCNotificationType, } from "matrix-js-sdk/lib/matrixrtc"; import { BehaviorSubject, @@ -52,7 +54,7 @@ import { UnknownCallError, } from "../../../utils/errors.ts"; import { ElementWidgetActions, widget } from "../../../widget.ts"; -import { getUrlParams } from "../../../UrlParams.ts"; + import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts"; import { advancedScreenShare, @@ -141,6 +143,8 @@ interface Props { MatrixRTCSession, "updateCallIntent" | "leaveRoomSession" >; + /** Whether to hide the screen-sharing button. */ + hideScreensharing: boolean; logger: Logger; } @@ -160,6 +164,7 @@ interface Props { * @param props.muteStates The mute states for video and audio. * @param props.matrixRTCSession The matrix RTC session to join. * @param props.roomId The room ID used as the call identifier in analytics events. + * @param props.hideScreensharing Whether to hide the screen-sharing button. * @returns * - publisher: The handle to create tracks and publish them to the room. * - connected$: the current connection state. Including matrix server and livekit server connection. (only considering the livekit server we are using for our own media publication) @@ -178,6 +183,7 @@ export const createLocalMembership$ = ({ muteStates, matrixRTCSession, roomId, + hideScreensharing, }: Props): { /** * This request to start audio and video tracks. @@ -709,7 +715,7 @@ export const createLocalMembership$ = ({ let toggleScreenSharing: (() => void) | null = null; if ( "getDisplayMedia" in (navigator.mediaDevices ?? {}) && - !getUrlParams().hideScreensharing + !hideScreensharing ) { toggleScreenSharing = (): void => { const screenshareSettings: ScreenShareCaptureOptions = { @@ -820,6 +826,10 @@ export function observeSharingScreen$(p: Participant): Observable { interface EnterRTCSessionOptions { encryptMedia: boolean; matrixRTCMode: MatrixRTCMode; + /** Whether and what kind of notification to send when joining. */ + sendNotificationType?: RTCNotificationType; + /** The kind of call being placed. */ + callIntent?: RTCCallIntent; } /** @@ -832,7 +842,9 @@ interface EnterRTCSessionOptions { * @param rtcSession - The MatrixRTCSession to join. * @param ownMembershipIdentity - Options for entering the RTC session. * @param transport - The LivekitTransport to use for this session. - * @param options - `encryptMedia`: Whether to encrypt media `matrixRTCMode`: The Matrix RTC mode to use. + * @param options - `encryptMedia`: Whether to encrypt media. `matrixRTCMode`: The + * Matrix RTC mode to use. `sendNotificationType`: Whether and what kind of + * notification to send on join. `callIntent`: The kind of call being placed. * @throws If the widget could not send ElementWidgetActions.JoinCall action. */ // Exported for unit testing @@ -842,7 +854,12 @@ export function enterRTCSession( transport: LivekitTransportConfig, options: EnterRTCSessionOptions, ): void { - const { encryptMedia, matrixRTCMode } = options; + const { + encryptMedia, + matrixRTCMode, + sendNotificationType: notificationType, + callIntent, + } = options; PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date()); PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId); @@ -851,7 +868,6 @@ export function enterRTCSession( // groupCallOTelMembership?.onJoinCall(); const { matrix_rtc_session: matrixRtcSessionConfig } = Config.get(); - const { sendNotificationType: notificationType, callIntent } = getUrlParams(); const multiSFU = matrixRTCMode === MatrixRTCMode.Compatibility || matrixRTCMode === MatrixRTCMode.Matrix_2_0; diff --git a/src/state/CallViewModel/localMember/Publisher.test.ts b/src/state/CallViewModel/localMember/Publisher.test.ts index 21775c58d..5d9f03784 100644 --- a/src/state/CallViewModel/localMember/Publisher.test.ts +++ b/src/state/CallViewModel/localMember/Publisher.test.ts @@ -192,6 +192,7 @@ describe("Publisher", () => { muteStates, constant({ supported: false, processor: undefined }), logger, + false, ); }); @@ -309,6 +310,7 @@ describe("Publisher", () => { muteStates, constant({ supported: false, processor: undefined }), logger, + false, ); }); afterEach(async () => { @@ -364,6 +366,7 @@ describe("Bug fix", () => { muteStates, constant({ supported: false, processor: undefined }), logger, + false, ); audioEnabled$.next(true); diff --git a/src/state/CallViewModel/localMember/Publisher.ts b/src/state/CallViewModel/localMember/Publisher.ts index 0d5f263a6..5353a98d4 100644 --- a/src/state/CallViewModel/localMember/Publisher.ts +++ b/src/state/CallViewModel/localMember/Publisher.ts @@ -29,7 +29,7 @@ import { type ProcessorState, trackProcessorSync, } from "../../../livekit/TrackProcessorContext.tsx"; -import { getUrlParams } from "../../../UrlParams.ts"; + import { observeTrackReference$ } from "../../observeTrackReference"; import { type Connection } from "../remoteMembers/Connection.ts"; import { ObservableScope } from "../../ObservableScope.ts"; @@ -56,6 +56,8 @@ export class Publisher { * @param muteStates - The mute states for audio and video. * @param trackerProcessorState$ - The processor state for the video track processor (e.g. background blur). * @param logger - The logger to use for logging :D. + * @param controlledAudioDevices - Whether the app hosting Element Call + * controls the audio output devices, rather than the browser. */ public constructor( private connection: Pick, //setE2EEEnabled, @@ -63,8 +65,8 @@ export class Publisher { private readonly muteStates: MuteStates, trackerProcessorState$: Behavior, private logger: Logger, + controlledAudioDevices: boolean, ) { - const { controlledAudioDevices } = getUrlParams(); const room = connection.livekitRoom; room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => { diff --git a/src/state/MediaDevices.ts b/src/state/MediaDevices.ts index 70a676cf5..4610bab66 100644 --- a/src/state/MediaDevices.ts +++ b/src/state/MediaDevices.ts @@ -16,6 +16,7 @@ import { } from "rxjs"; import { createMediaDeviceObserver } from "@livekit/components-core"; import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger"; +import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc"; import { alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting, @@ -25,7 +26,6 @@ import { } from "../settings/settings"; import { type ObservableScope } from "./ObservableScope"; import { availableOutputDevices$ as controlledAvailableOutputDevices$ } from "../controls"; -import { getUrlParams } from "../UrlParams"; import { platform } from "../Platform"; import { switchWhen } from "../utils/observable"; import { type Behavior, constant } from "./Behavior"; @@ -338,6 +338,22 @@ class VideoInput implements MediaDevice { } } +/** + * How Element Call should manage audio output. + */ +export interface AudioOutputOptions { + /** + * Whether the list of output devices is controlled by the app hosting Element + * Call, through the global JS controls, rather than by the browser. + */ + controlledAudioDevices: boolean; + /** + * The kind of call being placed, which decides the initial output route when + * the host controls the devices. + */ + callIntent?: RTCCallIntent; +} + export class MediaDevices { private readonly deviceNamesRequest$ = new Subject(); /** @@ -368,23 +384,28 @@ export class MediaDevices { public readonly audioOutput: MediaDevice< AudioOutputDeviceLabel, SelectedAudioOutputDevice - > = getUrlParams().controlledAudioDevices + > = this.audioOutputOptions.controlledAudioDevices ? platform == "android" ? new AndroidControlledAudioOutput( controlledAvailableOutputDevices$, this.scope, - getUrlParams().callIntent, + this.audioOutputOptions.callIntent, window.controls, ) : new IOSControlledAudioOutput( this.usingNames$, this.scope, - getUrlParams().callIntent, + this.audioOutputOptions.callIntent, ) : new AudioOutput(this.usingNames$, this.scope); public readonly videoInput: MediaDevice = new VideoInput(this.usingNames$, this.scope); - public constructor(private readonly scope: ObservableScope) {} + // Note: both parameters are read by the field initializers above, which is + // safe because TypeScript assigns parameter properties before running them. + public constructor( + private readonly scope: ObservableScope, + private readonly audioOutputOptions: AudioOutputOptions, + ) {} } diff --git a/src/useTheme.test.ts b/src/useTheme.test.ts index 6e4714626..4078ed346 100644 --- a/src/useTheme.test.ts +++ b/src/useTheme.test.ts @@ -19,10 +19,10 @@ import EventEmitter from "events"; import { WidgetApiToWidgetAction } from "matrix-widget-api"; import { useTheme } from "./useTheme"; -import { getUrlParams } from "./UrlParams"; +import { useUrlParams } from "./UrlParams"; import { widget } from "./widget"; -vi.mock("./UrlParams", () => ({ getUrlParams: vi.fn() })); +vi.mock("./UrlParams", () => ({ useUrlParams: vi.fn() })); vi.mock("./widget", () => ({ widget: { api: { transport: { reply: vi.fn() } }, @@ -39,7 +39,7 @@ describe("useTheme", () => { vi.spyOn(originalClassList, "add"); vi.spyOn(originalClassList, "remove"); vi.spyOn(originalClassList, "item").mockReturnValue(null); - (getUrlParams as Mock).mockReturnValue({ theme: "dark" }); + (useUrlParams as Mock).mockReturnValue({ theme: "dark" }); }); afterEach(() => { @@ -53,7 +53,7 @@ describe("useTheme", () => { { setTheme: "light-high-contrast", add: ["cpd-theme-light-hc"] }, ])("apply procedure", ({ setTheme, add }) => { test(`should apply ${add[0]} theme when ${setTheme} theme is specified`, () => { - (getUrlParams as Mock).mockReturnValue({ theme: setTheme }); + (useUrlParams as Mock).mockReturnValue({ theme: setTheme }); renderHook(() => useTheme()); diff --git a/src/useTheme.ts b/src/useTheme.ts index 5bac28982..85dace2aa 100644 --- a/src/useTheme.ts +++ b/src/useTheme.ts @@ -9,15 +9,14 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { WidgetApiToWidgetAction } from "matrix-widget-api"; import { type IThemeChangeActionRequest } from "matrix-widget-api"; -import { getUrlParams } from "./UrlParams"; +import { useUrlParams } from "./UrlParams"; import { widget } from "./widget"; import { useRootElement } from "./RootElementContext"; export const useTheme = (): void => { const rootElement = useRootElement(); - const [requestedTheme, setRequestedTheme] = useState( - () => getUrlParams().theme, - ); + const { theme } = useUrlParams(); + const [requestedTheme, setRequestedTheme] = useState(theme); const previousTheme = useRef(rootElement.classList.item(0)); useEffect(() => { diff --git a/src/utils/test-viewmodel.ts b/src/utils/test-viewmodel.ts index f53910024..8e88e720c 100644 --- a/src/utils/test-viewmodel.ts +++ b/src/utils/test-viewmodel.ts @@ -40,6 +40,7 @@ import { type RaisedHandInfo, type ReactionInfo } from "../reactions"; import { constant } from "../state/Behavior"; import { MatrixRTCMode } from "../config/ConfigOptions"; import { createCallFooterViewModel } from "../components/CallFooterViewModel"; +import { HeaderStyle } from "../UrlParams"; import { type FooterSnapshot } from "../components/CallFooter"; import { type ViewModel } from "../state/ViewModel"; import { createDeveloperSettingsTabViewModel } from "../settings/DeveloperSettingsTabViewModel"; @@ -187,6 +188,7 @@ export function getBasicCallViewModelEnvironment( muteStates, mediaDevices, "reactionId", + { showControls: true, header: HeaderStyle.Standard }, ); return { vm,