diff --git a/playwright.config.ts b/playwright.config.ts index 391e746f..4fb86b95 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -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. diff --git a/playwright/fixtures/widget-user.ts b/playwright/fixtures/widget-user.ts index f1f738b7..c2f8ca23 100644 --- a/playwright/fixtures/widget-user.ts +++ b/playwright/fixtures/widget-user.ts @@ -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; }; @@ -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({ - 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({ .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({ 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..a7aed984 --- /dev/null +++ b/playwright/widget/voice-call-dm.spec.ts @@ -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(); + }, +); diff --git a/sdk/main.ts b/sdk/main.ts index 8d07ab07..c371587f 100644 --- a/sdk/main.ts +++ b/sdk/main.ts @@ -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$( diff --git a/src/UrlParams.test.ts b/src/UrlParams.test.ts index faba394f..ec92ee89 100644 --- a/src/UrlParams.test.ts +++ b/src/UrlParams.test.ts @@ -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:", - {}, ); }); }); diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 9b262a43..f8ee22fb 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -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, }; }; diff --git a/src/button/Button.tsx b/src/button/Button.tsx index c11c92dd..3136e2da 100644 --- a/src/button/Button.tsx +++ b/src/button/Button.tsx @@ -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 = ({ 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 +69,22 @@ 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.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 diff --git a/src/room/__snapshots__/InCallView.test.tsx.snap b/src/room/__snapshots__/InCallView.test.tsx.snap index ec9ffb97..e4707c5c 100644 --- a/src/room/__snapshots__/InCallView.test.tsx.snap +++ b/src/room/__snapshots__/InCallView.test.tsx.snap @@ -286,6 +286,7 @@ exports[`InCallView > rendering > renders 1`] = ` >