From a9153f2781dd31bebab2e66cddd6cf6f3f5c99af Mon Sep 17 00:00:00 2001 From: Valere Date: Fri, 9 Jan 2026 12:00:45 +0100 Subject: [PATCH] fix: Regression on default mutestate for voicecall + end-2-end tests --- playwright/fixtures/widget-user.ts | 95 +++++++----- playwright/widget/voice-call-dm.spec.ts | 183 ++++++++++++++++++++++++ sdk/main.ts | 2 +- src/UrlParams.test.ts | 4 - src/UrlParams.ts | 34 +---- src/button/Button.tsx | 3 + src/room/RoomPage.tsx | 15 +- src/state/MuteStates.test.ts | 7 +- src/state/MuteStates.ts | 140 +++++++++--------- src/state/initialMuteState.test.ts | 95 ++++++++++++ src/state/initialMuteState.ts | 46 ++++++ src/utils/test.ts | 2 +- 12 files changed, 467 insertions(+), 159 deletions(-) create mode 100644 playwright/widget/voice-call-dm.spec.ts create mode 100644 src/state/initialMuteState.test.ts create mode 100644 src/state/initialMuteState.ts diff --git a/playwright/fixtures/widget-user.ts b/playwright/fixtures/widget-user.ts index f1f738b7..0611c97e 100644 --- a/playwright/fixtures/widget-user.ts +++ b/playwright/fixtures/widget-user.ts @@ -17,6 +17,7 @@ import type { MatrixClient } from "matrix-js-sdk"; export type UserBaseFixture = { mxId: string; + displayName: string; page: Page; clientHandle: JSHandle; }; @@ -28,6 +29,7 @@ export type BaseWidgetSetup = { export interface MyFixtures { asWidget: BaseWidgetSetup; + callType: "room" | "dm"; } const PASSWORD = "foobarbaz1!"; @@ -145,25 +147,27 @@ async function registerUser( } export const widgetTest = test.extend({ - asWidget: async ({ browser, context }, pUse) => { + // allow per-test override: `widgetTest.use({ callType: "dm" })` + callType: ["room", { option: true }], + asWidget: async ({ browser, context, callType }, pUse) => { await context.route(`http://localhost:8081/config.json*`, async (route) => { await route.fulfill({ json: CONFIG_JSON }); }); - const userA = `brooks_${Date.now()}`; - const userB = `whistler_${Date.now()}`; + const brooksDisplayName = `brooks_${Date.now()}`; + const whistlerDisplayName = `whistler_${Date.now()}`; // Register users const { page: ewPage1, clientHandle: brooksClientHandle, mxId: brooksMxId, - } = await registerUser(browser, userA); + } = await registerUser(browser, brooksDisplayName); const { page: ewPage2, clientHandle: whistlerClientHandle, mxId: whistlerMxId, - } = await registerUser(browser, userB); + } = await registerUser(browser, whistlerDisplayName); // Invite the second user await ewPage1 @@ -171,37 +175,60 @@ export const widgetTest = test.extend({ .getByRole("button", { name: "New conversation" }) .click(); - await ewPage1.getByRole("menuitem", { name: "New Room" }).click(); - await ewPage1.getByRole("textbox", { name: "Name" }).fill("Welcome Room"); - await ewPage1.getByRole("button", { name: "Create room" }).click(); - await expect(ewPage1.getByText("You created this room.")).toBeVisible(); - await expect(ewPage1.getByText("Encryption enabled")).toBeVisible(); + if (callType === "room") { - await ewPage1 - .getByRole("button", { name: "Invite to this room", exact: true }) - .click(); - await expect( - ewPage1.getByRole("heading", { name: "Invite to Welcome Room" }), - ).toBeVisible(); + await ewPage1.getByRole("menuitem", { name: "New Room" }).click(); + await ewPage1.getByRole("textbox", { name: "Name" }).fill("Welcome Room"); + await ewPage1.getByRole("button", { name: "Create room" }).click(); + await expect(ewPage1.getByText("You created this room.")).toBeVisible(); + await expect(ewPage1.getByText("Encryption enabled")).toBeVisible(); - // To get the invite textbox we need to specifically select within the - // dialog, since there is another textbox in the background (the message - // composer). In theory the composer shouldn't be visible to Playwright at - // all because the invite dialog has trapped focus, but the focus trap - // doesn't quite work right on Firefox. - await ewPage1.getByRole("dialog").getByRole("textbox").fill(whistlerMxId); - await ewPage1.getByRole("dialog").getByRole("textbox").click(); - await ewPage1.getByRole("button", { name: "Invite" }).click(); + await ewPage1 + .getByRole("button", { name: "Invite to this room", exact: true }) + .click(); + await expect( + ewPage1.getByRole("heading", { name: "Invite to Welcome Room" }), + ).toBeVisible(); - // Accept the invite - await expect( - ewPage2.getByRole("option", { name: "Welcome Room" }), - ).toBeVisible(); - await ewPage2.getByRole("option", { name: "Welcome Room" }).click(); - await ewPage2.getByRole("button", { name: "Accept" }).click(); - await expect( - ewPage2.getByRole("main").getByRole("heading", { name: "Welcome Room" }), - ).toBeVisible(); + // To get the invite textbox we need to specifically select within the + // dialog, since there is another textbox in the background (the message + // composer). In theory the composer shouldn't be visible to Playwright at + // all because the invite dialog has trapped focus, but the focus trap + // doesn't quite work right on Firefox. + await ewPage1.getByRole("dialog").getByRole("textbox").fill(whistlerMxId); + await ewPage1.getByRole("dialog").getByRole("textbox").click(); + await ewPage1.getByRole("button", { name: "Invite" }).click(); + + // Accept the invite + await expect( + ewPage2.getByRole("option", { name: "Welcome Room" }), + ).toBeVisible(); + await ewPage2.getByRole("option", { name: "Welcome Room" }).click(); + await ewPage2.getByRole("button", { name: "Accept" }).click(); + await expect( + ewPage2.getByRole("main").getByRole("heading", { name: "Welcome Room" }), + ).toBeVisible(); + } else if (callType === "dm") { + await ewPage1.getByRole("menuitem", { name: "Start chat" }).click(); + await ewPage1.getByRole('textbox', { name: 'Search' }).click(); + await ewPage1.getByRole('textbox', { name: 'Search' }).fill(whistlerMxId); + await ewPage1.getByRole("button", { name: "Go" }).click(); + + // Wait and send the first message to create the DM + await expect(ewPage1.getByText(/Send your first message to invite/)).toBeVisible(); + + await ewPage1.locator('.mx_BasicMessageComposer_input > div').click(); + await ewPage1.getByRole('textbox', { name: 'Send a message…' }).fill('Hello!'); + await ewPage1.getByRole("button", { name: "Send message" }).click(); + + await expect(ewPage1.getByText('This is the beginning of your')).toBeVisible(); + + + // Accept the DM invite from brooks + // This how playwright record selects the DM invite in the room list + await ewPage2.getByRole('option', { name: 'Open room' }).click(); + await ewPage2.getByRole('button', { name: 'Start chatting' }).click(); + } // Renamed use to pUse, as a workaround for eslint error that was thinking this use was a react use. await pUse({ @@ -209,11 +236,13 @@ export const widgetTest = test.extend({ mxId: brooksMxId, page: ewPage1, clientHandle: brooksClientHandle, + displayName: brooksDisplayName }, whistler: { mxId: whistlerMxId, page: ewPage2, clientHandle: whistlerClientHandle, + displayName: whistlerDisplayName }, }); }, diff --git a/playwright/widget/voice-call-dm.spec.ts b/playwright/widget/voice-call-dm.spec.ts new file mode 100644 index 00000000..39a1b8cb --- /dev/null +++ b/playwright/widget/voice-call-dm.spec.ts @@ -0,0 +1,183 @@ +/* +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 { expect, test } from "@playwright/test"; + +import { widgetTest } from "../fixtures/widget-user.ts"; + +widgetTest.use({callType: "dm"}); + +widgetTest("Start a new voice call in DM as widget", async ({ asWidget }) => { + test.slow(); // Triples the timeout + + const { brooks, whistler } = asWidget; + + await expect( + brooks.page.getByRole("button", { name: "Voice call" }), + ).toBeVisible(); + await brooks.page.getByRole("button", { name: "Voice call" }).click(); + + await expect( + brooks.page.getByRole("menuitem", { name: "Element Call" }), + ).toBeVisible(); + + await brooks.page.getByRole("menuitem", { name: "Element Call" }).click(); + + await expect( + brooks.page + .locator('iframe[title="Element Call"]') + ).toBeVisible(); + + const brooksFrame = brooks.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + + // We should show a ringing overlay, let's check for that + await expect(brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`)).toBeVisible(); + + + await expect(whistler.page.getByText('Incoming voice call')).toBeVisible(); + await whistler.page.getByRole('button', { name: 'Accept' }).click(); + + await expect( + whistler.page + .locator('iframe[title="Element Call"]') + ).toBeVisible(); + + const whistlerFrame = whistler.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + + // ASSERT the button states for whistler (the callee) + { + // The only way to know if it is muted or not is to look at the data-kind attribute.. + const videoButton = whistlerFrame.getByTestId('incall_videomute'); + // video should be off by default in a voice call + await expect(videoButton).toHaveAttribute("aria-label", /^Start video$/); + + + const audioButton = whistlerFrame.getByTestId('incall_mute'); + // audio should be on for the voice call + await expect(audioButton).toHaveAttribute("aria-label", /^Mute microphone$/); + } + + // ASSERT the button states for brools (the caller) + { + // The only way to know if it is muted or not is to look at the data-kind attribute.. + const videoButton = brooksFrame.getByTestId('incall_videomute'); + // video should be off by default in a voice call + await expect(videoButton).toHaveAttribute("aria-label", /^Start video$/); + + + const audioButton = brooksFrame.getByTestId('incall_mute'); + // audio should be on for the voice call + await expect(audioButton).toHaveAttribute("aria-label", /^Mute microphone$/); + } + + // In order to confirm that the call is disconnected we will check that the message composer is shown again. + // So first we need to confirm that it is hidden when in the call. + await expect(whistler.page.locator(".mx_BasicMessageComposer")).not.toBeVisible(); + await expect(brooks.page.locator(".mx_BasicMessageComposer")).not.toBeVisible(); + + // ASSERT hanging up on one side ends the call for both + { + const hangupButton = brooksFrame.getByTestId('incall_leave'); + await hangupButton.click(); + } + + // The widget should be closed on both sides and the timeline should be back on screen + await expect(whistler.page.locator(".mx_BasicMessageComposer")).toBeVisible(); + await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible(); + +}); + + + +widgetTest("Start a new video call in DM as widget", async ({ asWidget, browserName }) => { + test.slow(); // Triples the timeout + + const { brooks, whistler } = asWidget; + + await expect( + brooks.page.getByRole("button", { name: "Video call" }), + ).toBeVisible(); + await brooks.page.getByRole("button", { name: "Video call" }).click(); + + await expect( + brooks.page.getByRole("menuitem", { name: "Element Call" }), + ).toBeVisible(); + + await brooks.page.getByRole("menuitem", { name: "Element Call" }).click(); + + await expect( + brooks.page + .locator('iframe[title="Element Call"]') + ).toBeVisible(); + + const brooksFrame = brooks.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + + // We should show a ringing overlay, let's check for that + await expect(brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`)).toBeVisible(); + + + await expect(whistler.page.getByText('Incoming video call')).toBeVisible(); + await whistler.page.getByRole('button', { name: 'Accept' }).click(); + + await expect( + whistler.page + .locator('iframe[title="Element Call"]') + ).toBeVisible(); + + const whistlerFrame = whistler.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + + // ASSERT the button states for whistler (the callee) + { + // The only way to know if it is muted or not is to look at the data-kind attribute.. + const videoButton = whistlerFrame.getByTestId('incall_videomute'); + // video should be on by default in a voice call + await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/); + + + const audioButton = whistlerFrame.getByTestId('incall_mute'); + // audio should be on for the voice call + await expect(audioButton).toHaveAttribute("aria-label", /^Mute microphone$/); + } + + // ASSERT the button states for brools (the caller) + { + // The only way to know if it is muted or not is to look at the data-kind attribute.. + const videoButton = brooksFrame.getByTestId('incall_videomute'); + // video should be on by default in a voice call + await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/); + + + const audioButton = brooksFrame.getByTestId('incall_mute'); + // audio should be on for the voice call + await expect(audioButton).toHaveAttribute("aria-label", /^Mute microphone$/); + } + + // In order to confirm that the call is disconnected we will check that the message composer is shown again. + // So first we need to confirm that it is hidden when in the call. + await expect(whistler.page.locator(".mx_BasicMessageComposer")).not.toBeVisible(); + await expect(brooks.page.locator(".mx_BasicMessageComposer")).not.toBeVisible(); + + // ASSERT hanging up on one side ends the call for both + { + const hangupButton = brooksFrame.getByTestId('incall_leave'); + await hangupButton.click(); + } + + // The widget should be closed on both sides and the timeline should be back on screen + await expect(whistler.page.locator(".mx_BasicMessageComposer")).toBeVisible(); + await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible(); + +}); + diff --git a/sdk/main.ts b/sdk/main.ts index 376674a4..add71dbe 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -99,7 +99,7 @@ export async function createMatrixRTCSdk( if (room === null) throw Error("could not get room from client"); const mediaDevices = new MediaDevices(scope); - const muteStates = new MuteStates(scope, mediaDevices, constant(true)); + const muteStates = new MuteStates(scope, mediaDevices, { audioEnabled: true, videoEnabled: true }); const slot = { application, id }; const rtcSession = new MatrixRTCSession( client, diff --git a/src/UrlParams.test.ts b/src/UrlParams.test.ts index faba394f..bff772c2 100644 --- a/src/UrlParams.test.ts +++ b/src/UrlParams.test.ts @@ -256,8 +256,6 @@ describe("UrlParams", () => { skipLobby: false, returnToLobby: false, sendNotificationType: "notification", - defaultAudioEnabled: true, - defaultVideoEnabled: true, }); it("use no-intent-defaults with unknown intent", () => { expect(computeUrlParams()).toMatchObject(noIntentDefaults); @@ -395,8 +393,6 @@ describe("UrlParams", () => { expect.any(Object), "configuration:", expect.any(Object), - "intentAndPlatformDerivedConfiguration:", - {}, ); }); }); diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 9b262a43..edac5c07 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -247,11 +247,6 @@ export interface UrlConfiguration { callIntent?: RTCCallIntent; } -interface IntentAndPlatformDerivedConfiguration { - defaultAudioEnabled?: boolean; - defaultVideoEnabled?: boolean; -} - // If you need to add a new flag to this interface, prefer a name that describes // a specific behavior (such as 'confineToRoom'), rather than one that describes // the situations that call for this behavior ('isEmbedded'). This makes it @@ -260,8 +255,7 @@ interface IntentAndPlatformDerivedConfiguration { export interface UrlParams extends UrlProperties, - UrlConfiguration, - IntentAndPlatformDerivedConfiguration {} + UrlConfiguration {} // This is here as a stopgap, but what would be far nicer is a function that // takes a UrlParams and returns a query string. That would enable us to @@ -461,29 +455,6 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { }; } - const intentAndPlatformDerivedConfiguration: IntentAndPlatformDerivedConfiguration = - {}; - // Desktop also includes web. Its anything that is not mobile. - const desktopMobile = platform === "desktop" ? "desktop" : "mobile"; - switch (desktopMobile) { - case "desktop": - case "mobile": - switch (intent) { - case UserIntent.StartNewCall: - case UserIntent.JoinExistingCall: - case UserIntent.StartNewCallDM: - case UserIntent.JoinExistingCallDM: - intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true; - intentAndPlatformDerivedConfiguration.defaultVideoEnabled = true; - break; - case UserIntent.StartNewCallDMVoice: - case UserIntent.JoinExistingCallDMVoice: - intentAndPlatformDerivedConfiguration.defaultAudioEnabled = true; - intentAndPlatformDerivedConfiguration.defaultVideoEnabled = false; - break; - } - } - const properties: UrlProperties = { widgetId, parentUrl, @@ -548,15 +519,12 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { properties, "configuration:", configuration, - "intentAndPlatformDerivedConfiguration:", - intentAndPlatformDerivedConfiguration, ); return { ...properties, ...intentPreset, ...pickBy(configuration, (v?: unknown) => v !== undefined), - ...intentAndPlatformDerivedConfiguration, }; }; diff --git a/src/button/Button.tsx b/src/button/Button.tsx index c11c92dd..9cd579d1 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -35,6 +35,7 @@ export const MicButton: FC = ({ muted, ...props }) => { = ({ muted, ...props }) => { > = ({ { + const urlParams = useUrlParams(); const { confineToRoom, appPrompt, preload, header, displayName, skipLobby } = - useUrlParams(); + urlParams; const { t } = useTranslation(); const { roomAlias, roomId, viaServers } = useRoomIdentifier(); @@ -68,15 +68,12 @@ export const RoomPage: FC = () => { const devices = useMediaDevices(); const [muteStates, setMuteStates] = useState(null); - const joined$ = useObservable( - (inputs$) => inputs$.pipe(map(([joined]) => joined)), - [joined], - ); + useEffect(() => { const scope = new ObservableScope(); - setMuteStates(new MuteStates(scope, devices, joined$)); + setMuteStates(new MuteStates(scope, devices, calculateInitialMuteState(urlParams, import.meta.env.VITE_PACKAGE, window.location.hostname))); return (): void => scope.end(); - }, [devices, joined$]); + }, [devices, urlParams]); useEffect(() => { // If we've finished loading, are not already authed and we've been given a display name as diff --git a/src/state/MuteStates.test.ts b/src/state/MuteStates.test.ts index f2a6e35f..db3f503e 100644 --- a/src/state/MuteStates.test.ts +++ b/src/state/MuteStates.test.ts @@ -51,7 +51,6 @@ describe("MuteState", () => { const muteState = new MuteState( testScope, deviceStub, - constant(true), true, forceMute$, ); @@ -166,8 +165,10 @@ describe("MuteStates", () => { const muteStates = new MuteStates( testScope, mediaDevices, - // consider joined - constant(true), + { + audioEnabled: false, + videoEnabled: false, + } ); let latestSyncedState: boolean | null = null; diff --git a/src/state/MuteStates.ts b/src/state/MuteStates.ts index 7f048f27..7b29e31d 100644 --- a/src/state/MuteStates.ts +++ b/src/state/MuteStates.ts @@ -24,7 +24,6 @@ import { import { type MediaDevices, type MediaDevice } from "../state/MediaDevices"; import { ElementWidgetActions, widget } from "../widget"; -import { getUrlParams } from "../UrlParams"; import { type ObservableScope } from "./ObservableScope"; import { type Behavior, constant } from "./Behavior"; @@ -42,12 +41,6 @@ const defaultHandler: Handler = async (desired) => Promise.resolve(desired); * Do not use directly outside of tests. */ export class MuteState { - // TODO: rewrite this to explain behavior, it is not understandable, and cannot add logging - private readonly enabledByDefault$ = - this.enabledByConfig && !getUrlParams().skipLobby - ? this.joined$.pipe(map((isJoined) => !isJoined)) - : of(false); - private readonly handler$ = new BehaviorSubject(defaultHandler); public setHandler(handler: Handler): void { @@ -72,76 +65,73 @@ export class MuteState { private readonly data$ = this.scope.behavior( this.canControlDevices$.pipe( distinctUntilChanged(), - withLatestFrom( - this.enabledByDefault$, - (canControlDevices, enabledByDefault) => { + map((canControlDevices) => { + logger.info( + `MuteState: canControlDevices: ${canControlDevices}, enabled by default: ${this.enabledByDefault}`, + ); + if (!canControlDevices) { logger.info( - `MuteState: canControlDevices: ${canControlDevices}, enabled by default: ${enabledByDefault}`, + `MuteState: devices connected: ${canControlDevices}, disabling`, ); - if (!canControlDevices) { - logger.info( - `MuteState: devices connected: ${canControlDevices}, disabling`, - ); - // We need to sync the mute state with the handler - // to ensure nothing is beeing published. - this.handler$.value(false).catch((err) => { - logger.error("MuteState-disable: handler error", err); - }); - return { enabled$: of(false), set: null, toggle: null }; - } + // We need to sync the mute state with the handler + // to ensure nothing is beeing published. + this.handler$.value(false).catch((err) => { + logger.error("MuteState-disable: handler error", err); + }); + return { enabled$: of(false), set: null, toggle: null }; + } - // Assume the default value only once devices are actually connected - let enabled = enabledByDefault; - const set$ = new Subject(); - const toggle$ = new Subject(); - const desired$ = merge(set$, toggle$.pipe(map(() => !enabled))); - const enabled$ = new Observable((subscriber) => { - subscriber.next(enabled); - let latestDesired = enabledByDefault; - let syncing = false; + // Assume the default value only once devices are actually connected + let enabled = this.enabledByDefault; + const set$ = new Subject(); + const toggle$ = new Subject(); + const desired$ = merge(set$, toggle$.pipe(map(() => !enabled))); + const enabled$ = new Observable((subscriber) => { + subscriber.next(enabled); + let latestDesired = this.enabledByDefault; + let syncing = false; - const sync = async (): Promise => { - if (enabled === latestDesired) syncing = false; - else { - const previouslyEnabled = enabled; - enabled = await firstValueFrom( - this.handler$.pipe( - switchMap(async (handler) => handler(latestDesired)), - ), - ); - if (enabled === previouslyEnabled) { - syncing = false; - } else { - subscriber.next(enabled); - syncing = true; - sync().catch((err) => { - // TODO: better error handling - logger.error("MuteState: handler error", err); - }); - } - } - }; - - const s = desired$.subscribe((desired) => { - latestDesired = desired; - if (syncing === false) { + const sync = async (): Promise => { + if (enabled === latestDesired) syncing = false; + else { + const previouslyEnabled = enabled; + enabled = await firstValueFrom( + this.handler$.pipe( + switchMap(async (handler) => handler(latestDesired)), + ), + ); + if (enabled === previouslyEnabled) { + syncing = false; + } else { + subscriber.next(enabled); syncing = true; sync().catch((err) => { // TODO: better error handling logger.error("MuteState: handler error", err); }); } - }); - return (): void => s.unsubscribe(); - }); - - return { - set: (enabled: boolean): void => set$.next(enabled), - toggle: (): void => toggle$.next(), - enabled$, + } }; - }, - ), + + const s = desired$.subscribe((desired) => { + latestDesired = desired; + if (syncing === false) { + syncing = true; + sync().catch((err) => { + // TODO: better error handling + logger.error("MuteState: handler error", err); + }); + } + }); + return (): void => s.unsubscribe(); + }); + + return { + set: (enabled: boolean): void => set$.next(enabled), + toggle: (): void => toggle$.next(), + enabled$, + }; + }), ), ); @@ -159,8 +149,7 @@ export class MuteState { public constructor( private readonly scope: ObservableScope, private readonly device: MediaDevice, - private readonly joined$: Observable, - private readonly enabledByConfig: boolean, + private readonly enabledByDefault: boolean, /** * An optional observable which, when it emits `true`, will force the mute. * Used for video to stop camera when earpiece mode is on. @@ -175,10 +164,10 @@ export class MuteStates { * True if the selected audio output device is an earpiece. * Used to force-disable video when on earpiece. */ - private readonly isEarpiece$ = combineLatest( + private readonly isEarpiece$ = combineLatest([ this.mediaDevices.audioOutput.available$, this.mediaDevices.audioOutput.selected$, - ).pipe( + ]).pipe( map(([available, selected]) => { if (!selected?.id) return false; const device = available.get(selected.id); @@ -190,22 +179,23 @@ export class MuteStates { public readonly audio = new MuteState( this.scope, this.mediaDevices.audioInput, - this.joined$, - true, + this.initialMuteState.audioEnabled, constant(false), ); public readonly video = new MuteState( this.scope, this.mediaDevices.videoInput, - this.joined$, - true, + this.initialMuteState.videoEnabled, this.isEarpiece$, ); public constructor( private readonly scope: ObservableScope, private readonly mediaDevices: MediaDevices, - private readonly joined$: Observable, + private readonly initialMuteState: { + audioEnabled: boolean; + videoEnabled: boolean; + }, ) { if (widget !== null) { // Sync our mute states with the hosting client diff --git a/src/state/initialMuteState.test.ts b/src/state/initialMuteState.test.ts new file mode 100644 index 00000000..08be50ef --- /dev/null +++ b/src/state/initialMuteState.test.ts @@ -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 { test, expect } from "vitest"; +import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc"; + +import { calculateInitialMuteState } from "./initialMuteState"; + + +test.each<{ + callIntent: RTCCallIntent; + packageType: "full" | "embedded"; +}>([ + { callIntent: "audio", packageType: "full" }, + { callIntent: "audio", packageType: "embedded" }, + { callIntent: "video", packageType: "full" }, + { callIntent: "video", packageType: "embedded" }, + { callIntent: "unknown", packageType: "full" }, + { callIntent: "unknown", packageType: "embedded" }, +])( + "Should allow to unmute on start if not skipping lobby (callIntent: $callIntent, packageType: $packageType)", + ({ callIntent, packageType }) => { + const { audioEnabled, videoEnabled } = calculateInitialMuteState( + { skipLobby: false, callIntent }, + packageType, + ); + expect(audioEnabled).toBe(true); + expect(videoEnabled).toBe(callIntent !== "audio"); + }, +); + +test.each<{ + callIntent: RTCCallIntent; +}>([ + { callIntent: "audio" }, + { callIntent: "video" }, + { callIntent: "unknown" }, +])( + "Should always mute on start if skipping lobby on non embedded build (callIntent: $callIntent)", + ({ callIntent }) => { + const { audioEnabled, videoEnabled } = calculateInitialMuteState( + { skipLobby: true, callIntent }, + "full", + ); + expect(audioEnabled).toBe(false); + expect(videoEnabled).toBe(false); + }, +); + +test.each<{ + callIntent: RTCCallIntent; +}>([ + { callIntent: "audio" }, + { callIntent: "video" }, + { callIntent: "unknown" }, +])( + "Can start unmuted if skipping lobby on embedded build (callIntent: $callIntent)", + ({ callIntent }) => { + const { audioEnabled, videoEnabled } = calculateInitialMuteState( + { skipLobby: true, callIntent }, + "embedded", + ); + expect(audioEnabled).toBe(true); + expect(videoEnabled).toBe(callIntent !== "audio"); + }, +); + + +test.each<{ + isDevBuild: boolean; + currentHost: string; + expectedEnabled: boolean; +}>([ + { isDevBuild: true, currentHost: "localhost", expectedEnabled: true }, + { isDevBuild: false, currentHost: "localhost", expectedEnabled: false }, + { isDevBuild: true, currentHost: "call.example.com", expectedEnabled: false }, + { isDevBuild: false, currentHost: "call.example.com", expectedEnabled: false }, +]) +("Should trust localhost domain when in dev mode isDevBuild($isDevBuild) host($currentHost)", ( + {isDevBuild, currentHost, expectedEnabled} +) => { + const { audioEnabled, videoEnabled } = calculateInitialMuteState( + { skipLobby: true, callIntent: "video" }, + "full", + currentHost, + isDevBuild, + ); + + expect(audioEnabled).toBe(expectedEnabled); + expect(videoEnabled).toBe(expectedEnabled); +}); diff --git a/src/state/initialMuteState.ts b/src/state/initialMuteState.ts new file mode 100644 index 00000000..41b36559 --- /dev/null +++ b/src/state/initialMuteState.ts @@ -0,0 +1,46 @@ +/* +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 { type UrlParams } from "../UrlParams.ts"; + +/** + * Calculates the initial mute state for media devices based on configuration. + * + * It is not always possible to start the widget with audio/video unmuted due to privacy concerns. + * This function encapsulates the logic to determine the appropriate initial state. + */ +export function calculateInitialMuteState( + urlParams: Pick, + packageType: "full" | "embedded", + hostname: string | undefined = undefined, + isDevBuild: boolean = import.meta.env.DEV, +): { audioEnabled: boolean; videoEnabled: boolean } { + const { skipLobby, callIntent } = urlParams; + + const isTrustedHost = + packageType == "embedded" || + // Trust local hosts in dev mode to make local testing easier + (hostname == "localhost" && isDevBuild); + + if (skipLobby && !isTrustedHost) { + // If host not trusted and lobby skipped, default to muted to protect user privacy. + // This prevents users from inadvertently joining with active audio/video + // when browser permissions were previously granted in a different context. + return { + audioEnabled: false, + videoEnabled: false, + }; + } + + // Embedded contexts are trusted environments, so they allow unmuted by default. + // Same for when showing a lobby, as users can adjust their settings there. + // Additionally, if the call intent is "audio", we disable video by default. + return { + audioEnabled: true, + videoEnabled: callIntent != "audio", + }; +} diff --git a/src/utils/test.ts b/src/utils/test.ts index 9a845908..95d6ed0c 100644 --- a/src/utils/test.ts +++ b/src/utils/test.ts @@ -525,5 +525,5 @@ export function mockMuteStates( joined$: Observable = of(true), ): MuteStates { const observableScope = new ObservableScope(); - return new MuteStates(observableScope, mockMediaDevices({}), joined$); + return new MuteStates(observableScope, mockMediaDevices({}), { audioEnabled: false, videoEnabled: false }); }