mirror of
https://github.com/vector-im/element-call.git
synced 2026-01-18 02:32:27 +00:00
fix: Regression on default mutestate for voicecall + end-2-end tests
This commit is contained in:
@@ -17,6 +17,7 @@ import type { MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
export type UserBaseFixture = {
|
||||
mxId: string;
|
||||
displayName: string;
|
||||
page: Page;
|
||||
clientHandle: JSHandle<MatrixClient>;
|
||||
};
|
||||
@@ -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<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 +175,60 @@ 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("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<MyFixtures>({
|
||||
mxId: brooksMxId,
|
||||
page: ewPage1,
|
||||
clientHandle: brooksClientHandle,
|
||||
displayName: brooksDisplayName
|
||||
},
|
||||
whistler: {
|
||||
mxId: whistlerMxId,
|
||||
page: ewPage2,
|
||||
clientHandle: whistlerClientHandle,
|
||||
displayName: whistlerDisplayName
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
183
playwright/widget/voice-call-dm.spec.ts
Normal file
183
playwright/widget/voice-call-dm.spec.ts
Normal file
@@ -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();
|
||||
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:",
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -35,6 +35,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 +59,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 +104,7 @@ export const EndCallButton: FC<ComponentPropsWithoutRef<"button">> = ({
|
||||
<CpdButton
|
||||
className={classNames(className, styles.endCall)}
|
||||
iconOnly
|
||||
aria-label={t("hangup_button_label")}
|
||||
Icon={EndCallIcon}
|
||||
destructive
|
||||
{...props}
|
||||
|
||||
@@ -20,8 +20,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 +42,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 +68,12 @@ 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, 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
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -525,5 +525,5 @@ 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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user