Merge pull request #3659 from element-hq/valere/initial_mute_states

Fix: Ensure voice and audio intent are respected
This commit is contained in:
Valere Fedronic
2026-01-13 11:50:32 +01:00
committed by GitHub
16 changed files with 506 additions and 183 deletions

View File

@@ -1,5 +1,6 @@
/*
Copyright 2025 New Vector Ltd.
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.

View File

@@ -1,5 +1,6 @@
/*
Copyright 2025 New Vector Ltd.
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.
@@ -17,6 +18,7 @@ import type { MatrixClient } from "matrix-js-sdk";
export type UserBaseFixture = {
mxId: string;
displayName: string;
page: Page;
clientHandle: JSHandle<MatrixClient>;
};
@@ -28,6 +30,7 @@ export type BaseWidgetSetup = {
export interface MyFixtures {
asWidget: BaseWidgetSetup;
callType: "room" | "dm";
}
const PASSWORD = "foobarbaz1!";
@@ -145,25 +148,27 @@ async function registerUser(
}
export const widgetTest = test.extend<MyFixtures>({
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 +176,66 @@ export const widgetTest = test.extend<MyFixtures>({
.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("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();
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("button", { name: "Invite to this room", exact: true })
.click();
await expect(
ewPage1.getByRole("heading", { name: "Invite to 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();
// 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();
// 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 +243,13 @@ export const widgetTest = test.extend<MyFixtures>({
mxId: brooksMxId,
page: ewPage1,
clientHandle: brooksClientHandle,
displayName: brooksDisplayName,
},
whistler: {
mxId: whistlerMxId,
page: ewPage2,
clientHandle: whistlerClientHandle,
displayName: whistlerDisplayName,
},
});
},

View File

@@ -0,0 +1,212 @@
/*
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, browserName }) => {
test.skip(
browserName === "firefox",
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
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.skip(
browserName === "firefox",
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
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();
},
);

View File

@@ -1,5 +1,5 @@
/*
Copyright 2025 Element Creations Ltd.
Copyright 2025-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.
@@ -99,7 +99,10 @@ 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, room, slot);
const callViewModel = createCallViewModel$(

View File

@@ -1,5 +1,6 @@
/*
Copyright 2023, 2024 New Vector Ltd.
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.
@@ -256,8 +257,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 +394,6 @@ describe("UrlParams", () => {
expect.any(Object),
"configuration:",
expect.any(Object),
"intentAndPlatformDerivedConfiguration:",
{},
);
});
});

View File

@@ -1,5 +1,6 @@
/*
Copyright 2022-2024 New Vector Ltd.
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.
@@ -247,21 +248,12 @@ 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
// clearer what each flag means, and helps us avoid coupling Element Call's
// behavior to the needs of specific consumers.
export interface UrlParams
extends
UrlProperties,
UrlConfiguration,
IntentAndPlatformDerivedConfiguration {}
export interface UrlParams extends UrlProperties, 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 +453,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 +517,12 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
properties,
"configuration:",
configuration,
"intentAndPlatformDerivedConfiguration:",
intentAndPlatformDerivedConfiguration,
);
return {
...properties,
...intentPreset,
...pickBy(configuration, (v?: unknown) => v !== undefined),
...intentAndPlatformDerivedConfiguration,
};
};

View File

@@ -1,5 +1,6 @@
/*
Copyright 2022-2024 New Vector Ltd.
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.
@@ -35,6 +36,7 @@ export const MicButton: FC<MicButtonProps> = ({ muted, ...props }) => {
<Tooltip label={label}>
<CpdButton
iconOnly
aria-label={label}
Icon={Icon}
kind={muted ? "primary" : "secondary"}
{...props}
@@ -58,6 +60,7 @@ export const VideoButton: FC<VideoButtonProps> = ({ muted, ...props }) => {
<Tooltip label={label}>
<CpdButton
iconOnly
aria-label={label}
Icon={Icon}
kind={muted ? "primary" : "secondary"}
{...props}
@@ -102,6 +105,7 @@ export const EndCallButton: FC<ComponentPropsWithoutRef<"button">> = ({
<CpdButton
className={classNames(className, styles.endCall)}
iconOnly
aria-label={t("hangup_button_label")}
Icon={EndCallIcon}
destructive
{...props}

View File

@@ -1,5 +1,6 @@
/*
Copyright 2022-2024 New Vector Ltd.
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.
@@ -163,10 +164,6 @@ export interface ResolvedConfigOptions extends ConfigOptions {
};
};
ssla: string;
media_devices: {
enable_audio: boolean;
enable_video: boolean;
};
app_prompt: boolean;
}
@@ -181,9 +178,5 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
feature_use_device_session_member_events: true,
},
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
media_devices: {
enable_audio: true,
enable_video: true,
},
app_prompt: true,
};

View File

@@ -1,5 +1,6 @@
/*
Copyright 2025 New Vector Ltd.
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.

View File

@@ -1,5 +1,6 @@
/*
Copyright 2021-2024 New Vector Ltd.
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.
@@ -20,8 +21,6 @@ import {
CheckIcon,
UnknownSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useObservable } from "observable-hooks";
import { map } from "rxjs";
import { useClientLegacy } from "../ClientContext";
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
@@ -44,10 +43,12 @@ import { ErrorView } from "../ErrorView";
import { useMediaDevices } from "../MediaDevicesContext";
import { MuteStates } from "../state/MuteStates";
import { ObservableScope } from "../state/ObservableScope";
import { calculateInitialMuteState } from "../state/initialMuteState.ts";
export const RoomPage: FC = () => {
const urlParams = useUrlParams();
const { confineToRoom, appPrompt, preload, header, displayName, skipLobby } =
useUrlParams();
urlParams;
const { t } = useTranslation();
const { roomAlias, roomId, viaServers } = useRoomIdentifier();
@@ -68,15 +69,22 @@ export const RoomPage: FC = () => {
const devices = useMediaDevices();
const [muteStates, setMuteStates] = useState<MuteStates | null>(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.skipLobby,
urlParams.callIntent,
widget !== null,
),
),
);
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

View File

@@ -286,6 +286,7 @@ exports[`InCallView > rendering > renders 1`] = `
>
<button
aria-disabled="true"
aria-label="Unmute microphone"
aria-labelledby="_r_8_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="primary"
@@ -309,6 +310,7 @@ exports[`InCallView > rendering > renders 1`] = `
</button>
<button
aria-disabled="true"
aria-label="Start video"
aria-labelledby="_r_d_"
class="_button_13vu4_8 _has-icon_13vu4_60 _icon-only_13vu4_53"
data-kind="primary"
@@ -352,6 +354,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-label="End call"
aria-labelledby="_r_n_"
class="_button_13vu4_8 endCall _has-icon_13vu4_60 _icon-only_13vu4_53 _destructive_13vu4_110"
data-kind="primary"

View File

@@ -1,5 +1,5 @@
/*
Copyright 2025 Element Creations Ltd.
Copyright 2025-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.
@@ -48,13 +48,7 @@ describe("MuteState", () => {
select(): void {},
} as unknown as MediaDevice<DeviceLabel, SelectedDevice>;
const muteState = new MuteState(
testScope,
deviceStub,
constant(true),
true,
forceMute$,
);
const muteState = new MuteState(testScope, deviceStub, true, forceMute$);
let lastEnabled: boolean = false;
muteState.enabled$.subscribe((enabled) => {
lastEnabled = enabled;
@@ -163,12 +157,10 @@ describe("MuteStates", () => {
videoInput: aVideoInput(),
// other devices are not relevant for this test
});
const muteStates = new MuteStates(
testScope,
mediaDevices,
// consider joined
constant(true),
);
const muteStates = new MuteStates(testScope, mediaDevices, {
audioEnabled: false,
videoEnabled: false,
});
let latestSyncedState: boolean | null = null;
muteStates.video.setHandler(async (enabled: boolean): Promise<boolean> => {

View File

@@ -1,5 +1,6 @@
/*
Copyright 2023-2025 New Vector Ltd.
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.
@@ -24,8 +25,6 @@ import {
import { type MediaDevices, type MediaDevice } from "../state/MediaDevices";
import { ElementWidgetActions, widget } from "../widget";
import { Config } from "../config/Config";
import { getUrlParams } from "../UrlParams";
import { type ObservableScope } from "./ObservableScope";
import { type Behavior, constant } from "./Behavior";
@@ -43,12 +42,6 @@ const defaultHandler: Handler = async (desired) => Promise.resolve(desired);
* Do not use directly outside of tests.
*/
export class MuteState<Label, Selected> {
// 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 {
@@ -73,76 +66,73 @@ export class MuteState<Label, Selected> {
private readonly data$ = this.scope.behavior<MuteStateData>(
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<boolean>();
const toggle$ = new Subject<void>();
const desired$ = merge(set$, toggle$.pipe(map(() => !enabled)));
const enabled$ = new Observable<boolean>((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<boolean>();
const toggle$ = new Subject<void>();
const desired$ = merge(set$, toggle$.pipe(map(() => !enabled)));
const enabled$ = new Observable<boolean>((subscriber) => {
subscriber.next(enabled);
let latestDesired = this.enabledByDefault;
let syncing = false;
const sync = async (): Promise<void> => {
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<void> => {
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$,
};
}),
),
);
@@ -160,8 +150,7 @@ export class MuteState<Label, Selected> {
public constructor(
private readonly scope: ObservableScope,
private readonly device: MediaDevice<Label, Selected>,
private readonly joined$: Observable<boolean>,
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.
@@ -176,10 +165,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);
@@ -191,22 +180,23 @@ export class MuteStates {
public readonly audio = new MuteState(
this.scope,
this.mediaDevices.audioInput,
this.joined$,
Config.get().media_devices.enable_audio,
this.initialMuteState.audioEnabled,
constant(false),
);
public readonly video = new MuteState(
this.scope,
this.mediaDevices.videoInput,
this.joined$,
Config.get().media_devices.enable_video,
this.initialMuteState.videoEnabled,
this.isEarpiece$,
);
public constructor(
private readonly scope: ObservableScope,
private readonly mediaDevices: MediaDevices,
private readonly joined$: Observable<boolean>,
private readonly initialMuteState: {
audioEnabled: boolean;
videoEnabled: boolean;
},
) {
if (widget !== null) {
// Sync our mute states with the hosting client

View File

@@ -0,0 +1,72 @@
/*
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;
isWidgetMode: boolean;
}>([
{ callIntent: "audio", isWidgetMode: false },
{ callIntent: "audio", isWidgetMode: true },
{ callIntent: "video", isWidgetMode: false },
{ callIntent: "video", isWidgetMode: true },
{ callIntent: "unknown", isWidgetMode: false },
{ callIntent: "unknown", isWidgetMode: true },
])(
"Should allow to unmute on start if not skipping lobby (callIntent: $callIntent, packageType: $packageType)",
({ callIntent, isWidgetMode }) => {
const { audioEnabled, videoEnabled } = calculateInitialMuteState(
false,
callIntent,
isWidgetMode,
);
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 widget mode (callIntent: $callIntent)",
({ callIntent }) => {
const { audioEnabled, videoEnabled } = calculateInitialMuteState(
true,
callIntent,
false,
);
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 widget mode (callIntent: $callIntent)",
({ callIntent }) => {
const { audioEnabled, videoEnabled } = calculateInitialMuteState(
true,
callIntent,
true,
);
expect(audioEnabled).toBe(true);
expect(videoEnabled).toBe(callIntent !== "audio");
},
);

View File

@@ -0,0 +1,42 @@
/*
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 { logger } from "matrix-js-sdk/lib/logger";
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
/**
* 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(
skipLobby: boolean,
callIntent: RTCCallIntent | undefined,
isWidgetMode: boolean,
): { audioEnabled: boolean; videoEnabled: boolean } {
logger.debug(
`calculateInitialMuteState: skipLobby=${skipLobby}, callIntent=${callIntent} isWidgetMode=${isWidgetMode}`,
);
if (skipLobby && !isWidgetMode) {
// If not in widget mode and lobby is skipped, default to muted to protect user privacy.
// In the SPA context we don't want to unmute users without giving them a chance to adjust their settings first.
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",
};
}

View File

@@ -530,5 +530,8 @@ export function mockMuteStates(
joined$: Observable<boolean> = of(true),
): MuteStates {
const observableScope = new ObservableScope();
return new MuteStates(observableScope, mockMediaDevices({}), joined$);
return new MuteStates(observableScope, mockMediaDevices({}), {
audioEnabled: false,
videoEnabled: false,
});
}