fix: Regression on default mutestate for voicecall + end-2-end tests
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<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 {
|
||||
@@ -72,76 +65,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$,
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -159,8 +149,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.
|
||||
@@ -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<boolean>,
|
||||
private readonly initialMuteState: {
|
||||
audioEnabled: boolean;
|
||||
videoEnabled: boolean;
|
||||
},
|
||||
) {
|
||||
if (widget !== null) {
|
||||
// Sync our mute states with the hosting client
|
||||
|
||||
95
src/state/initialMuteState.test.ts
Normal file
95
src/state/initialMuteState.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
46
src/state/initialMuteState.ts
Normal file
46
src/state/initialMuteState.ts
Normal file
@@ -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<UrlParams, "skipLobby" | "callIntent">,
|
||||
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",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user