diff --git a/docs/url-params.md b/docs/url-params.md index e24e9823..ac6f2f7a 100644 --- a/docs/url-params.md +++ b/docs/url-params.md @@ -46,32 +46,32 @@ possible to support encryption. These parameters are relevant to both [widget](./embedded-standalone.md) and [standalone](./embedded-standalone.md) modes: -| Name | Values | Required for widget | Required for SPA | Description | -| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `intent` | `start_call`, `join_existing`, `start_call_dm`, `join_existing_dm. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. | -| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesn’t provide any. | -| `posthogUserId` | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. | -| `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. | -| `confineToRoom` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Keeps the user confined to the current call/room. | -| `displayName` | | No | No | Display name used for auto-registration. | -| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. | -| `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. | -| `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. | -| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. | -| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. | -| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. | -| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. | -| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) | -| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. | -| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. | -| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. | -| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. | -| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) | -| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. | -| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. | -| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. | -| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. | -| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. | +| Name | Values | Required for widget | Required for SPA | Description | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `intent` | `start_call`, `join_existing`, `start_call_voice`, `join_existing_voice`, `start_call_dm`, `join_existing_dm`, `start_call_dm_voice`, or `join_existing_dm_voice`. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. | +| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesn’t provide any. | +| `posthogUserId` | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. | +| `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. | +| `confineToRoom` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Keeps the user confined to the current call/room. | +| `displayName` | | No | No | Display name used for auto-registration. | +| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. | +| `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. | +| `fonts` | | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. | +| `header` | `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. | +| `hideScreensharing` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. | +| `homeserver` | | Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. | +| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. | +| `password` | | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) | +| `perParticipantE2EE` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. | +| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. | +| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No | Anything about what room we're pointed to should be from useRoomIdentifier which parses the path and resolves alias with respect to the default server name, however roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. | +| `showControls` | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. | +| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) | +| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. | +| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. | +| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. | +| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. | +| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. | ### Widget-only parameters diff --git a/package.json b/package.json index 8aa37499..32476d10 100644 --- a/package.json +++ b/package.json @@ -80,8 +80,8 @@ "@typescript-eslint/eslint-plugin": "^8.31.0", "@typescript-eslint/parser": "^8.31.0", "@use-gesture/react": "^10.2.11", - "@vector-im/compound-design-tokens": "^9.0.0", - "@vector-im/compound-web": "^8.0.0", + "@vector-im/compound-design-tokens": "^10.0.0", + "@vector-im/compound-web": "^9.0.0", "@vitejs/plugin-react": "^4.0.1", "@vitest/coverage-v8": "^4.0.18", "babel-plugin-transform-vite-meta-env": "^1.0.3", diff --git a/playwright/widget/federation-oldest-membership-bug.spec.ts b/playwright/widget/federation-oldest-membership-bug.spec.ts new file mode 100644 index 00000000..70442e05 --- /dev/null +++ b/playwright/widget/federation-oldest-membership-bug.spec.ts @@ -0,0 +1,92 @@ +/* +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"; +import { HOST1, HOST2, TestHelpers } from "./test-helpers"; + +widgetTest( + "Bug new joiner was not publishing on correct SFU", + async ({ addUser, browserName }) => { + test.skip( + browserName === "firefox", + "This is a bug in the old widget, not a browser problem.", + ); + + test.slow(); + + // 2 users in federation + const florian = await addUser("floriant", HOST1); + const timo = await addUser("timo", HOST2); + + // Florian creates a room and invites Timo to it + const roomName = "Call Room"; + await TestHelpers.createRoom(roomName, florian.page, [timo.mxId]); + + // Timo joins the room + await TestHelpers.acceptRoomInvite(roomName, timo.page); + + // Ensure we are in legacy mode (should be the default) + await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget( + florian.page, + "legacy", + ); + await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget( + timo.page, + "legacy", + ); + + // Let timo create a call + await TestHelpers.startCallInCurrentRoom(timo.page, false); + await TestHelpers.joinCallFromLobby(timo.page); + + // We want to simulate that the oldest membership authentication is way slower than + // the preffered auth. + // In this setup, timo advertised$ transport will be it's own, and the active will be the one from florian + await florian.page.route( + "**/matrix-rtc.othersite.m.localhost/livekit/jwt/**", + async (route) => { + await new Promise((resolve) => setTimeout(resolve, 2000)); // 5 second delay + await route.continue(); + }, + ); + + // Florian joins the call + await expect(florian.page.getByTestId("join-call-button")).toBeVisible(); + await florian.page.getByTestId("join-call-button").click(); + await TestHelpers.joinCallFromLobby(florian.page); + + await florian.page.waitForTimeout(3000); + await timo.page.waitForTimeout(3000); + + // We should see 2 video tiles everywhere now + for (const user of [timo, florian]) { + const frame = user.page + .locator('iframe[title="Element Call"]') + .contentFrame(); + await expect(frame.getByTestId("videoTile")).toHaveCount(2); + + // No one should be waiting for media + await expect(frame.getByText("Waiting for media...")).not.toBeVisible(); + + // There should be 2 video elements, visible and autoplaying + const videoElements = await frame.locator("video").all(); + expect(videoElements.length).toBe(2); + + const blockDisplayCount = await frame + .locator("video") + .evaluateAll( + (videos: Element[]) => + videos.filter( + (v: Element) => window.getComputedStyle(v).display === "block", + ).length, + ); + expect(blockDisplayCount).toBe(2); + } + }, +); diff --git a/src/UrlParams.test.ts b/src/UrlParams.test.ts index ec92ee89..a18d10f1 100644 --- a/src/UrlParams.test.ts +++ b/src/UrlParams.test.ts @@ -271,7 +271,11 @@ describe("UrlParams", () => { computeUrlParams( "?intent=start_call&widgetId=1234&parentUrl=parent.org", ), - ).toMatchObject({ ...startNewCallDefaults("desktop"), skipLobby: false }); + ).toMatchObject({ + ...startNewCallDefaults("desktop"), + skipLobby: false, + callIntent: "video", + }); }); it("accepts start_call_dm mobile", () => { @@ -308,6 +312,29 @@ describe("UrlParams", () => { ), ).toMatchObject(joinExistingCallDefaults("desktop")); }); + + it("accepts start_call_voice", () => { + expect( + computeUrlParams( + "?intent=start_call_voice&widgetId=1234&parentUrl=parent.org", + ), + ).toMatchObject({ + ...startNewCallDefaults("desktop"), + skipLobby: false, + callIntent: "audio", + }); + }); + + it("accepts join_existing_voice", () => { + expect( + computeUrlParams( + "?intent=join_existing_voice&widgetId=1234&parentUrl=parent.org", + ), + ).toMatchObject({ + ...joinExistingCallDefaults("desktop"), + callIntent: "audio", + }); + }); }); describe("skipLobby", () => { diff --git a/src/UrlParams.ts b/src/UrlParams.ts index 339dd9f1..a256e0a4 100644 --- a/src/UrlParams.ts +++ b/src/UrlParams.ts @@ -31,6 +31,8 @@ interface RoomIdentifier { export enum UserIntent { StartNewCall = "start_call", JoinExistingCall = "join_existing", + StartNewCallVoice = "start_call_voice", + JoinExistingCallVoice = "join_existing_voice", StartNewCallDM = "start_call_dm", StartNewCallDMVoice = "start_call_dm_voice", JoinExistingCallDM = "join_existing_dm", @@ -416,6 +418,15 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => { intentPreset.skipLobby = false; intentPreset.callIntent = "video"; break; + case UserIntent.StartNewCallVoice: + intentPreset.skipLobby = false; + intentPreset.callIntent = "audio"; + break; + case UserIntent.JoinExistingCallVoice: + // On desktop this will be overridden based on which button was used to join the call + intentPreset.skipLobby = false; + intentPreset.callIntent = "audio"; + break; case UserIntent.StartNewCallDMVoice: intentPreset.callIntent = "audio"; // Fall through diff --git a/src/components/InCallFooter.module.css b/src/components/InCallFooter.module.css index da96af5f..563d27d2 100644 --- a/src/components/InCallFooter.module.css +++ b/src/components/InCallFooter.module.css @@ -27,9 +27,8 @@ Please see LICENSE in the repository root for full details. } .footer.overlay { - position: absolute; - inset-block-end: 0; - inset-inline: 0; + /* Note that the footer is still position: sticky in this case so that certain + tiles can move up out of the way of the footer when visible. */ opacity: 1; transition: opacity 0.15s; } @@ -38,6 +37,11 @@ Please see LICENSE in the repository root for full details. display: grid; opacity: 0; pointer-events: none; + /* Switch to position: absolute so the footer takes up no space in the layout + when hidden. */ + position: absolute; + inset-block-end: 0; + inset-inline: 0; } .footer.overlay:has(:focus-visible) { @@ -72,7 +76,7 @@ Please see LICENSE in the repository root for full details. } /*First hide the logo*/ -@media (max-width: 660px) { +@media (max-width: 750px) { .logo { display: none; } diff --git a/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap index 3f02a49a..0d2d39bc 100644 --- a/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap +++ b/src/room/__snapshots__/GroupCallErrorBoundary.test.tsx.snap @@ -108,8 +108,9 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = ` class="error" >