From 6e44332fd1ada38406a6890f0b77fe7267ea50b6 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Sep 2026 10:31:48 +0200 Subject: [PATCH] Stop reading URL parameters from inside the call path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The view models reached for getUrlParams() — and so window.location — from deep inside the call path: CallViewModel, MediaDevices, Publisher, LocalMember and the footer view model. An embedded Element Call has no URL of its own, so these values have to arrive as arguments instead. Add the relevant options to CallViewModelOptions, to the MediaDevices and Publisher constructors, to createLocalMembership$ and enterRTCSession, and to createCallFooterViewModel. The remaining React consumers read the context added in the previous commit. AppViewModel now takes its audio output options too, moving that URL read out to main.tsx, where the app shell can act as the adapter. The new CallViewModelOptions fields are optional, defaulting to what the URL parameters resolve to outside widget mode; the MediaDevices and Publisher arguments are required, so that every construction site has to be explicit. useTheme.test.ts mocked the UrlParams module with a factory, so it needed updating to mock the hook rather than getUrlParams. No functional change. --- sdk/main.ts | 13 ++++-- src/App.tsx | 25 ++++------- src/components/CallFooter.stories.tsx | 4 +- src/components/CallFooterViewModel.test.ts | 3 ++ src/components/CallFooterViewModel.tsx | 7 ++- .../MediaMuteAndSwitchButton.stories.tsx | 4 +- src/main.tsx | 11 ++++- src/room/GroupCallView.tsx | 10 ++--- src/room/InCallView.tsx | 19 +++++++- src/state/AppViewModel.ts | 12 +++-- src/state/CallViewModel/CallViewModel.test.ts | 5 ++- src/state/CallViewModel/CallViewModel.ts | 44 ++++++++++++++++--- .../localMember/LocalMember.test.ts | 1 + .../CallViewModel/localMember/LocalMember.ts | 26 ++++++++--- .../localMember/Publisher.test.ts | 3 ++ .../CallViewModel/localMember/Publisher.ts | 6 ++- src/state/MediaDevices.ts | 31 ++++++++++--- src/useTheme.test.ts | 8 ++-- src/useTheme.ts | 7 ++- src/utils/test-viewmodel.ts | 2 + 20 files changed, 176 insertions(+), 65 deletions(-) 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,