diff --git a/locales/en/app.json b/locales/en/app.json index ec6c92de2..56ee4c302 100644 --- a/locales/en/app.json +++ b/locales/en/app.json @@ -78,8 +78,10 @@ "device_id": "Device ID: {{id}}", "duplicate_tiles_label": "Number of additional tile copies per participant", "environment_variables": "Environment variables", + "full_mesh_capabilities": "Take part in full mesh (MSC4575)", + "full_mesh_capabilities_description": "Receive members publishing on full mesh. As a widget, this asks the host for the signalling permissions.", "full_mesh_peer_connections": "Full mesh peer connections", - "full_mesh_publishing": "Publish on full mesh (MSC4575) instead of LiveKit", + "full_mesh_publishing": "Publish on full mesh instead of LiveKit", "full_mesh_transport": "Full mesh: {{device}}", "hostname": "Hostname: {{hostname}}", "livekit_server_info": "LiveKit Server Info", diff --git a/playwright/widget/full-mesh.test.ts b/playwright/widget/full-mesh.test.ts index a361533b8..fb74f824c 100644 --- a/playwright/widget/full-mesh.test.ts +++ b/playwright/widget/full-mesh.test.ts @@ -55,14 +55,28 @@ async function recordWidgetToDevice( ); } -const cases: { name: string; fullMesh: [boolean, boolean] }[] = [ +const cases: { + name: string; + /** Who publishes on full mesh; both have the capabilities unless said. */ + fullMesh: [boolean, boolean]; + capabilities?: [boolean, boolean]; +}[] = [ // Both publish on full mesh: the media flows without any SFU { name: "both on full mesh", fullMesh: [true, true] }, - // One on each: Florian receives Valere over LiveKit without publishing there + // One on each: Florian receives Valere over LiveKit without publishing + // there, and Valere receives Florian over full mesh without publishing there { name: "full mesh and LiveKit mixed", fullMesh: [true, false] }, + // Without the capabilities a widget can't be subscribed to full mesh + { + name: "LiveKit side without the full mesh capabilities", + fullMesh: [true, false], + capabilities: [true, false], + }, ]; -cases.forEach(({ name, fullMesh: [florianMesh, valereMesh] }) => { +cases.forEach(({ name, fullMesh, capabilities = [true, true] }) => { + const [florianMesh, valereMesh] = fullMesh; + const [florianCaps, valereCaps] = capabilities; widgetTest(`Full mesh call: ${name}`, async ({ addUser, browserName }) => { test.skip( browserName === "firefox", @@ -71,7 +85,7 @@ cases.forEach(({ name, fullMesh: [florianMesh, valereMesh] }) => { test.slow(); // Distinct per case: the cases run in parallel and register at once - const suffix = `${Number(florianMesh)}${Number(valereMesh)}`; + const suffix = `${Number(florianMesh)}${Number(valereMesh)}${Number(florianCaps)}${Number(valereCaps)}`; const [florian, valere] = await Promise.all([ addUser(`florian${suffix}`, HOST1), addUser(`valere${suffix}`, HOST1), @@ -95,24 +109,22 @@ cases.forEach(({ name, fullMesh: [florianMesh, valereMesh] }) => { await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget( florian.page, "2_0", - { fullMesh: florianMesh }, + { capabilities: florianCaps, publish: florianMesh }, ); await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget( valere.page, "2_0", - { fullMesh: valereMesh }, + { capabilities: valereCaps, publish: valereMesh }, ); await TestHelpers.startCallInCurrentRoom(florian.page, false); await TestHelpers.joinCallFromLobby(florian.page); await TestHelpers.joinCallInCurrentRoom(valere.page); - // A widget only asks for the full mesh signalling with full mesh turned - // on, so a LiveKit publisher in Element Web can't receive full mesh yet: - // the other's media arrives if it is on LiveKit or we have full mesh + // The other's media arrives if it is on LiveKit, or we can signal full mesh const receives = { - florian: !valereMesh || florianMesh, - valere: !florianMesh || valereMesh, + florian: !valereMesh || florianCaps, + valere: !florianMesh || valereCaps, }; // In parallel: assertions on a page also run its permission handler await Promise.all( diff --git a/playwright/widget/test-helpers.ts b/playwright/widget/test-helpers.ts index d62fb3791..bf8d3a9b0 100644 --- a/playwright/widget/test-helpers.ts +++ b/playwright/widget/test-helpers.ts @@ -24,6 +24,14 @@ export const HOST2 = "https://app.othersite.m.localhost/#/welcome"; export type RtcMode = "compat" | "2_0"; +/** Full mesh (MSC4575) in the widget's developer settings. */ +export interface FullMeshOptions { + /** Ask the host for the signalling capabilities, to receive full mesh. */ + capabilities?: boolean; + /** Publish on full mesh instead of LiveKit; needs the capabilities. */ + publish?: boolean; +} + export class TestHelpers { public static async startCallInCurrentRoom( page: Page, @@ -306,7 +314,7 @@ export class TestHelpers { public static async openWidgetSetEmbeddedElementCallRtcModeCloseWidget( page: Page, mode: RtcMode, - { fullMesh = false }: { fullMesh?: boolean } = {}, + fullMesh: FullMeshOptions = {}, ): Promise { await page.getByRole("button", { name: "Video call" }).click({ timeout: 5000, @@ -315,7 +323,7 @@ export class TestHelpers { timeout: 10000, }); - await TestHelpers.setEmbeddedElementCallRtcMode(page, mode, { fullMesh }); + await TestHelpers.setEmbeddedElementCallRtcMode(page, mode, fullMesh); await page.getByRole("button", { name: "Close lobby" }).click(); } @@ -328,7 +336,7 @@ export class TestHelpers { public static async setEmbeddedElementCallRtcMode( page: Page, mode: RtcMode, - { fullMesh = false }: { fullMesh?: boolean } = {}, + { capabilities = false, publish = false }: FullMeshOptions = {}, ): Promise { const iframe = page.locator('iframe[title="Element Call"]').contentFrame(); @@ -346,10 +354,14 @@ export class TestHelpers { // compat await iframe.getByText("Compatibility: state events").click(); } - // Publish on the full mesh transport (MSC4575) instead of LiveKit + // Full mesh (MSC4575): the capabilities first, as publishing needs them await iframe - .getByText("Publish on full mesh", { exact: false }) - .setChecked(fullMesh); + .getByText("Take part in full mesh", { exact: false }) + .setChecked(capabilities); + if (capabilities) + await iframe + .getByText("Publish on full mesh", { exact: false }) + .setChecked(publish); await iframe.getByTestId("modal_close").click(); } diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 84a7ec5c8..73e589e63 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -86,6 +86,7 @@ import { cameraFramerate, cameraResolution, customTransport, + fullMeshCapabilities, fullMeshPublishing, echoCancellationSetting, matrixRTCMode as matrixRTCModeSetting, @@ -839,9 +840,10 @@ export function rtcParticipationOptions( delegatedDelayedLeave: session.delegated_delayed_leave, }, mediaQuality: config.media_quality, - transport: fullMeshPublishing.value$.value - ? { type: FULL_MESH_TRANSPORT_TYPE } - : (customTransport.value$.value ?? undefined), + transport: + fullMeshCapabilities.value$.value && fullMeshPublishing.value$.value + ? { type: FULL_MESH_TRANSPORT_TYPE } + : (customTransport.value$.value ?? undefined), fallbackTransport: config.livekit && { type: "livekit", livekit_service_url: config.livekit.livekit_service_url, diff --git a/src/settings/DeveloperSettingsTab.module.css b/src/settings/DeveloperSettingsTab.module.css index 369bec3b7..04c538133 100644 --- a/src/settings/DeveloperSettingsTab.module.css +++ b/src/settings/DeveloperSettingsTab.module.css @@ -9,6 +9,11 @@ Please see LICENSE in the repository root for full details. font-size: var(--font-size-micro); } +/* An option that depends on the one above it */ +.subOption { + padding-inline-start: var(--cpd-space-8x); +} + .livekit_room_box { border: 3px solid var(--cpd-color-bg-subtle-secondary); border-radius: var(--cpd-space-8x); diff --git a/src/settings/DeveloperSettingsTab.tsx b/src/settings/DeveloperSettingsTab.tsx index 5c2e3cf8d..130c61342 100644 --- a/src/settings/DeveloperSettingsTab.tsx +++ b/src/settings/DeveloperSettingsTab.tsx @@ -45,6 +45,7 @@ import { alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting, matrixRTCMode as matrixRTCModeSetting, customTransport as customTransportSetting, + fullMeshCapabilities as fullMeshCapabilitiesSetting, fullMeshPublishing as fullMeshPublishingSetting, advancedScreenShare as advancedScreenShareSetting, screenShareResolution as screenShareResolutionSetting, @@ -177,6 +178,9 @@ export const DeveloperSettingsTab: FC = ({ const [customTransportUpdateError, setCustomTransportUpdateError] = useState< string | null >(null); + const [fullMeshCapabilities, setFullMeshCapabilities] = useSetting( + fullMeshCapabilitiesSetting, + ); const [fullMeshPublishing, setFullMeshPublishing] = useSetting( fullMeshPublishingSetting, ); @@ -423,10 +427,25 @@ export const DeveloperSettingsTab: FC = ({ /> + ): void => { + setFullMeshCapabilities(event.target.checked); + // Publishing needs the capabilities + if (!event.target.checked) setFullMeshPublishing(false); + }} + /> + + ): void => setFullMeshPublishing(event.target.checked) diff --git a/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap b/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap index e34df26e2..b6e4ad781 100644 --- a/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap +++ b/src/settings/__snapshots__/DeveloperSettingsTab.test.tsx.snap @@ -51,6 +51,50 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` > + +

+ Receive members publishing on full mesh. As a widget, this asks the host for the signalling permissions. +

+ + +
+
+ @@ -76,7 +120,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` />
- Publish on full mesh (MSC4575) instead of LiveKit + Publish on full mesh instead of LiveKit
@@ -87,7 +131,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_field_1bd8c0 _checkboxField_1bd8c0" > @@ -124,7 +168,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_field_1bd8c0 _checkboxField_1bd8c0" > @@ -161,7 +205,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_field_1bd8c0 _checkboxField_1bd8c0" > @@ -199,7 +243,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_field_1bd8c0 _checkboxField_1bd8c0" > @@ -236,7 +280,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_field_1bd8c0 _checkboxField_1bd8c0" > @@ -274,7 +318,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` > @@ -282,9 +326,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_controls_17lij_8" > renders and matches snapshot 1`] = ` Currently, no overwrite is set. Url from config is used. @@ -322,10 +366,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_container_1ug7n_10" > renders and matches snapshot 1`] = ` > Compatible with homeservers that do not support sticky events (but all other EC clients are v0.17.0 or later) @@ -363,9 +407,9 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_container_1ug7n_10" > renders and matches snapshot 1`] = ` > Compatible only with homservers supporting sticky events and all EC clients v0.17.0 or later @@ -452,7 +496,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_field_1bd8c0 _checkboxField_1bd8c0" > @@ -482,7 +526,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `

Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.

@@ -504,7 +548,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_field_1bd8c0 _checkboxField_1bd8c0" > @@ -534,7 +578,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `

Configure resolution, framerate, bitrate, and codec for screen sharing

@@ -559,7 +603,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = ` class="_field_1bd8c0 _checkboxField_1bd8c0" > renders and matches snapshot 1`] = ` class="_field_1bd8c0 _checkboxField_1bd8c0" > renders and matches snapshot 1`] = ` class="_field_1bd8c0 _checkboxField_1bd8c0" > ( ); /** - * Publish on the full mesh transport (MSC4575) instead of LiveKit. Members on - * either transport are received whatever this says. + * Take part in the full mesh transport (MSC4575): as a widget, ask the host + * for the signalling capabilities, so that members publishing on full mesh + * are received. Element Web doesn't auto-approve them for Element Call yet + * and asks the user whenever the widget opens, hence a setting. + */ +export const fullMeshCapabilities = new Setting( + "full-mesh-capabilities", + false, +); + +/** + * Publish on the full mesh transport instead of LiveKit. Only with + * `fullMeshCapabilities`: a widget without them couldn't be subscribed to. */ export const fullMeshPublishing = new Setting( "full-mesh-publishing", diff --git a/src/widget.ts b/src/widget.ts index 764e13d9a..f94e49cbc 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -22,7 +22,7 @@ import { LazyEventEmitter } from "./LazyEventEmitter"; import { getUrlParams } from "./UrlParams"; import { Config } from "./config/Config"; import { - fullMeshPublishing, + fullMeshCapabilities, seedSettingsFromConfig, } from "./settings/settings"; import { ElementCallReactionEventType } from "./reactions"; @@ -169,7 +169,9 @@ export const initializeWidget = ( // Full mesh signalling (MSC4575), only where it is turned on: Element // Web doesn't auto-approve these for Element Call yet, and would ask // the user on every call - ...(fullMeshPublishing.getValue() ? fullMeshSignallingEventTypes : []), + ...(fullMeshCapabilities.getValue() + ? fullMeshSignallingEventTypes + : []), ]; const client = createRoomWidgetClient(