diff --git a/.github/labels.yml b/.github/labels.yml
index 063787d96..0747a55b7 100644
--- a/.github/labels.yml
+++ b/.github/labels.yml
@@ -102,6 +102,9 @@
- name: "backport-candidate"
description: "Something that is a candidate for backport to a particular release branch"
color: "0B8D85"
+- name: "Claude-Review"
+ description: "Request code review from Claude"
+ color: "e57157"
- name: "customer-retainer"
color: "F44A5F"
- name: "dependencies"
diff --git a/.github/workflows/claude.yaml b/.github/workflows/claude.yaml
new file mode 100644
index 000000000..1ddbeadaa
--- /dev/null
+++ b/.github/workflows/claude.yaml
@@ -0,0 +1,90 @@
+name: Claude
+on:
+ pull_request:
+ types: [labeled]
+
+permissions:
+ # The action exchanges the GitHub OIDC token for a short-lived Anthropic
+ # access token. GitHub API access goes through CLAUDE_GH_TOKEN instead.
+ id-token: write
+
+jobs:
+ review:
+ name: Review
+ # Only run on labelling and skip forks.
+ if: github.event.label.name == 'Claude-Review' && github.event.pull_request.head.repo.full_name == github.repository
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
+ with:
+ # The review looks at the individual commits, not just the combined diff
+ fetch-depth: 0
+ persist-credentials: false
+ - name: Review
+ uses: anthropics/claude-code-action@6fed3ca145920b639991cb756090506e1bcaf515 # v1.0.245
+ with:
+ anthropic_federation_rule_id: fdrl_01XQmWo4nka1ZTqZdsdeScyr
+ anthropic_organization_id: dfbc9a23-d636-4653-8970-035204ef60fa
+ anthropic_service_account_id: svac_018Ay5qaA7ZW8X7XsbdekqUM
+ anthropic_workspace_id: wrkspc_01Aw8LHjBS6fZysu9yqzYifW
+ # The token used here must grant read access to code, issues, and metadata as well as
+ # read and write access to pull requests.
+ github_token: ${{ secrets.CLAUDE_GH_TOKEN }}
+ use_sticky_comment: true
+ track_progress: true
+ prompt: |
+ Review this pull request under the following (and no further) angles:
+
+ 1. Scope:
+
+ - Does the PR do one thing?
+ - Is the PR free of unrelated fixes and drive-by refactorings?
+ - If the PR is large, could it reasonably be split into smaller PRs
+ that still deliver value?
+
+ 2. Commits:
+
+ - Is the work split into self-contained commits of reasonable size?
+ - Are the commits arranged in a sensible order?
+ - Have required refactorings been split out into separate commits?
+ - Is the history free of "wip", checkpoint and fixup-style commits
+ which only correct an earlier commit in the same PR?
+
+ 3. Code:
+
+ - Are the code changes functionally correct?
+ - Do the changes introduce new security vulnerabilities or metadata
+ leaks?
+ - Is the new code performant enough?
+ - Don't check anything that is already validated via other GitHub
+ workflows like linting, formatting or tests passing.
+
+ Read the commits themselves with `git log` and `git diff` against
+ the base branch of the PR.
+
+ Drop any findings that are outside the above review angles or that don't
+ require resolution. Grade the severity of each finding as either "🟡 low",
+ "🟠 medium" or "🔴 high". Devise at least one possible resolution for each
+ finding.
+
+ Report the graded findings and resolutions from the "Code" angle as inline
+ comments. Add a footer "_-- Finding from automated review_" to each inline
+ comment.
+
+ Finally, report your overall result as PR comment. Start your message with
+ "# Automated review". Then add one section per review angle with:
+
+ - A verdict of "✅ PASS" or "❌ NEEDS WORK"
+ - A bullet list summarizing the remaining non-inlined graded findings and
+ resolutions (as nested bullets).
+
+ Be brief and concise in all your communication. Do not mention what you have
+ not covered and don't bring up issues that have already been fixed.
+
+ Use Markdown links for anything that can be navigated to on GitHub such
+ as files, commits, issues, pull requests, workflow runs, etc.
+ claude_args: |
+ --model claude-fable-5-1
+ --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(git log:*),Bash(git diff:*),Bash(git show:*)"
+ --max-turns 50
diff --git a/backend/app-service-othersite.yaml b/backend/app-service-othersite.yaml
index ef2a7ada7..a80322baf 100644
--- a/backend/app-service-othersite.yaml
+++ b/backend/app-service-othersite.yaml
@@ -5,7 +5,7 @@ sender_localpart: "_lk_jwt_service"
namespaces:
users:
- exclusive: false
- regex: "@.*:homeserver-1" # Cover all local users
+ regex: "@.*:synapse\\.othersite\\.m\\.localhost" # Cover all local users
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
url: "http://auth-server-1:16080"
# Enable membership look-ups via /is_joined.
diff --git a/backend/app-service.yaml b/backend/app-service.yaml
index b480df64e..75716ca59 100644
--- a/backend/app-service.yaml
+++ b/backend/app-service.yaml
@@ -5,7 +5,7 @@ sender_localpart: "_lk_jwt_service"
namespaces:
users:
- exclusive: false
- regex: "@.*:homeserver" # Cover all local users
+ regex: "@.*:synapse\\.m\\.localhost" # Cover all local users
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
url: "http://auth-server:6080"
# Enable membership look-ups via /is_joined.
diff --git a/backend/dev_livekit-othersite.yaml b/backend/dev_livekit-othersite.yaml
index 53fc9ce94..476ecc45c 100644
--- a/backend/dev_livekit-othersite.yaml
+++ b/backend/dev_livekit-othersite.yaml
@@ -6,6 +6,11 @@ rtc:
port_range_start: 50300
port_range_end: 50400
use_external_ip: false
+ # Advertise the loopback address instead of the container IP, which is not
+ # routable from the host when Docker runs in a VM (colima, Docker Desktop).
+ # The browser then reaches the SFU through the published ports. Firefox
+ # ignores loopback candidates, so this only works in Chromium-based browsers.
+ node_ip: 127.0.0.1
turn:
enabled: false
domain: localhost
diff --git a/backend/dev_livekit.yaml b/backend/dev_livekit.yaml
index 6cef4241a..576c67589 100644
--- a/backend/dev_livekit.yaml
+++ b/backend/dev_livekit.yaml
@@ -6,6 +6,11 @@ rtc:
port_range_start: 50100
port_range_end: 50200
use_external_ip: false
+ # Advertise the loopback address instead of the container IP, which is not
+ # routable from the host when Docker runs in a VM (colima, Docker Desktop).
+ # The browser then reaches the SFU through the published ports. Firefox
+ # ignores loopback candidates, so this only works in Chromium-based browsers.
+ node_ip: 127.0.0.1
turn:
enabled: false
domain: localhost
diff --git a/component/CallPortalRoot.test.tsx b/component/CallPortalRoot.test.tsx
new file mode 100644
index 000000000..bfa23971b
--- /dev/null
+++ b/component/CallPortalRoot.test.tsx
@@ -0,0 +1,57 @@
+/*
+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 "vitest";
+import { render, screen } from "@testing-library/react";
+import { type ReactNode } from "react";
+import { Tooltip, TooltipProvider } from "@vector-im/compound-web";
+
+import { CallPortalRoot } from "./CallPortalRoot";
+
+// A button whose accessible name comes from its tooltip, as every icon button
+// in the call does: the name is lost whenever the tooltip has nowhere to go.
+const LabelledButton = (): ReactNode => (
+
+
+
+
+
+);
+
+test("floats into the host document with no root", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Microphone" })).toBeTruthy();
+});
+
+test("floats into the root it is given", () => {
+ const root = document.createElement("div");
+ document.body.appendChild(root);
+ render(
+
+
+ ,
+ );
+ const button = screen.getByRole("button", { name: "Microphone" });
+ const label = document.getElementById(
+ button.getAttribute("aria-labelledby")!,
+ );
+ expect(root.contains(label)).toBe(true);
+ root.remove();
+});
+
+test("waits for a root that is not available yet", () => {
+ render(
+
+
+ ,
+ );
+ expect(screen.queryByRole("button", { name: "Microphone" })).toBeNull();
+});
diff --git a/component/CallPortalRoot.tsx b/component/CallPortalRoot.tsx
new file mode 100644
index 000000000..281eb28c0
--- /dev/null
+++ b/component/CallPortalRoot.tsx
@@ -0,0 +1,26 @@
+/*
+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 FC, type ReactNode } from "react";
+import { PortalRoot } from "@vector-im/compound-web";
+
+interface Props {
+ root: HTMLElement | null | undefined;
+ children: ReactNode;
+}
+
+/**
+ * Where Compound floats the call's menus and tooltips. With no `root` the call
+ * is not wrapped at all and they go into the host document's body; `null` is
+ * Compound's "not available yet", and the floating parts wait for it.
+ */
+export const CallPortalRoot: FC = ({ root, children }): ReactNode =>
+ root === undefined ? (
+ children
+ ) : (
+ {children}
+ );
diff --git a/component/api.ts b/component/api.ts
index c9e7a2fd7..fa2928eb1 100644
--- a/component/api.ts
+++ b/component/api.ts
@@ -121,4 +121,18 @@ export interface ElementCallProps {
* the most recently set language wins for all of them.
*/
language?: string;
+ /**
+ * Where the parts of Element Call that float above it — its menus and
+ * tooltips, which Compound portals out of the call — are rendered. Left out,
+ * they go into the host document's body, as they do for any other component
+ * on the host's page.
+ *
+ * A host that moves Element Call's DOM into another document, such as a
+ * Document Picture-in-Picture window, passes that document's body here so
+ * that a menu opens in the window its button is in. Changes take effect at
+ * once. `null` means the root is not available yet, as from a callback ref
+ * that has not fired: menus and tooltips wait for it rather than falling
+ * back to the body.
+ */
+ portalRoot?: HTMLElement | null;
}
diff --git a/component/index.tsx b/component/index.tsx
index 8f901c139..b3f7b6209 100644
--- a/component/index.tsx
+++ b/component/index.tsx
@@ -82,6 +82,7 @@ import { useTheme } from "../src/useTheme";
import { useStableValue } from "../src/useStableValue";
import styles from "./ElementCall.module.css";
import { useComponentHostBridge } from "./host";
+import { CallPortalRoot } from "./CallPortalRoot";
import { type ElementCallProps } from "./api";
import { supportedLanguages, translationsBackend } from "./localization";
@@ -154,6 +155,7 @@ export const ElementCall: FC = ({
ref,
theme,
language,
+ portalRoot,
}): ReactNode => {
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme);
@@ -240,34 +242,36 @@ export const ElementCall: FC = ({
// Stands in for the standalone page's `#root`: see `.content`
// in ElementCall.module.css
-
- {/* Whatever goes wrong in here is shown in here. Left to
- propagate, an error would unmount the host's own tree. */}
- }
- // A broken call should not hold the host on screen
- onError={() => void hostBridge.setAlwaysOnScreen(false)}
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+ {/* Whatever goes wrong in here is shown in here. Left to
+ propagate, an error would unmount the host's own tree. */}
+ }
+ // A broken call should not hold the host on screen
+ onError={() => void hostBridge.setAlwaysOnScreen(false)}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)}
diff --git a/component/package.json b/component/package.json
index 0d45eb4d7..9929b39bb 100644
--- a/component/package.json
+++ b/component/package.json
@@ -39,7 +39,7 @@
},
"peerDependencies": {
"@vector-im/compound-design-tokens": "^11.0.0",
- "@vector-im/compound-web": "^10.0.0",
+ "@vector-im/compound-web": "^10.3.1",
"matrix-js-sdk": "*",
"react": "^19",
"react-dom": "^19"
diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml
index 3de89ec38..2db9a63a0 100644
--- a/docker-compose-dev.yml
+++ b/docker-compose-dev.yml
@@ -3,7 +3,7 @@ networks:
services:
auth-service:
- image: ghcr.io/element-hq/lk-jwt-service:0.7.0
+ image: ghcr.io/element-hq/lk-jwt-service:0.8.0
pull_policy: always
hostname: auth-server
environment:
@@ -33,7 +33,7 @@ services:
- ecbackend
auth-service-1:
- image: ghcr.io/element-hq/lk-jwt-service:0.7.0
+ image: ghcr.io/element-hq/lk-jwt-service:0.8.0
pull_policy: always
hostname: auth-server-1
environment:
diff --git a/docker-compose-playwright.yml b/docker-compose-playwright.yml
index 889b94bd2..8cf23fa32 100644
--- a/docker-compose-playwright.yml
+++ b/docker-compose-playwright.yml
@@ -19,7 +19,7 @@ services:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
element-web:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
- image: ghcr.io/element-hq/element-web:develop@sha256:53a4fe6d60afc0647b95d9e1365d696679241821d63da94dfbfc33171a42ed6d
+ image: ghcr.io/element-hq/element-web:develop@sha256:06ac258c582ba3e59899cc52c9c2f4fde2e0a2dafddc1e3541c18471162828f8
element-web-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
- image: ghcr.io/element-hq/element-web:develop@sha256:53a4fe6d60afc0647b95d9e1365d696679241821d63da94dfbfc33171a42ed6d
+ image: ghcr.io/element-hq/element-web:develop@sha256:06ac258c582ba3e59899cc52c9c2f4fde2e0a2dafddc1e3541c18471162828f8
diff --git a/docs/url_params.md b/docs/url_params.md
index 789e3f3ce..8263055c5 100644
--- a/docs/url_params.md
+++ b/docs/url_params.md
@@ -61,7 +61,6 @@ These parameters are relevant to both [widget](./embedded_standalone.md) and [st
| `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. |
diff --git a/locales/en/app.json b/locales/en/app.json
index e31c7d50a..5be1bfff6 100644
--- a/locales/en/app.json
+++ b/locales/en/app.json
@@ -59,10 +59,10 @@
"developer_mode": {
"always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms",
"crypto_version": "Crypto version: {{version}}",
- "custom_livekit_url": {
- "current_url": "Currently set to: ",
+ "custom_transport": {
+ "current": "Currently set to: ",
"from_config": "Currently, no overwrite is set. Url from config is used.",
- "label": "Custom Livekit-url",
+ "label": "Custom transport JSON",
"reset": "Reset overwrite",
"save": "Save",
"saving": "Saving..."
diff --git a/package.json b/package.json
index 3127614c5..cd75a791b 100644
--- a/package.json
+++ b/package.json
@@ -88,7 +88,7 @@
"@typescript-eslint/utils": "^8.61.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^11.0.0",
- "@vector-im/compound-web": "^10.0.0",
+ "@vector-im/compound-web": "^10.4.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "^4.0.18",
diff --git a/playwright.config.ts b/playwright.config.ts
index 847b87c84..2de044194 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -39,8 +39,20 @@ export default defineConfig({
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
- /* Reporter to use. See https://playwright.dev/docs/test-reporters */
- reporter: "html",
+ /**
+ * Reporter to use. See https://playwright.dev/docs/test-reporters
+ *
+ * On CI, `list` prints a line per test as it finishes, which keeps the job
+ * log moving; `github` annotates failures on the PR; `html` is uploaded as
+ * an artifact and must never try to open a browser there.
+ */
+ reporter: process.env.CI
+ ? [["list"], ["github"], ["html", { open: "never" }]]
+ : [["list"], ["html"]],
+ /* Stop a broken run early on CI instead of waiting out every retry. */
+ maxFailures: process.env.CI ? 5 : undefined,
+ /* Under the job's 60 minute limit, so a hung run still ends with a report. */
+ globalTimeout: process.env.CI ? 50 * 60 * 1000 : undefined,
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
diff --git a/playwright/component/audio-menu.spec.ts b/playwright/component/audio-menu.spec.ts
index f4d811598..3276c28a7 100644
--- a/playwright/component/audio-menu.spec.ts
+++ b/playwright/component/audio-menu.spec.ts
@@ -129,7 +129,7 @@ async function openDeviceList(page: Page, pane: Locator): Promise {
.getByRole("button", { name: "Microphone" })
.click({ timeout: 60_000 });
await expect(page.getByRole("menu")).toBeVisible();
- const list = page.locator("[role='menu'] div[role='none']").first();
+ const list = page.getByRole("menu").getByTestId("device-list");
await expect(list).toBeVisible();
return list;
}
diff --git a/playwright/errors.spec.ts b/playwright/errors.spec.ts
index 23f6e29f3..e7eff6592 100644
--- a/playwright/errors.spec.ts
+++ b/playwright/errors.spec.ts
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { expect, test } from "@playwright/test";
-import { createJTWToken } from "./fixtures/jwt-token";
+import { createJWTToken } from "./fixtures/jwt-token";
test("Should show error screen if fails to get JWT token", async ({ page }) => {
await page.goto("/");
@@ -95,7 +95,7 @@ test("Should show error screen if call creation is restricted", async ({
contentType: "application/json",
body: JSON.stringify({
url: "wss://badurltotricktest/livekit/sfu",
- jwt: createJTWToken("@fake:user", "!fake:room"),
+ jwt: createJWTToken("@fake:user", "!fake:room"),
}),
}),
);
diff --git a/playwright/fixtures/jwt-token.ts b/playwright/fixtures/jwt-token.ts
index 18119c7e0..dff715f85 100644
--- a/playwright/fixtures/jwt-token.ts
+++ b/playwright/fixtures/jwt-token.ts
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
-export function createJTWToken(sub: string, room: string): string {
+export function createJWTToken(sub: string, room: string): string {
return [
{}, // header
{
diff --git a/playwright/url-transport.spec.ts b/playwright/url-transport.spec.ts
new file mode 100644
index 000000000..77fe00827
--- /dev/null
+++ b/playwright/url-transport.spec.ts
@@ -0,0 +1,493 @@
+/*
+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, type Page, test } from "@playwright/test";
+
+import { SpaHelpers } from "./spa-helpers";
+import { type RtcMode } from "./widget/test-helpers.ts";
+
+const sfuUrl = "wss://matrix-rtc.m.localhost/livekit/sfu";
+const serviceUrl = "https://matrix-rtc.m.localhost/livekit/jwt";
+
+const otherHomeserver = {
+ base_url: "https://synapse.othersite.m.localhost",
+ server_name: "synapse.othersite.m.localhost",
+};
+const otherSfuUrl = "wss://matrix-rtc.othersite.m.localhost/livekit/sfu";
+
+interface Transport {
+ type: "livekit";
+ url?: string;
+ livekit_service_url?: string;
+}
+
+/**
+ * The transport shapes a homeserver may advertise while migrating to the
+ * latest MSC4195. `legacy` is what the e2e Synapse advertises on its own.
+ */
+const shapes = {
+ url: { type: "livekit", url: sfuUrl },
+ both: { type: "livekit", url: sfuUrl, livekit_service_url: serviceUrl },
+ legacy: null,
+} satisfies Record;
+type Shape = keyof typeof shapes;
+
+/** Which endpoint a page asks for its SFU tokens. */
+type Flow = "cs-api" | "service-legacy" | "service-default";
+
+const cases: {
+ hostShape: Shape;
+ guestShape: Shape;
+ /** The MatrixRTC mode both sides use; the default is compatibility mode. */
+ mode?: RtcMode;
+ hostFlows: Flow[];
+ guestFlows: Flow[];
+}[] = [
+ {
+ hostShape: "url",
+ guestShape: "url",
+ hostFlows: ["cs-api"],
+ guestFlows: ["cs-api"],
+ },
+ {
+ // Publishers keep the legacy participant identity that clients predating
+ // MSC4195 expect, which only the JWT service assigns.
+ hostShape: "both",
+ guestShape: "both",
+ hostFlows: ["service-legacy"],
+ guestFlows: ["service-legacy"],
+ },
+ {
+ // The MatrixRTC mode has no bearing on the choice.
+ hostShape: "both",
+ guestShape: "both",
+ mode: "2_0",
+ hostFlows: ["service-legacy"],
+ guestFlows: ["service-legacy"],
+ },
+ {
+ hostShape: "url",
+ guestShape: "legacy",
+ hostFlows: ["cs-api", "service-default"],
+ guestFlows: ["service-legacy", "cs-api"],
+ },
+];
+
+for (const { hostShape, guestShape, mode, hostFlows, guestFlows } of cases) {
+ test(`One to one call with ${hostShape} host and ${guestShape} guest transport${mode === "2_0" ? " in Matrix 2.0 mode" : ""}`, async ({
+ browser,
+ page,
+ browserName,
+ }) => {
+ skipOnFirefox(browserName);
+
+ // The host starts the call.
+ await advertiseTransport(page, shapes[hostShape]);
+ const hostRequests = recordAuthRequests(page);
+ await page.goto("/");
+ await SpaHelpers.createCall(page, "Androl", "HelloCall", true, mode);
+ const inviteLink = await SpaHelpers.getCallInviteLink(page);
+
+ // The guest joins.
+ const guestContext = await browser.newContext({ reducedMotion: "reduce" });
+ const guestPage = await guestContext.newPage();
+ await advertiseTransport(guestPage, shapes[guestShape]);
+ const guestRequests = recordAuthRequests(guestPage);
+ await SpaHelpers.joinCallFromInviteLink(
+ guestPage,
+ inviteLink,
+ "Pevara",
+ mode,
+ );
+
+ // Wait for the call to connect and render.
+ await SpaHelpers.expectVideoTilesCount(page, 2);
+ await SpaHelpers.expectVideoTilesCount(guestPage, 2);
+
+ // The host and guest ran through the expected flows.
+ expect(flowsOf(hostRequests)).toEqual(new Set(hostFlows));
+ expect(flowsOf(guestRequests)).toEqual(new Set(guestFlows));
+
+ // All requests succeeded and the SFU URL was set on C-S requests.
+ for (const request of [...hostRequests, ...guestRequests]) {
+ expect(request.status).toBe(200);
+ if (request.flow === "cs-api") expect(request.sfuUrl).toBe(sfuUrl);
+ // Clients predating MSC4195 match a publisher by the legacy identity,
+ // which only the JWT service's legacy endpoint assigns. The other flows
+ // hash it.
+ expect(request.identity).toMatch(
+ request.flow === "service-legacy" ? legacyIdentity : hashedIdentity,
+ );
+ }
+ });
+}
+
+test("Subscriber falls back to the JWT service when the homeserver lacks MSC4195", async ({
+ browser,
+ page,
+ browserName,
+}) => {
+ skipOnFirefox(browserName);
+
+ // The host starts the call with both transport shapes.
+ await advertiseTransport(page, shapes.both);
+ await page.goto("/");
+ await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
+ const inviteLink = await SpaHelpers.getCallInviteLink(page);
+
+ // The guest joins but their C-S requests fail.
+ const guestContext = await browser.newContext({ reducedMotion: "reduce" });
+ const guestPage = await guestContext.newPage();
+ await rejectCsApiTokens(guestPage);
+ const guestRequests = recordAuthRequests(guestPage);
+ await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink, "Pevara");
+
+ // Wait for the call to connect and render.
+ await SpaHelpers.expectVideoTilesCount(page, 2);
+ await SpaHelpers.expectVideoTilesCount(guestPage, 2);
+
+ // The guest ran through all flows.
+ expect(flowsOf(guestRequests)).toEqual(
+ new Set(["cs-api", "service-legacy", "service-default"]),
+ );
+
+ // The C-S token requests failed.
+ for (const request of guestRequests) {
+ expect(request.status).toBe(request.flow === "cs-api" ? 404 : 200);
+ }
+
+ // The C-S token requests preceeded the fallback request.
+ const flows = guestRequests.map((r) => r.flow);
+ expect(flows.indexOf("service-default")).toBeGreaterThan(
+ flows.indexOf("cs-api"),
+ );
+});
+
+test("Publisher shows an error when the homeserver lacks MSC4195", async ({
+ page,
+ browserName,
+}) => {
+ skipOnFirefox(browserName);
+
+ // The host tries to start a call with only the C-S transport shape available but
+ // their C-S requests fail.
+ await advertiseTransport(page, shapes.url);
+ await rejectCsApiTokens(page);
+ await page.goto("/");
+ await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
+
+ // We land on an error page.
+ await expect(
+ page.getByRole("heading", { name: "Something went wrong" }),
+ ).toBeVisible();
+ await expect(
+ page.getByText(
+ "The authorization service for your media server (SFU) is out of date.",
+ ),
+ ).toBeVisible();
+});
+
+/**
+ * Delegation follows the flow that authenticated the publisher: the homeserver
+ * for a `url`-only transport, the JWT service wherever there is one.
+ */
+const delegationCases: { shape: Shape; via: Delegation["via"] }[] = [
+ { shape: "url", via: "homeserver" },
+ { shape: "legacy", via: "service" },
+ { shape: "both", via: "service" },
+];
+
+for (const { shape, via } of delegationCases) {
+ test(`Delayed leave event delegated through the ${via} for a ${shape} transport gets sent on disconnect`, async ({
+ browser,
+ page,
+ browserName,
+ }) => {
+ skipOnFirefox(browserName);
+
+ // The host starts the call.
+ await advertiseTransport(page, shapes[shape]);
+ await page.goto("/");
+ await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
+ const inviteLink = await SpaHelpers.getCallInviteLink(page);
+
+ // The guest joins.
+ const guestContext = await browser.newContext({ reducedMotion: "reduce" });
+ const guestPage = await guestContext.newPage();
+ await advertiseTransport(guestPage, shapes[shape]);
+ const delayedLeave = recordDelayedLeave(guestPage);
+ const delegations = recordDelegations(guestPage);
+ await SpaHelpers.joinCallFromInviteLink(guestPage, inviteLink, "Pevara");
+
+ // Wait for the call to connect and render.
+ await SpaHelpers.expectVideoTilesCount(page, 2);
+ await SpaHelpers.expectVideoTilesCount(guestPage, 2);
+
+ // The guest handed its delayed leave over, after probing for delegation
+ // support with bodiless requests, which count as supported unless they
+ // 404. The homeserver is always probed; the JWT service only when there
+ // is one.
+ await expect
+ .poll(() => delegations.at(-1))
+ .toEqual({
+ via,
+ status: 200,
+ delayId: delayedLeave.delayId,
+ ...(via === "homeserver" && { sfuUrl }),
+ });
+ const probes = delegations.slice(0, -1);
+ expect(probes.some((probe) => probe.via === via)).toBe(true);
+ for (const probe of probes) {
+ expect(probe.status).toBeGreaterThanOrEqual(400);
+ expect(probe.status).not.toBe(404);
+ }
+
+ // The delay is long enough that a leave within the window below cannot
+ // come from the delayed event expiring on its own.
+ expect(delayedLeave.delayMs).toBeGreaterThan(60_000);
+
+ // The guest drops without leaving.
+ await guestPage.reload();
+
+ // The host eventually sees the guest go once the delayed event fires.
+ await expect(page.getByTestId("videoTile")).toHaveCount(1, {
+ timeout: 30_000,
+ });
+ });
+}
+
+test("Subscribers get tokens for a remote homeserver's SFU over federation", async ({
+ browser,
+ page,
+ browserName,
+}) => {
+ skipOnFirefox(browserName);
+
+ // The host lives on the other homeserver and publishes on its SFU.
+ await useHomeserver(page, otherHomeserver);
+ await advertiseTransport(page, { type: "livekit", url: otherSfuUrl });
+ const hostRequests = recordAuthRequests(page);
+ await page.goto("/");
+ await SpaHelpers.createCall(page, "Androl", "HelloCall", true);
+ const inviteLink = await SpaHelpers.getCallInviteLink(page);
+
+ // The guest lives on the default homeserver and joins over federation.
+ const guestContext = await browser.newContext({ reducedMotion: "reduce" });
+ const guestPage = await guestContext.newPage();
+ await advertiseTransport(guestPage, shapes.url);
+ const guestRequests = recordAuthRequests(guestPage);
+ await SpaHelpers.joinCallFromInviteLink(
+ guestPage,
+ `${inviteLink}&viaServers=${otherHomeserver.server_name}`,
+ "Pevara",
+ );
+
+ // Wait for the call to connect and render.
+ await SpaHelpers.expectVideoTilesCount(page, 2);
+ await SpaHelpers.expectVideoTilesCount(guestPage, 2);
+
+ // Each side got a token for its own SFU and for the other side's SFU, all
+ // through its own homeserver.
+ for (const requests of [hostRequests, guestRequests]) {
+ expect(flowsOf(requests)).toEqual(new Set(["cs-api"]));
+ expect(new Set(requests.map((r) => r.sfuUrl))).toEqual(
+ new Set([sfuUrl, otherSfuUrl]),
+ );
+ for (const request of requests) expect(request.status).toBe(200);
+ }
+});
+
+/** Skips the calling test if we're running on Firefox. */
+function skipOnFirefox(browserName: string): void {
+ test.skip(
+ browserName === "firefox",
+ "The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
+ );
+}
+
+/** Points this page's Element Call at another homeserver. */
+async function useHomeserver(
+ page: Page,
+ homeserver: typeof otherHomeserver,
+): Promise {
+ await page.route("**/config.json", async (route) => {
+ const response = await route.fetch();
+ const config = (await response.json()) as Record;
+ await route.fulfill({
+ response,
+ json: {
+ ...config,
+ default_server_config: { "m.homeserver": homeserver },
+ },
+ });
+ });
+}
+
+/**
+ * Maps recorded requests to a set of flows. Publisher and subscriber connections
+ * start concurrently so using arrays for comparison isn't convenient.
+ */
+function flowsOf(requests: AuthRequest[]): Set {
+ return new Set(requests.map((r) => r.flow));
+}
+
+/**
+ * Replaces the transports the homeserver advertises to this page. The real
+ * response is fetched first so its CORS headers carry over.
+ */
+async function advertiseTransport(
+ page: Page,
+ transport: Transport | null,
+): Promise {
+ if (transport === null) return;
+ await page.route("**/org.matrix.msc4143/rtc/transports", async (route) => {
+ if (route.request().method() !== "GET") return route.continue();
+ const response = await route.fetch();
+ await route.fulfill({ response, json: { rtc_transports: [transport] } });
+ });
+}
+
+/**
+ * Makes the homeserver look like one without MSC4195: its `get_token`
+ * endpoint answers as Synapse does for an unknown route.
+ */
+async function rejectCsApiTokens(page: Page): Promise {
+ await page.route("**/rtc/livekit/get_token", async (route) => {
+ if (route.request().method() !== "POST") return route.continue();
+ await route.fulfill({
+ status: 404,
+ json: { errcode: "M_UNRECOGNIZED", error: "Unrecognized request" },
+ headers: { "Access-Control-Allow-Origin": "*" },
+ });
+ });
+}
+
+/** A recorded delayed leave event. */
+interface DelayedLeave {
+ delayMs?: number;
+ delayId?: string;
+}
+
+/** Records the delayed leave event the page schedules on joining. */
+function recordDelayedLeave(page: Page): DelayedLeave {
+ const delayedLeave: DelayedLeave = {};
+ page.on("response", (response) => {
+ const delay = new URL(response.request().url()).searchParams.get(
+ "org.matrix.msc4140.delay",
+ );
+ if (delay === null || response.status() !== 200) return;
+ delayedLeave.delayMs = Number(delay);
+ response
+ .json()
+ .then((body: { delay_id: string }) => {
+ delayedLeave.delayId = body.delay_id;
+ })
+ .catch(() => {});
+ });
+ return delayedLeave;
+}
+
+/** A recorded delegation request for a delayed leave event. */
+interface Delegation {
+ /** Who was asked to send the leave event on the guest's behalf. */
+ via: "homeserver" | "service";
+ status: number;
+ sfuUrl?: string;
+ delayId?: string;
+}
+
+/**
+ * Records requests that delegate a delayed leave event, or probe for support:
+ * the MSC4195 `delegate_delayed_leave` endpoint of the homeserver, and the JWT
+ * service's `delegate_delayed_leave` probe and `/sfu/get` with delay fields.
+ */
+function recordDelegations(page: Page): Delegation[] {
+ const delegations: Delegation[] = [];
+ page.on("response", (response) => {
+ const request = response.request();
+ if (request.method() !== "POST") return;
+ const url = request.url();
+ const body = request.postDataJSON() as {
+ url?: string;
+ delay_id?: string;
+ } | null;
+ if (url.endsWith("/rtc/livekit/delegate_delayed_leave")) {
+ delegations.push({
+ via: "homeserver",
+ status: response.status(),
+ ...(body && { sfuUrl: body.url, delayId: body.delay_id }),
+ });
+ } else if (url.endsWith("/livekit/jwt/delegate_delayed_leave")) {
+ delegations.push({ via: "service", status: response.status() });
+ } else if (url.endsWith("/livekit/jwt/sfu/get") && body?.delay_id) {
+ delegations.push({
+ via: "service",
+ status: response.status(),
+ delayId: body.delay_id,
+ });
+ }
+ });
+ return delegations;
+}
+
+/** A recorded token request. */
+interface AuthRequest {
+ flow: Flow;
+ status: number;
+ /** The SFU a request to the MSC4195 `get_token` endpoint asked for. */
+ sfuUrl?: string;
+ /** The participant identity the issued token carries. */
+ identity?: string;
+}
+
+/** The legacy `:` participant identity. */
+const legacyIdentity = /^@[^:]+:[^:]+:[A-Z]+$/;
+/** A hashed participant identity: unpadded base64 of a SHA-256. */
+const hashedIdentity = /^[A-Za-z0-9+/]{43}$/;
+
+/** Reads the subject of a JWT without verifying it. */
+function jwtSubject(jwt: string): string {
+ const [, payload] = jwt.split(".");
+ return (
+ JSON.parse(Buffer.from(payload, "base64url").toString()) as {
+ sub: string;
+ }
+ ).sub;
+}
+
+/** Records token requests, including the identity each token carries. */
+function recordAuthRequests(page: Page): AuthRequest[] {
+ const requests: AuthRequest[] = [];
+ page.on("response", (response) => {
+ const request = response.request();
+ if (request.method() !== "POST") return;
+ const url = request.url();
+ let entry: AuthRequest;
+ if (url.endsWith("/rtc/livekit/get_token")) {
+ entry = {
+ flow: "cs-api",
+ status: response.status(),
+ sfuUrl: (request.postDataJSON() as { url: string }).url,
+ };
+ } else if (url.endsWith("/livekit/jwt/sfu/get")) {
+ entry = { flow: "service-legacy", status: response.status() };
+ } else if (url.endsWith("/livekit/jwt/get_token")) {
+ entry = { flow: "service-default", status: response.status() };
+ } else {
+ return;
+ }
+ requests.push(entry);
+ if (entry.status !== 200) return;
+ response
+ .json()
+ .then((body: { jwt: string }) => {
+ entry.identity = jwtSubject(body.jwt);
+ })
+ .catch(() => {});
+ });
+ return requests;
+}
diff --git a/playwright/widget/federated-call.test.ts b/playwright/widget/federated-call.test.ts
index 61f4750b3..8359dd233 100644
--- a/playwright/widget/federated-call.test.ts
+++ b/playwright/widget/federated-call.test.ts
@@ -37,8 +37,6 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
await TestHelpers.acceptRoomInvite(roomName, timo.page);
- await florian.page.pause();
-
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
florian.page,
rtcMode1,
@@ -74,8 +72,6 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
await TestHelpers.expectVisibleVideoCount(frame, 2);
}
-
- // await florian.page.pause();
},
);
});
diff --git a/playwright/widget/url-transport.test.ts b/playwright/widget/url-transport.test.ts
new file mode 100644
index 000000000..c4e39cfdd
--- /dev/null
+++ b/playwright/widget/url-transport.test.ts
@@ -0,0 +1,108 @@
+/*
+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, type Page, test } from "@playwright/test";
+
+import { widgetTest } from "../fixtures/widget-user.ts";
+import { HOST1, TestHelpers } from "./test-helpers.ts";
+
+const sfuUrl = "wss://matrix-rtc.m.localhost/livekit/sfu";
+const serviceUrl = "https://matrix-rtc.m.localhost/livekit/jwt";
+
+/** Which endpoint a page asks for its SFU tokens. */
+type Flow = "cs-api" | "service-legacy" | "service-default";
+
+widgetTest(
+ "Call between a url + livekit_service_url transport and a legacy one",
+ async ({ addUser, 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();
+
+ const [host, guest] = await Promise.all([
+ addUser("Androl", HOST1),
+ addUser("Pevara", HOST1),
+ ]);
+
+ // The host's homeserver has started advertising a `url` next to its JWT
+ // service; the guest's still advertises the JWT service alone.
+ await advertiseTransport(host.page, {
+ type: "livekit",
+ url: sfuUrl,
+ livekit_service_url: serviceUrl,
+ });
+ // Element Web answers the widget from a cache it filled at login, so make
+ // it fetch the transports again now that they are rerouted
+ await host.clientHandle.evaluate(async (client) => {
+ await client.cachedRtcTransports.refresh();
+ });
+ const hostFlows = recordFlows(host.page);
+ const guestFlows = recordFlows(guest.page);
+
+ const roomName = "Transport Room";
+ await TestHelpers.createRoom(roomName, host.page, [guest.mxId]);
+ await TestHelpers.acceptRoomInvite(roomName, guest.page);
+
+ await TestHelpers.startCallInCurrentRoom(host.page, false);
+ await TestHelpers.joinCallFromLobby(host.page);
+ await TestHelpers.joinCallInCurrentRoom(guest.page);
+
+ for (const user of [host, guest]) {
+ const frame = user.page
+ .locator('iframe[title="Element Call"]')
+ .contentFrame();
+ await expect(frame.getByTestId("videoTile")).toHaveCount(2, {
+ timeout: 10000,
+ });
+ await expect(frame.getByText("Waiting for media...")).not.toBeVisible({
+ timeout: 10000,
+ });
+ await TestHelpers.expectVisibleVideoCount(frame, 2);
+ }
+
+ // Element Web does not expose the MSC4195 endpoints to widgets yet, so
+ // neither side reached the CS API. The host published through the JWT
+ // service's legacy endpoint and subscribed to the guest through its
+ // default one; the guest published the same way and fell back to the
+ // default endpoint for the host's transport.
+ expect(hostFlows).toEqual(new Set(["service-legacy", "service-default"]));
+ expect(guestFlows).toEqual(new Set(["service-legacy", "service-default"]));
+ },
+);
+
+/**
+ * Replaces the transports the homeserver advertises to this page, including
+ * to the widget host, which fetches them on the widget's behalf. The real
+ * response is fetched first so its CORS headers carry over.
+ */
+async function advertiseTransport(
+ page: Page,
+ transport: { type: "livekit"; url?: string; livekit_service_url?: string },
+): Promise {
+ await page.route("**/org.matrix.msc4143/rtc/transports", async (route) => {
+ if (route.request().method() !== "GET") return route.continue();
+ const response = await route.fetch();
+ await route.fulfill({ response, json: { rtc_transports: [transport] } });
+ });
+}
+
+/** Records which token endpoints the page's widget asks. */
+function recordFlows(page: Page): Set {
+ const flows = new Set();
+ page.on("response", (response) => {
+ const request = response.request();
+ if (request.method() !== "POST") return;
+ const url = request.url();
+ if (url.endsWith("/rtc/livekit/get_token")) flows.add("cs-api");
+ else if (url.endsWith("/livekit/jwt/sfu/get")) flows.add("service-legacy");
+ else if (url.endsWith("/livekit/jwt/get_token"))
+ flows.add("service-default");
+ });
+ return flows;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c55527011..cc0d93beb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -132,10 +132,10 @@ importers:
version: 10.3.1(react@19.2.8)
'@vector-im/compound-design-tokens':
specifier: ^11.0.0
- version: 11.1.0(@types/react@19.2.18)(react@19.2.8)
+ version: 11.2.0(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-web':
- specifier: ^10.0.0
- version: 10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+ specifier: ^10.4.0
+ version: 10.4.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.2.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@vitejs/plugin-react':
specifier: ^6.0.2
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.7)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
@@ -3223,8 +3223,8 @@ packages:
peerDependencies:
react: '>= 16.8.0'
- '@vector-im/compound-design-tokens@11.1.0':
- resolution: {integrity: sha512-vkMcy/G5VBlVle8umQNpctKHqZITS8XOQprsgcbSGRmwW080Z+TXV/vPsEXhgq8JKdxURkLhua0zQUufCeo46A==}
+ '@vector-im/compound-design-tokens@11.2.0':
+ resolution: {integrity: sha512-XGwO63ZqFVXdkhCVLm3wa/tpx2Lw8pyaQYskObrLZIUkjs3aUmgq8CNhGcjkqRJXYGsLNJE2azov4DaM74h2PQ==}
peerDependencies:
'@adobe/leonardo-contrast-colors': ^1.0.0
'@types/react': '*'
@@ -3240,13 +3240,13 @@ packages:
react:
optional: true
- '@vector-im/compound-web@10.1.0':
- resolution: {integrity: sha512-Lhw9Q+RZo1GtDYjF5PXWTD0zdfbrz/vnSVBYFHC8Hq+YeDule2QfGbTRo8IVHeV7jK6ZzsLc8zb6553eeIRLYg==}
+ '@vector-im/compound-web@10.4.0':
+ resolution: {integrity: sha512-Tt5hsNK7ulhGdRuy9lzSm5C+T8M/4O4kLDFLkaU+Hi1kVWj6tj/5awW+U+GvtpWRh5llbHN2HRCh79+gQhek+A==}
peerDependencies:
'@fontsource/inconsolata': ^5
'@fontsource/inter': ^5
'@types/react': '*'
- '@vector-im/compound-design-tokens': '>=1.6.1 <11.0.0'
+ '@vector-im/compound-design-tokens': '>=1.6.1 <12.0.0'
react: ^18 || ^19.0.0
peerDependenciesMeta:
'@types/react':
@@ -8667,12 +8667,12 @@ snapshots:
'@use-gesture/core': 10.3.1
react: 19.2.8
- '@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8)':
+ '@vector-im/compound-design-tokens@11.2.0(@types/react@19.2.18)(react@19.2.8)':
optionalDependencies:
'@types/react': 19.2.18
react: 19.2.8
- '@vector-im/compound-web@10.1.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.1.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+ '@vector-im/compound-web@10.4.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.2.0(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -8684,7 +8684,7 @@ snapshots:
'@radix-ui/react-progress': 1.1.16(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-separator': 1.1.15(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8)
- '@vector-im/compound-design-tokens': 11.1.0(@types/react@19.2.18)(react@19.2.8)
+ '@vector-im/compound-design-tokens': 11.2.0(@types/react@19.2.18)(react@19.2.8)
classnames: 2.5.1
react: 19.2.8
vaul: 1.1.2(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
diff --git a/sdk-target-based-on-call-view-model/README.md b/sdk-target-based-on-call-view-model/README.md
index 806a6357f..4ead105ae 100644
--- a/sdk-target-based-on-call-view-model/README.md
+++ b/sdk-target-based-on-call-view-model/README.md
@@ -31,7 +31,7 @@ This file needs to be hosted. Locally (via `npx serve -l 81234 --cors`) or on a
Now you just need to add the widget to element web via:
```
-/addwidget http://localhost:3000?widgetId=$matrix_widget_id&perParticipantE2EE=true&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
+/addwidget http://localhost:3000?widgetId=$matrix_widget_id&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
```
## Widgets
@@ -47,7 +47,6 @@ by the matrixRTCSdk to start the postmessage widget api (communication between t
```
widgetId = $matrix_widget_id
-perParticipantE2EE = true
userId = $matrix_user_id
deviceId = $org.matrix.msc3819.matrix_device_id
baseUrl = $org.matrix.msc4039.matrix_base_url
@@ -58,7 +57,7 @@ baseUrl = $org.matrix.msc4039.matrix_base_url
Full template use as ``:
```
-http://localhost:3000?widgetId=$matrix_widget_id&perParticipantE2EE=true&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
+http://localhost:3000?widgetId=$matrix_widget_id&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
```
the `$` prefixed variables will be replaced by EW on widget instantiation. (e.g. `$matrix_user_id` -> `@user:example.com` (url encoding will also be applied automatically by EW) -> `%40user%3Aexample.com`)
diff --git a/sdk-target-based-on-call-view-model/main.ts b/sdk-target-based-on-call-view-model/main.ts
index 6cd37032d..3932d1190 100644
--- a/sdk-target-based-on-call-view-model/main.ts
+++ b/sdk-target-based-on-call-view-model/main.ts
@@ -231,7 +231,7 @@ export async function createMatrixRTCSdk(
complete: () => {
logger.info("Livekit room items subscription completed");
for (const item of callViewModel.livekitRoomItems$.value) {
- logger.info("unregistering room item from room", item.url);
+ logger.info("unregistering room item from room", item.transport);
item.livekitRoom.unregisterTextStreamHandler(TEXT_LK_TOPIC);
}
},
diff --git a/src/Modal.test.tsx b/src/Modal.test.tsx
index 74670b6db..223bbbc8d 100644
--- a/src/Modal.test.tsx
+++ b/src/Modal.test.tsx
@@ -11,6 +11,7 @@ import { type ReactNode, useState } from "react";
import userEvent from "@testing-library/user-event";
import { Modal } from "./Modal";
+import { RootElementProvider } from "./RootElementContext";
const originalMatchMedia = window.matchMedia;
afterEach(() => {
@@ -70,3 +71,31 @@ test("the modal renders as a drawer in mobile viewports", () => {
);
expect(queryByRole("dialog")).toMatchSnapshot();
});
+
+test("the modal can be closed by clicking the backdrop, in another window too", async () => {
+ // A dialog moved into another window, as a host does for a Document
+ // Picture-in-Picture window: its nodes are not `Node`s of this window
+ const frame = document.createElement("iframe");
+ document.body.appendChild(frame);
+ const otherBody = frame.contentDocument!.body;
+
+ function ModalFn(): ReactNode {
+ const [isOpen, setOpen] = useState(true);
+ return (
+
+ setOpen(false)}>
+
This is the content.
+
+
+ );
+ }
+ render();
+ const dialog = otherBody.querySelector('[role="dialog"]');
+ expect(dialog).not.toBeNull();
+ const backdrop = dialog!.previousElementSibling as HTMLElement;
+
+ const user = userEvent.setup({ document: frame.contentDocument! });
+ await user.click(backdrop);
+ expect(otherBody.querySelector('[role="dialog"]')).toBeNull();
+ frame.remove();
+});
diff --git a/src/Modal.tsx b/src/Modal.tsx
index 46316d370..01b2c76bc 100644
--- a/src/Modal.tsx
+++ b/src/Modal.tsx
@@ -160,6 +160,12 @@ export const Modal: FC = ({
onDismiss?.()}
/>
{
- try {
- const parsedUrl = new URL(url);
- return parsedUrl.hostname; // Returns "kdk.cpm"
- } catch (error) {
- console.error("Invalid URL:", error);
- return url;
- }
-};
-
// This is only used in developer mode for debugging purposes, so we don't need full localization
export const RTCConnectionStats: FC = ({
audio,
video,
- focusUrl,
+ transport,
rtcBackendIdentity,
- ...rest
}) => {
const [showModal, setShowModal] = useState(false);
const [modalContents, setModalContents] = useState<
@@ -76,10 +67,10 @@ export const RTCConnectionStats: FC = ({
rtcBackendIdentity:{rtcBackendIdentity}
- {focusUrl && (
+ {transport && (