Compare commits

..
Author SHA1 Message Date
Robin 4441bb5e8c Add snapshot coverage for all call layouts
Snapshotting the layout templates so that they get reviewed as a whole feels a little more straightforward than the existing tests.
2026-09-29 03:20:14 +02:00
Robin f2f49315eb Make drag & drop performant in all layouts
While also giving the logic for drag & drop of a floating tile a common home in the view model layer.
2026-09-28 19:53:30 +02:00
Robin 99256fa01c Make drag & drop of floating tiles less janky
This fixes an issue where upon dragging a floating tile to a different corner of the interface, it would briefly move toward its original position before jerking back in the intended direction.
2026-09-28 19:48:49 +02:00
140 changed files with 3643 additions and 5660 deletions
-3
View File
@@ -102,9 +102,6 @@
- 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"
+1 -3
View File
@@ -44,9 +44,7 @@ jobs:
node-version-file: ".node-version"
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "scripts/layered.sh --ignore-pnpmfile"
env:
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
- name: Build Element Call
run: pnpm run build:"$PACKAGE":"$BUILD_MODE"
env:
-90
View File
@@ -1,90 +0,0 @@
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
+1 -12
View File
@@ -1,12 +1,6 @@
name: Lint, format & type check
on:
pull_request: {}
workflow_call:
inputs:
matrix-js-sdk-sha:
type: string
required: false
description: The matrix-js-sdk commit to build against instead of the locked one
jobs:
lint:
name: Lint, format & type check
@@ -15,8 +9,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
persist-credentials: false
- name: Enable Corepack
run: corepack enable
@@ -27,10 +19,7 @@ jobs:
node-version-file: ".node-version"
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "scripts/layered.sh --ignore-pnpmfile"
env:
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
- name: Formatting
run: "pnpm run format:check"
- name: i18n
+3 -36
View File
@@ -3,25 +3,6 @@ on:
pull_request: {}
push:
branches: [main]
workflow_call:
inputs:
matrix-js-sdk-sha:
type: string
required: false
description: The matrix-js-sdk commit to build against instead of the locked one
disable_coverage:
type: boolean
required: false
default: false
description: Skip generating and uploading coverage reports
disable_e2e_tests:
type: boolean
required: false
default: false
description: Skip the end-to-end tests
secrets:
CODECOV_TOKEN:
required: false
jobs:
vitest:
name: Run unit tests
@@ -30,8 +11,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
persist-credentials: false
- name: Enable Corepack
run: corepack enable
@@ -42,10 +21,7 @@ jobs:
node-version-file: ".node-version"
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "scripts/layered.sh --ignore-pnpmfile"
env:
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
- name: Get Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
- name: Cache Playwright binaries
@@ -59,11 +35,8 @@ jobs:
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm playwright install --with-deps
- name: Vitest
run: pnpm vitest --coverage.enabled="$ENABLE_COVERAGE"
env:
ENABLE_COVERAGE: ${{ !inputs.disable_coverage }}
run: "pnpm run test:coverage"
- name: Upload to codecov
if: ${{ !inputs.disable_coverage }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
@@ -72,14 +45,11 @@ jobs:
fail_ci_if_error: true
playwright:
name: Run end-to-end tests
if: ${{ !inputs.disable_e2e_tests }}
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
persist-credentials: false
- name: Enable Corepack
run: corepack enable
@@ -89,10 +59,7 @@ jobs:
node-version-file: ".node-version"
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: scripts/layered.sh --ignore-pnpmfile
env:
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
run: pnpm install --frozen-lockfile --ignore-pnpmfile
- name: Get Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
- name: Cache Playwright binaries
-4
View File
@@ -2,7 +2,6 @@ node_modules
.DS_Store
.env
dist
dist-embedded
dist-ssr
*.local
*.bkp
@@ -42,6 +41,3 @@ yarn-error.log
*storybook.log
storybook-static
# Nested checkout made by scripts/layered.sh
/matrix-js-sdk/
+10 -57
View File
@@ -34,16 +34,16 @@ You can find the latest development version continuously deployed to
## ✨ Key Features
✅ **Decentralized & Federated** – No central authority; works across Matrix
homeservers.
✅ **End-to-End Encrypted** – Secure and private calls.
homeservers.
✅ **End-to-End Encrypted** – Secure and private calls.
✅ **Standalone, Widget & Component Mode** – Use as an independent app, embed
in Matrix clients as a widget, or (experimentally) mount it as a React component
inside your own application.
✅ **WebRTC-based** – No additional software required.
inside your own application.
✅ **WebRTC-based** – No additional software required.
✅ **Scalable with LiveKit** – Supports large meetings via SFU
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
✅ **Raise Hand** – Participants can signal when they want to speak, helping to
organize the flow of the meeting.
organize the flow of the meeting.
✅ **Emoji Reactions** – Users can react with emojis 👍️ 🎉 👏 🤘, adding
engagement and interactivity to the conversation.
@@ -297,8 +297,8 @@ The package is not published yet. A host installs it as a git dependency on the
```
whose `prepare` script runs the build on install. That build needs pnpm (via
Corepack) on the host's machine, runs a full `pnpm install` of this repository,
since it inherits the `--max-old-space-size` setting of
Corepack) on the host's machine, runs a full `pnpm install` of this repository
and is memory-hungry, since it inherits the `--max-old-space-size` setting of
the app build; the host's pnpm also has to allow it to run at all
(`allowBuilds` in its `pnpm-workspace.yaml`). Note that `component/` is a pnpm
project of its own for this reason, so pnpm commands run from inside that
@@ -306,28 +306,8 @@ directory target it rather than the repository; run them from the repository
root. The host imports the component from
`@element-hq/element-call-component` and the stylesheet from
`@element-hq/element-call-component/style.css`, and has to provide `react`,
`react-dom`, `matrix-js-sdk`, `@vector-im/compound-web` and
`@vector-im/compound-design-tokens` itself, since the bundle leaves them
external.
Compound's stylesheet are the host's to load too: the component shares
the host's copy of Compound, (required for example for tooltips)
The component is large, and a host will usually load it lazily, only once a
call is shown. Everything a host needs in order to talk about a call before
then is also exported
from `@element-hq/element-call-component/api` (a small entry point that does not
load the component).
- props and the types they are made of
- the handle
- the host bridge
- `UserIntent`
- `HeaderStyle`
- `BackgroundStyle` enums
- `configurationForIntent`, the defaults each intent implies
Import from it wherever a value such as
`BackgroundStyle.Solid` is needed on a path that must stay light; the main
entry point re-exports all of it too.
`react-dom`, `matrix-js-sdk` and `livekit-client` itself, since the bundle leaves
them external.
### Backend
@@ -361,33 +341,6 @@ pnpm backend
# podman-compose -f docker-compose-dev.yml up
```
The two Element Web instances load the Element Call widget from
`./dist-embedded`, which is mounted over the copy bundled in the Element Web
image. Build it from the current sources before starting the backend, and keep
it rebuilding on change:
```sh
VITE_MATRIX_RTC_MODE=matrix_2_0 pnpm build:embedded:development --outDir dist-embedded --watch
```
Build it at least once before the first `pnpm backend`, since Docker otherwise
creates an empty `dist-embedded` directory that the build cannot write to.
Reload the page after a rebuild to pick up the new widget. The widget has no
`config.json` in this setup, so `VITE_MATRIX_RTC_MODE` sets the default
MatrixRTC mode of the build instead (see
[docs/matrix_rtc_modes.md](docs/matrix_rtc_modes.md)); it can still be changed
under Settings → Developer. Do not delete `dist-embedded` while the backend is
running: the containers hold a mount of the directory, and a new directory of
the same name is not picked up until they are recreated.
> [!NOTE]
> When Docker runs in a VM (colima, Docker Desktop), the SFUs' container IPs
> are not routable from the host, so they advertise `127.0.0.1` as their ICE
> candidate instead and the browser reaches them through the published ports.
> This only works in Chromium-based browsers: Firefox ignores loopback
> candidates, and calls drop about fifteen seconds after joining. Media runs
> over ICE-TCP, since the UDP port ranges are not forwarded out of the VM.
> [!NOTE]
> To ensure your local development frontend functions properly, you’ll need to
> add certificate exceptions in your browser for `https://localhost:3000` and
+1 -1
View File
@@ -5,7 +5,7 @@ sender_localpart: "_lk_jwt_service"
namespaces:
users:
- exclusive: false
regex: "@.*:synapse\\.othersite\\.m\\.localhost" # Cover all local users
regex: "@.*:homeserver-1" # 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.
+1 -1
View File
@@ -5,7 +5,7 @@ sender_localpart: "_lk_jwt_service"
namespaces:
users:
- exclusive: false
regex: "@.*:synapse\\.m\\.localhost" # Cover all local users
regex: "@.*:homeserver" # 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.
-5
View File
@@ -6,11 +6,6 @@ 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
-5
View File
@@ -6,11 +6,6 @@ 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
-57
View File
@@ -1,57 +0,0 @@
/*
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 => (
<TooltipProvider>
<Tooltip label="Microphone">
<button>🎤</button>
</Tooltip>
</TooltipProvider>
);
test("floats into the host document with no root", () => {
render(
<CallPortalRoot root={undefined}>
<LabelledButton />
</CallPortalRoot>,
);
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(
<CallPortalRoot root={root}>
<LabelledButton />
</CallPortalRoot>,
);
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(
<CallPortalRoot root={null}>
<LabelledButton />
</CallPortalRoot>,
);
expect(screen.queryByRole("button", { name: "Microphone" })).toBeNull();
});
-26
View File
@@ -1,26 +0,0 @@
/*
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<Props> = ({ root, children }): ReactNode =>
root === undefined ? (
children
) : (
<PortalRoot root={root}>{children}</PortalRoot>
);
+5 -13
View File
@@ -34,18 +34,10 @@ component rather than a page makes. */
-webkit-tap-highlight-color: transparent;
}
/* The call itself. It is a stacking context of its own, as the standalone
page's `#root` is, so that the layers the call view uses for its header, footer
and overlays stay below the modals and toasts, which are portalled into the
root and carry no layer of their own.
What Compound floats — tooltips, menus — is not in here at all: it goes into
the host's body, as it would for any other component on the host's page, and is
styled and layered by the host's copy of Compound. */
.content {
display: flex;
flex-direction: column;
block-size: 100%;
/* Compound's overlay container, which holds tooltips and popovers, has to fill
the container for the elements inside it to be positioned against it. The
standalone page does the same for the container under `#root`. */
.root > [data-overlay-container] {
position: relative;
isolation: isolate;
block-size: 100%;
}
-138
View File
@@ -1,138 +0,0 @@
/*
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.
*/
/**
* EXPERIMENTAL
*
* What a host needs in order to talk about Element Call, without Element Call:
* the component's props and the types they are made of, the handle and the
* host bridge, the intents a call can be started with and the configuration
* each implies.
*
* This is an entry point of its own, `@element-hq/element-call-component/api`,
* so that a host can import it on a path that must stay light — a call model
* that needs `BackgroundStyle.Solid` as a value, say — and load the component
* itself, and everything it brings, only when a call is shown. The main entry
* point re-exports all of it, so a host that does not care may import
* everything from there.
*
* Nothing in here may reach anything that is not a type or a constant: the
* point of this module is what it does not import.
*/
import type { Ref } from "react";
import type { MatrixClient } from "matrix-js-sdk";
import type { UrlProperties } from "../src/UrlParams";
import type { ElementCallHandle, ElementCallHostBridge } from "./host";
import {
BackgroundStyle,
configurationForIntent,
HeaderStyle,
type UrlConfiguration,
UserIntent,
} from "../src/UrlConfiguration";
// How the host and Element Call talk to each other, and what they say
export type { ElementCallHandle, ElementCallHostBridge } from "./host";
export type { DeviceMuteRequest, DeviceMuteState } from "../src/HostBridge";
export type { JoinCallData } from "../src/widget";
// The deployment-wide configuration, as distinct from ElementCallConfiguration
// below, which is per call
export type { ConfigOptions } from "../src/config/ConfigOptions";
// The values that appear in ElementCallConfiguration and in the intent, and
// what each intent means by default
export {
BackgroundStyle,
configurationForIntent,
HeaderStyle,
type UrlConfiguration,
UserIntent,
};
/**
* How Element Call should behave. Everything is optional; anything left out
* takes the default that {@link ElementCallProps.intent} implies.
*
* This is the behaviour a widget can be configured with through its URL, plus
* the one fact about the call a host has a say in here, the background. The
* rest of what a widget's URL carries — who the user is, how to reach the
* homeserver, where to report analytics, the shared secret of a room that is
* encrypted with one — a component host supplies by other routes, or not at
* all; and what can change while the call is running, the theme and the
* language, is a prop of its own.
*/
export type ElementCallConfiguration = Partial<UrlConfiguration> &
Partial<Pick<UrlProperties, "background">>;
export interface ElementCallProps {
/**
* The client to place the call with. Element Call does not authenticate
* anyone or manage a session of its own; this one is the host's.
*/
client: MatrixClient;
/** The room to call in. The host's client must already know about it. */
roomId: string;
/**
* What the user asked for — whether they started the call or joined one that
* was already running, and whether it is a call in a group or a DM. Element
* Call decides what each of those means: whether to show the lobby first,
* whether to ring, and so on.
*
* Defaults to joining an existing group call, which is the most conservative
* reading, but a host that knows which button the user pressed should say so.
*/
intent?: UserIntent;
/**
* How Element Call should behave, overriding whatever {@link intent} implies.
* A host that finds itself setting a lot of these probably wants a different
* intent instead.
*
* Compared by value, so it is fine to write this inline; only a change to
* what it says restarts anything.
*/
config?: ElementCallConfiguration;
/**
* What Element Call tells the host while the call is running: that the user
* has joined or hung up, that it would like to be kept on screen, and so on.
* Without one, Element Call assumes nobody is listening.
*/
hostBridge?: ElementCallHostBridge;
/**
* What the host tells Element Call: to hang up, to mute, to join. Available
* once the component has rendered.
*/
ref?: Ref<ElementCallHandle>;
/**
* The theme to show Element Call in, `light` or `dark`. Left out, Element
* Call picks. Changes take effect at once, and cost nothing else.
*/
theme?: string;
/**
* The language to show Element Call in, as a BCP 47 tag: one of the
* `supportedLanguages` the main entry point exports, or something that falls
* back to one (`de-AT` to `de`). Left out, the browser's language is used.
*
* Translations are one thing shared by every Element Call on the page, so
* 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;
}
+7 -8
View File
@@ -40,10 +40,10 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`;
* Element Call as a component.
*
* As a page of its own, Element Call can style the document: normalize.css and
* its own base styles speak of `html`, `body` and bare elements, and its custom
* properties are declared on `:root`. As a component, all of that would land on
* the host's document too. This rewrites every selector so that it matches only
* the root or its descendants:
* Compound speak of `html`, `body` and bare elements, and the design tokens are
* declared on `:root`. As a component, all of that would land on the host's
* document too. This rewrites every selector so that it matches only the root
* or its descendants:
*
* - `html`, `body` and `:root` become the root element, which is what stands in
* for the document inside a host.
@@ -55,10 +55,9 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`;
* CSS modules are scoped by their class names already, so only their selectors
* that would match by element alone — `pre` rather than `.pre` — are touched.
*
* The root's custom properties are still inherited by everything inside it,
* the way they were from `:root`. Compound's own stylesheet — the design
* tokens, the component styles, the fonts — is not in this build at all: the
* host supplies it, unscoped, along with its copy of Compound.
* The root's fonts and design tokens are still inherited by everything inside
* it, the way they were from `body` and `:root`, and `@font-face` declarations
* stay global, which they are by nature.
*/
export function scopeStylesToRoot(): Plugin {
return {
-10
View File
@@ -9,16 +9,6 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { logger } from "matrix-js-sdk/lib/logger";
// What a host provides along with its copy of Compound: its stylesheet, the
// design tokens and the fonts they name. The component does not bundle them.
import "@fontsource/inter/400.css";
import "@fontsource/inter/500.css";
import "@fontsource/inter/600.css";
import "@fontsource/inter/700.css";
import "@fontsource/inconsolata/400.css";
import "@fontsource/inconsolata/700.css";
import "@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css";
import "@vector-im/compound-web/dist/style.css";
import { type ConfigOptions, initializeElementCall } from "../index";
import { Harness } from "./Harness";
// After Element Call's, so that the host has the last word on its own page
+127 -51
View File
@@ -17,37 +17,33 @@ Please see LICENSE in the repository root for full details.
* host instead, or is confined to the container it is mounted in.
*/
// The element defaults and custom properties every Element Call stylesheet
// The design tokens, fonts and element defaults every Element Call stylesheet
// builds on. Written for a page, they speak of `html`, `body` and bare
// elements; the component build confines them, and every other stylesheet in
// this bundle, to the root element below (see build/scopeStylesToRoot.ts), so
// that the host's document is left as it was.
//
// Compound is not in here. The component uses the host's copy of it (a peer
// dependency, left external by the build), and the host loads Compound's
// stylesheet, design tokens and fonts along with it: what Compound floats into
// the host's body — tooltips, menus — is then styled by the same stylesheet as
// everything else there, and Element Call's root takes the tokens and fonts of
// the theme class it carries, as any element on the host's page would.
//
// Where these land relative to the component stylesheets is the bundler's
// choice — the standalone app puts them first, this build puts them in the
// middle — so nothing in base.css may depend on winning or losing against a
// component's own rules at equal specificity. It currently does not: what it
// declares unlayered is custom properties on Element Call's root, which
// components inherit rather than compete with.
// components inherit rather than compete with, and everything from Compound
// sits in a `@layer`, which loses to unlayered rules either way.
import "../src/base.css";
import {
type FC,
type JSX,
type ReactNode,
type Ref,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { I18nextProvider } from "react-i18next";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -66,8 +62,10 @@ import { RootElementProvider, useRootElement } from "../src/RootElementContext";
import {
configurationForIntent,
componentProperties,
type UrlConfiguration,
type UrlParams,
UrlParamsProvider,
type UrlProperties,
UserIntent,
useUrlParams,
} from "../src/UrlParams";
@@ -81,17 +79,102 @@ import { i18n } from "../src/utils/i18n";
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 {
type ElementCallHandle,
type ElementCallHostBridge,
useComponentHostBridge,
} from "./host";
import { supportedLanguages, translationsBackend } from "./localization";
// The languages Element Call can be shown in
export { supportedLanguages } from "./localization";
// Everything a host needs to talk about Element Call — the props, the handle,
// the host bridge, the intents and what each means — also available without
// the component itself from `@element-hq/element-call-component/api`
export * from "./api";
// How the host and Element Call talk to each other, and what they say
export { type ElementCallHandle, type ElementCallHostBridge } from "./host";
export {
type DeviceMuteRequest,
type DeviceMuteState,
} from "../src/HostBridge";
export { type JoinCallData } from "../src/widget";
// The deployment-wide configuration, as distinct from ElementCallConfiguration
// above, which is per call
export { type ConfigOptions } from "../src/config/ConfigOptions";
// The values that appear in ElementCallConfiguration and in the intent
export {
BackgroundStyle,
HeaderStyle,
UserIntent,
type UrlConfiguration,
} from "../src/UrlParams";
/**
* How Element Call should behave. Everything is optional; anything left out
* takes the default that {@link ElementCallProps.intent} implies.
*
* This is the behaviour a widget can be configured with through its URL, plus
* the one fact about the call a host has a say in here, the background. The
* rest of what a widget's URL carries — who the user is, how to reach the
* homeserver, where to report analytics, the shared secret of a room that is
* encrypted with one — a component host supplies by other routes, or not at
* all; and what can change while the call is running, the theme and the
* language, is a prop of its own.
*/
export type ElementCallConfiguration = Partial<UrlConfiguration> &
Partial<Pick<UrlProperties, "background">>;
export interface ElementCallProps {
/**
* The client to place the call with. Element Call does not authenticate
* anyone or manage a session of its own; this one is the host's.
*/
client: MatrixClient;
/** The room to call in. The host's client must already know about it. */
roomId: string;
/**
* What the user asked for — whether they started the call or joined one that
* was already running, and whether it is a call in a group or a DM. Element
* Call decides what each of those means: whether to show the lobby first,
* whether to ring, and so on.
*
* Defaults to joining an existing group call, which is the most conservative
* reading, but a host that knows which button the user pressed should say so.
*/
intent?: UserIntent;
/**
* How Element Call should behave, overriding whatever {@link intent} implies.
* A host that finds itself setting a lot of these probably wants a different
* intent instead.
*
* Compared by value, so it is fine to write this inline; only a change to
* what it says restarts anything.
*/
config?: ElementCallConfiguration;
/**
* What Element Call tells the host while the call is running: that the user
* has joined or hung up, that it would like to be kept on screen, and so on.
* Without one, Element Call assumes nobody is listening.
*/
hostBridge?: ElementCallHostBridge;
/**
* What the host tells Element Call: to hang up, to mute, to join. Available
* once the component has rendered.
*/
ref?: Ref<ElementCallHandle>;
/**
* The theme to show Element Call in, `light` or `dark`. Left out, Element
* Call picks. Changes take effect at once, and cost nothing else.
*/
theme?: string;
/**
* The language to show Element Call in, as a BCP 47 tag: one of
* {@link supportedLanguages}, or something that falls back to one (`de-AT`
* to `de`). Left out, the browser's language is used.
*
* Translations are one thing shared by every Element Call on the page, so
* the most recently set language wins for all of them.
*/
language?: string;
}
/**
* Prepares the things Element Call needs before it can be shown: translations,
@@ -155,7 +238,6 @@ export const ElementCall: FC<ElementCallProps> = ({
ref,
theme,
language,
portalRoot,
}): ReactNode => {
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme);
@@ -239,40 +321,34 @@ export const ElementCall: FC<ElementCallProps> = ({
<UrlParamsProvider value={params}>
<div ref={setContainer} className={styles.root}>
{ready && (
// Stands in for the standalone page's `#root`: see `.content`
// in ElementCall.module.css
<div className={styles.content}>
<CallPortalRoot root={portalRoot}>
<RootElementProvider value={container}>
{/* Whatever goes wrong in here is shown in here. Left to
propagate, an error would unmount the host's own tree. */}
<ErrorBoundary
fallback={(error) => <ErrorPage error={error} />}
// A broken call should not hold the host on screen
onError={() => void hostBridge.setAlwaysOnScreen(false)}
>
<Decoration>
<TooltipProvider>
<ClientProvider client={client}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<CallView
client={client}
rtcSession={rtcSession}
isPasswordlessUser={false}
confineToRoom={params.confineToRoom}
preload={params.preload}
skipLobby={params.skipLobby}
/>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
</TooltipProvider>
</Decoration>
</ErrorBoundary>
</RootElementProvider>
</CallPortalRoot>
</div>
<RootElementProvider value={container}>
{/* Whatever goes wrong in here is shown in here. Left to
propagate, an error would unmount the host's own tree. */}
<ErrorBoundary
fallback={(error) => <ErrorPage error={error} />}
// A broken call should not hold the host on screen
onError={() => void hostBridge.setAlwaysOnScreen(false)}
>
<Decoration>
<TooltipProvider>
<ClientProvider client={client}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<CallView
client={client}
rtcSession={rtcSession}
isPasswordlessUser={false}
confineToRoom={params.confineToRoom}
preload={params.preload}
skipLobby={params.skipLobby}
/>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
</TooltipProvider>
</Decoration>
</ErrorBoundary>
</RootElementProvider>
)}
</div>
</UrlParamsProvider>
+1 -6
View File
@@ -25,10 +25,6 @@
"types": "./dist/types/component/index.d.ts",
"default": "./dist/element-call.js"
},
"./api": {
"types": "./dist/types/component/api.d.ts",
"default": "./dist/api.js"
},
"./style.css": "./dist/element-call.css"
},
"sideEffects": [
@@ -38,8 +34,7 @@
"prepare": "cd .. && pnpm install --frozen-lockfile && pnpm build:component"
},
"peerDependencies": {
"@vector-im/compound-design-tokens": "^11.0.0",
"@vector-im/compound-web": "^10.3.1",
"livekit-client": "^2.18.1",
"matrix-js-sdk": "*",
"react": "^19",
"react-dom": "^19"
+3 -3
View File
@@ -13,8 +13,8 @@
"rootDir": "..",
"outDir": "./dist/types"
},
// The entry points, plus the ambient declarations (CSS modules, `?react` SVGs,
// `import.meta.env`, …) that the sources they reach rely on.
"include": ["./index.tsx", "./api.ts", "../src/@types/*.d.ts"],
// The entry point, plus the ambient declarations (CSS modules, `?react` SVGs,
// `import.meta.env`, …) that the sources it reaches rely on.
"include": ["./index.tsx", "../src/@types/*.d.ts"],
"exclude": []
}
+1
View File
@@ -7,6 +7,7 @@
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"network_error_retry_ms": 100
}
+1
View File
@@ -11,6 +11,7 @@
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_mode": "compatibility",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"network_error_retry_ms": 100
}
+1
View File
@@ -7,6 +7,7 @@
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"network_error_retry_ms": 100
},
+1
View File
@@ -7,6 +7,7 @@
},
"ssla": "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
"matrix_rtc_session": {
"wait_for_key_rotation_ms": 3000,
"membership_event_expiry_ms": 180000000,
"network_error_retry_ms": 100
}
+2 -8
View File
@@ -3,7 +3,7 @@ networks:
services:
auth-service:
image: ghcr.io/element-hq/lk-jwt-service:0.8.0
image: ghcr.io/element-hq/lk-jwt-service:0.7.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.8.0
image: ghcr.io/element-hq/lk-jwt-service:0.7.0
pull_policy: always
hostname: auth-server-1
environment:
@@ -157,9 +157,6 @@ services:
pull_policy: always
volumes:
- ./backend/ew.test.config.json:/app/config.json:Z
# Replaces the Element Call widget bundled in the image with the embedded
# build of the current sources. See "Backend" in README.md.
- ./dist-embedded:/app/widgets/element-call:ro,Z
environment:
ELEMENT_WEB_PORT: 8081
ports:
@@ -172,9 +169,6 @@ services:
pull_policy: always
volumes:
- ./backend/ew.test.othersite.config.json:/app/config.json:Z
# Replaces the Element Call widget bundled in the image with the embedded
# build of the current sources. See "Backend" in README.md.
- ./dist-embedded:/app/widgets/element-call:ro,Z
environment:
ELEMENT_WEB_PORT: 18081
ports:
+2 -2
View File
@@ -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:d43e8d26361b546a9ceed13602cc13892ff212df7024b2282147ee8ec370977e
image: ghcr.io/element-hq/element-web:develop@sha256:cfbe7ceb346918257ef07cb25f7b9b03ec0575ecb5ce51dfeeef873e67c60c95
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:d43e8d26361b546a9ceed13602cc13892ff212df7024b2282147ee8ec370977e
image: ghcr.io/element-hq/element-web:develop@sha256:cfbe7ceb346918257ef07cb25f7b9b03ec0575ecb5ce51dfeeef873e67c60c95
+1 -9
View File
@@ -76,12 +76,4 @@ Code that builds in only one is a bug.
- `build:component` — `@element-hq/element-call-component`, sources in `component/`
(its own pnpm project; run pnpm from the repo root). Host API is in the README;
`pnpm lint:externals` rejects an import of a `react` / `react-dom` /
`matrix-js-sdk` / `@vector-im/compound-web` /
`@vector-im/compound-design-tokens` subpath the externals list omits.
`livekit-client` is bundled on purpose: no host imports it, and the pinned
version travels with the component.
Compound's stylesheets are loaded by `src/index.css` for the page and by the
host for the component, never by `src/base.css`. Two entry
points: `index.tsx` (the component) and `api.ts` (types, enums and
`configurationForIntent`, for a host that must not load the component). `api.ts`
may only reach types and constants — `src/UrlConfiguration.ts`, not `UrlParams`.
`matrix-js-sdk` / `livekit-client` subpath the externals list omits.
-15
View File
@@ -60,21 +60,6 @@ git config --local core.hooksPath .githooks
This will add the hook path for this repository only to .gihooks. which is a tracked (by git) folder containing the pre-commit hook.
## Testing against a matrix-js-sdk branch in CI
Linking covers local development. In CI, `scripts/layered.sh` plays the same
role for `matrix-js-sdk`: after the usual frozen install, `scripts/fetchdep.sh`
looks for a matrix-js-sdk branch with the same name as the pull request's head
branch (in the fork first, when the pull request comes from one). If there is
one, it is cloned, packed and installed over the locked version. If there is
none, the install stays as `pnpm-lock.yaml` pins it.
This means a change that needs a not yet merged matrix-js-sdk change can be tested
by giving both branches the same name. This mirrors how element-web tests against
matrix-js-sdk. Once the matrix-js-sdk branch is merged and deleted, the pull
request falls back to the lockfile and needs a lockfile bump to stay green,
which is also what keeps `main` consistent after the merge.
## Background
Information, why this approach is used can be found in the [linking concept reasoning](./linking_concept_reasoning.md) document.
+1 -9
View File
@@ -24,7 +24,7 @@ homeserver does not support sticky events. This is the default.
MSC4354 and all clients on v0.17.0 or later. The local membership requests its
token from the Matrix 2.0 JWT endpoint of the
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
and identifies the room by a hashed identity instead of the Matrix `room_id`.
and identifies the room by a hashed identity instead of a `livekit_alias`.
(Remote memberships always try the new endpoint first and fall back to the
legacy one, so remote participants can be on either.)
@@ -46,11 +46,3 @@ Valid values are `compatibility` and `matrix_2_0`; an invalid value is
ignored (with a warning) and the user's choice applies. Pinning `matrix_2_0` on a
homeserver without sticky event support makes joining fail with a "sticky events
required" error.
A build can change the default mode, for setups without a `config.json` by setting
`VITE_MATRIX_RTC_MODE` at build time:
```sh
VITE_MATRIX_RTC_MODE=matrix_2_0 pnpm build:embedded
```
+1
View File
@@ -61,6 +61,7 @@ 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. |
+3 -3
View File
@@ -59,10 +59,10 @@
"developer_mode": {
"always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms",
"crypto_version": "Crypto version: {{version}}",
"custom_transport": {
"current": "Currently set to: ",
"custom_livekit_url": {
"current_url": "Currently set to: ",
"from_config": "Currently, no overwrite is set. Url from config is used.",
"label": "Custom transport JSON",
"label": "Custom Livekit-url",
"reset": "Reset overwrite",
"save": "Save",
"saving": "Saving..."
+3 -2
View File
@@ -8,7 +8,7 @@
"dev:embedded": "vite --config vite-embedded.config.js",
"dev:component": "vite --config vite-component-dev.config.ts",
"build": "pnpm build:full",
"build:full": "node --max-old-space-size=16384 node_modules/vite/bin/vite.js build",
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
"build:full:production": "pnpm build:full",
"build:full:development": "pnpm build:full --mode development",
"build:embedded": "pnpm build:full --config vite-embedded.config.js",
@@ -84,7 +84,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.4.0",
"@vector-im/compound-web": "^10.0.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "^4.0.18",
@@ -129,6 +129,7 @@
"uuid": "^14.0.0",
"vaul": "^1.0.0",
"vite": "^8.0.0",
"vite-plugin-generate-file": "^0.3.0",
"vite-plugin-html": "^3.2.2",
"vite-plugin-node-polyfills": "^0.28.0",
"vite-plugin-node-stdlib-browser": "^0.2.1",
+2 -14
View File
@@ -38,20 +38,8 @@ 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
*
* 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,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: "html",
/* 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('/')`. */
+1 -1
View File
@@ -129,7 +129,7 @@ async function openDeviceList(page: Page, pane: Locator): Promise<Locator> {
.getByRole("button", { name: "Microphone" })
.click({ timeout: 60_000 });
await expect(page.getByRole("menu")).toBeVisible();
const list = page.getByRole("menu").getByTestId("device-list");
const list = page.locator("[role='menu'] div[role='none']").first();
await expect(list).toBeVisible();
return list;
}
-6
View File
@@ -92,12 +92,6 @@ export async function expectWithin(
outer: Locator,
): Promise<void> {
await expect(inner).toBeVisible();
// Wait for animations to settle.
await inner.evaluate(async (element) => {
for (const animation of element.getAnimations({ subtree: true }))
await animation.finished;
});
const innerBox = await inner.boundingBox();
const outerBox = await outer.boundingBox();
if (innerBox === null || outerBox === null)
+2 -2
View File
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { expect, test } from "@playwright/test";
import { createJWTToken } from "./fixtures/jwt-token";
import { createJTWToken } 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: createJWTToken("@fake:user", "!fake:room"),
jwt: createJTWToken("@fake:user", "!fake:room"),
}),
}),
);
+1 -1
View File
@@ -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 createJWTToken(sub: string, room: string): string {
export function createJTWToken(sub: string, room: string): string {
return [
{}, // header
{
+1 -1
View File
@@ -132,7 +132,7 @@ export const widgetTest = test.extend<MyFixtures>({
await ewPage1
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New", exact: true })
.getByRole("button", { name: "New conversation" })
.click();
await ewPage1.getByRole("menuitem", { name: "Start chat" }).click();
-493
View File
@@ -1,493 +0,0 @@
/*
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<string, Transport | null>;
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<Flow>(["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<Flow>(["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<void> {
await page.route("**/config.json", async (route) => {
const response = await route.fetch();
const config = (await response.json()) as Record<string, unknown>;
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<Flow> {
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<void> {
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<void> {
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 `<user_id>:<device_id>` 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;
}
+4
View File
@@ -37,6 +37,8 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
await TestHelpers.acceptRoomInvite(roomName, timo.page);
await florian.page.pause();
await TestHelpers.openWidgetSetEmbeddedElementCallRtcModeCloseWidget(
florian.page,
rtcMode1,
@@ -72,6 +74,8 @@ modePairs.forEach(([rtcMode1, rtcMode2]) => {
await TestHelpers.expectVisibleVideoCount(frame, 2);
}
// await florian.page.pause();
},
);
});
+1 -1
View File
@@ -223,7 +223,7 @@ export class TestHelpers {
await page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New", exact: true })
.getByRole("button", { name: "New conversation" })
.click();
await page.getByRole("menuitem", { name: "New Room" }).click({
-108
View File
@@ -1,108 +0,0 @@
/*
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<void> {
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<Flow> {
const flows = new Set<Flow>();
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;
}
+94 -59
View File
@@ -132,10 +132,10 @@ importers:
version: 10.3.1(react@19.2.8)
'@vector-im/compound-design-tokens':
specifier: ^11.0.0
version: 11.2.0(@types/react@19.2.18)(react@19.2.8)
version: 11.0.0(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-web':
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)
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.0.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))
@@ -192,10 +192,10 @@ importers:
version: 1.9.2
matrix-js-sdk:
specifier: github:matrix-org/matrix-js-sdk#develop
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4df2f2bfb5826c25f05d9535a8556acecaf2e570
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a
matrix-widget-api:
specifier: ^1.18.0
version: 1.20.0
version: 1.19.0
node-stdlib-browser:
specifier: ^1.3.1
version: 1.3.1
@@ -222,7 +222,7 @@ importers:
version: 10.6.1(postcss@8.5.26)
postcss-selector-parser:
specifier: ^7.1.1
version: 7.1.6
version: 7.1.3
posthog-js:
specifier: 1.408.2
version: 1.408.2
@@ -268,6 +268,9 @@ importers:
vite:
specifier: ^8.0.0
version: 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)
vite-plugin-generate-file:
specifier: ^0.3.0
version: 0.3.1
vite-plugin-html:
specifier: ^3.2.2
version: 3.2.2(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))
@@ -1191,8 +1194,8 @@ packages:
'@types/dom-mediacapture-transform': ^0.1.9
livekit-client: ^1.12.0 || ^2.1.0
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0':
resolution: {integrity: sha512-NtLzNDTEfNASBjNCTGZHuk7N0jrQhS6Zx2CM857rCHZYYh/rpbKGQNrJ7azktm4S1P5Jy3WU3nwJTGohGw26qQ==}
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0':
resolution: {integrity: sha512-E826Hy1rG26LanPjtSsOiVRcVoHfSgPgj2r2Xsb5RPScpaKi9XJADQ0u3dNjRCitZPX4oyLNl1FDd5AfDlmIwQ==}
engines: {node: '>= 18'}
'@mdx-js/react@3.1.1':
@@ -3223,8 +3226,8 @@ packages:
peerDependencies:
react: '>= 16.8.0'
'@vector-im/compound-design-tokens@11.2.0':
resolution: {integrity: sha512-XGwO63ZqFVXdkhCVLm3wa/tpx2Lw8pyaQYskObrLZIUkjs3aUmgq8CNhGcjkqRJXYGsLNJE2azov4DaM74h2PQ==}
'@vector-im/compound-design-tokens@11.0.0':
resolution: {integrity: sha512-pWz0Q67Xbo8gwzaTeFbTTcM6i29ju6PR7SqyuExr2nIV3j9K785cOInft8FA2CM7PEapkr6TRtJKR/4xtWh2RA==}
peerDependencies:
'@adobe/leonardo-contrast-colors': ^1.0.0
'@types/react': '*'
@@ -3240,13 +3243,13 @@ packages:
react:
optional: true
'@vector-im/compound-web@10.4.0':
resolution: {integrity: sha512-Tt5hsNK7ulhGdRuy9lzSm5C+T8M/4O4kLDFLkaU+Hi1kVWj6tj/5awW+U+GvtpWRh5llbHN2HRCh79+gQhek+A==}
'@vector-im/compound-web@10.1.0':
resolution: {integrity: sha512-Lhw9Q+RZo1GtDYjF5PXWTD0zdfbrz/vnSVBYFHC8Hq+YeDule2QfGbTRo8IVHeV7jK6ZzsLc8zb6553eeIRLYg==}
peerDependencies:
'@fontsource/inconsolata': ^5
'@fontsource/inter': ^5
'@types/react': '*'
'@vector-im/compound-design-tokens': '>=1.6.1 <12.0.0'
'@vector-im/compound-design-tokens': '>=1.6.1 <11.0.0'
react: ^18 || ^19.0.0
peerDependenciesMeta:
'@types/react':
@@ -3656,8 +3659,8 @@ packages:
constants-browserify@1.0.0:
resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==}
content-type@3.1.1:
resolution: {integrity: sha512-GW4qUsfFo59d0HbUibDlWv5wPz+vAAcaTWbKIuKCf0JkC7wWkSyf8f13IpXn5JkeMlB2P8iTSSIjwXljorg2vA==}
content-type@3.0.0:
resolution: {integrity: sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw==}
engines: {node: '>=22'}
convert-source-map@2.0.0:
@@ -4462,6 +4465,10 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@4.3.0:
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
hasBin: true
js-yaml@4.3.1:
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
hasBin: true
@@ -4673,13 +4680,13 @@ packages:
matrix-events-sdk@0.0.1:
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4df2f2bfb5826c25f05d9535a8556acecaf2e570:
resolution: {gitHosted: true, integrity: sha512-b1o6ywaPY9jakj+3GIgx3samM9AbqohzPQ5nZKbyOGYB16IHQtNMyjEo/PY0fW/s0uwBVApEHNb/MMtN40c/rQ==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4df2f2bfb5826c25f05d9535a8556acecaf2e570}
version: 43.0.0
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a:
resolution: {gitHosted: true, integrity: sha512-0EGwbXzvG88sBOxq4ZlCRwT4VpQj2xkizfyy0A3bTSK0ysb6/gXJ/o1WPFoqCjRGMDgPq4gAsk92xGtHA7EhPg==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a}
version: 42.2.0
engines: {node: '>=22.0.0'}
matrix-widget-api@1.20.0:
resolution: {integrity: sha512-qwFP37rgns7mPesM5/vvZgknzb+kvyRB5BISv5sCuBxsGMceMr+Kogf7OkjRxBXdMCPBFuf6gIuYRe5S+lpzQw==}
matrix-widget-api@1.19.0:
resolution: {integrity: sha512-Qe7L6G1mQJphBC47/Bzp5ljPIK1UARgLiuGgS+/KmEs/eaNHkQ3+ArD0/0kvMV+d6kCGAZ9OKRqsnuPjizI+tw==}
md5.js@1.3.5:
resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==}
@@ -4696,6 +4703,14 @@ packages:
resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==}
hasBin: true
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mimic-function@5.0.1:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
@@ -4880,8 +4895,8 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-retry@8.0.1:
resolution: {integrity: sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==}
p-retry@8.0.0:
resolution: {integrity: sha512-kFVqH1HxOHp8LupNsOys7bSV09VYTRLxarH/mokO4Rqhk6wGi70E0jh4VzvVGXfEVNggHoHLAMWsQqHyU1Ey9A==}
engines: {node: '>=22'}
p-try@2.2.0:
@@ -5160,8 +5175,8 @@ packages:
peerDependencies:
postcss: ^8.4
postcss-selector-parser@7.1.6:
resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==}
postcss-selector-parser@7.1.3:
resolution: {integrity: sha512-ajnd7iZnqjJDkyHNfznl/ZVO0lWqvBmQXfKKENx9/p/bEiF/L3eHwdydNUg9RXZx6xfZWOCmXmBa5oeB+YrAPQ==}
engines: {node: '>=4'}
postcss-value-parser@4.2.0:
@@ -5889,6 +5904,9 @@ packages:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
vite-plugin-generate-file@0.3.1:
resolution: {integrity: sha512-tiA3gkPM21MS2+RyqsBMT33GSlM9LM1TJjf6vGvV/e/ml3e3vTKfuH3l2N0NpUgcayvj1fXnmlo5YBuahA6bsg==}
vite-plugin-html@3.2.2:
resolution: {integrity: sha512-vb9C9kcdzcIo/Oc3CLZVS03dL5pDlOFuhGlZYDCJ840BhWl/0nGeZWf3Qy7NlOayscY4Cm/QRgULCQkEZige5Q==}
peerDependencies:
@@ -6397,9 +6415,9 @@ snapshots:
'@csstools/postcss-cascade-layers@5.0.2(postcss@8.5.26)':
dependencies:
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
postcss: 8.5.26
postcss-selector-parser: 7.1.6
postcss-selector-parser: 7.1.3
'@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.26)':
dependencies:
@@ -6505,9 +6523,9 @@ snapshots:
'@csstools/postcss-is-pseudo-class@5.0.3(postcss@8.5.26)':
dependencies:
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
postcss: 8.5.26
postcss-selector-parser: 7.1.6
postcss-selector-parser: 7.1.3
'@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.26)':
dependencies:
@@ -6609,7 +6627,7 @@ snapshots:
'@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.26)':
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.6
postcss-selector-parser: 7.1.3
'@csstools/postcss-sign-functions@1.1.4(postcss@8.5.26)':
dependencies:
@@ -6653,13 +6671,13 @@ snapshots:
dependencies:
postcss: 8.5.26
'@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.6)':
'@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.3)':
dependencies:
postcss-selector-parser: 7.1.6
postcss-selector-parser: 7.1.3
'@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.6)':
'@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.3)':
dependencies:
postcss-selector-parser: 7.1.6
postcss-selector-parser: 7.1.3
'@csstools/utilities@2.0.0(postcss@8.5.26)':
dependencies:
@@ -7083,7 +7101,7 @@ snapshots:
'@types/dom-mediacapture-transform': 0.1.11
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
'@matrix-org/matrix-sdk-crypto-wasm@18.9.0': {}
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0': {}
'@mdx-js/react@3.1.1(@types/react@19.2.18)(react@19.2.8)':
dependencies:
@@ -8667,12 +8685,12 @@ snapshots:
'@use-gesture/core': 10.3.1
react: 19.2.8
'@vector-im/compound-design-tokens@11.2.0(@types/react@19.2.18)(react@19.2.8)':
'@vector-im/compound-design-tokens@11.0.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.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)':
'@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.0.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 +8702,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.2.0(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-design-tokens': 11.0.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)
@@ -9145,7 +9163,7 @@ snapshots:
constants-browserify@1.0.0: {}
content-type@3.1.1: {}
content-type@3.0.0: {}
convert-source-map@2.0.0: {}
@@ -9216,13 +9234,13 @@ snapshots:
css-blank-pseudo@7.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.6
postcss-selector-parser: 7.1.3
css-has-pseudo@7.0.3(postcss@8.5.26):
dependencies:
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
postcss: 8.5.26
postcss-selector-parser: 7.1.6
postcss-selector-parser: 7.1.3
postcss-value-parser: 4.2.0
css-prefers-color-scheme@10.0.0(postcss@8.5.26):
@@ -10016,6 +10034,10 @@ snapshots:
js-tokens@4.0.0: {}
js-yaml@4.3.0:
dependencies:
argparse: 2.0.1
js-yaml@4.3.1:
dependencies:
argparse: 2.0.1
@@ -10227,21 +10249,21 @@ snapshots:
matrix-events-sdk@0.0.1: {}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/4df2f2bfb5826c25f05d9535a8556acecaf2e570:
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a:
dependencies:
'@babel/runtime': 8.0.0
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
'@matrix-org/matrix-sdk-crypto-wasm': 18.5.0
another-json: 0.2.0
bs58: 6.0.0
content-type: 3.1.1
content-type: 3.0.0
loglevel: 1.9.2
matrix-events-sdk: 0.0.1
matrix-widget-api: 1.20.0
p-retry: 8.0.1
matrix-widget-api: 1.19.0
p-retry: 8.0.0
sdp-transform: 3.0.0
unhomoglyph: 1.0.6
matrix-widget-api@1.20.0:
matrix-widget-api@1.19.0:
dependencies:
'@types/events': 3.0.3
events: 3.3.0
@@ -10264,6 +10286,12 @@ snapshots:
bn.js: 4.12.3
brorand: 1.1.0
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mimic-function@5.0.1: {}
min-indent@1.0.1: {}
@@ -10556,7 +10584,7 @@ snapshots:
dependencies:
p-limit: 3.1.0
p-retry@8.0.1:
p-retry@8.0.0:
dependencies:
is-network-error: 1.3.2
@@ -10666,7 +10694,7 @@ snapshots:
postcss-attribute-case-insensitive@7.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.6
postcss-selector-parser: 7.1.3
postcss-clamp@4.1.0(postcss@8.5.26):
dependencies:
@@ -10717,12 +10745,12 @@ snapshots:
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
postcss: 8.5.26
postcss-selector-parser: 7.1.6
postcss-selector-parser: 7.1.3
postcss-dir-pseudo-class@9.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.6
postcss-selector-parser: 7.1.3
postcss-double-position-gradients@6.0.4(postcss@8.5.26):
dependencies:
@@ -10734,12 +10762,12 @@ snapshots:
postcss-focus-visible@10.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.6
postcss-selector-parser: 7.1.3
postcss-focus-within@9.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.6
postcss-selector-parser: 7.1.3
postcss-font-variant@5.0.0(postcss@8.5.26):
dependencies:
@@ -10771,10 +10799,10 @@ snapshots:
postcss-nesting@13.0.2(postcss@8.5.26):
dependencies:
'@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.6)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.6)
'@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.3)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
postcss: 8.5.26
postcss-selector-parser: 7.1.6
postcss-selector-parser: 7.1.3
postcss-opacity-percentage@3.0.0(postcss@8.5.26):
dependencies:
@@ -10872,7 +10900,7 @@ snapshots:
postcss-pseudo-class-any-link@10.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.6
postcss-selector-parser: 7.1.3
postcss-replace-overflow-wrap@4.0.0(postcss@8.5.26):
dependencies:
@@ -10881,9 +10909,9 @@ snapshots:
postcss-selector-not@8.0.1(postcss@8.5.26):
dependencies:
postcss: 8.5.26
postcss-selector-parser: 7.1.6
postcss-selector-parser: 7.1.3
postcss-selector-parser@7.1.6:
postcss-selector-parser@7.1.3:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
@@ -11624,6 +11652,13 @@ snapshots:
- '@types/react'
- '@types/react-dom'
vite-plugin-generate-file@0.3.1:
dependencies:
ejs: 3.1.10
js-yaml: 4.3.0
mime-types: 2.1.35
picocolors: 1.1.1
vite-plugin-html@3.2.2(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)):
dependencies:
'@rollup/pluginutils': 4.2.1
+4 -11
View File
@@ -9,11 +9,9 @@ Please see LICENSE in the repository root for full details.
* Checks that the component build leaves the packages a host must supply to
* the host.
*
* A host application already has React, the Matrix SDK and Compound,
* and a second copy of any of them is worse than dead weight: React would hold
* two sets of hooks, the Matrix client would run two sync loops, and a second
* Compound would style the tooltips it floats into the host's body with class
* names the host's stylesheet does not know. So the
* A host application already has React, the Matrix SDK and LiveKit, and a
* second copy of any of them is worse than dead weight: React would hold two
* sets of hooks, and the Matrix client would run two sync loops. So the
* component build lists them as external — but that list has to name every
* subpath, since the bundler silently ignores the pattern and callback forms
* of the option, and an import it does not cover is bundled with no warning at
@@ -35,17 +33,13 @@ import { loadConfigFromFile } from "vite";
const CONFIG = "vite-component.config.ts";
const SOURCES = ["src", "component"];
// The development harness is a host, not part of the component: it is the
// one that imports what a host supplies (Compound's stylesheets, say).
const EXCLUDED = ["component/dev"];
/** The packages whose duplication would break a host, rather than merely enlarge it. */
const MUST_BE_EXTERNAL = [
"react",
"react-dom",
"matrix-js-sdk",
"@vector-im/compound-web",
"@vector-im/compound-design-tokens",
"livekit-client",
];
const isTestFile = (name) =>
@@ -55,7 +49,6 @@ const isTestFile = (name) =>
async function* sourceFiles(dir) {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (EXCLUDED.includes(path)) continue;
if (entry.isDirectory()) yield* sourceFiles(path);
else if (/\.(ts|tsx)$/.test(entry.name) && !isTestFile(entry.name))
yield path;
-73
View File
@@ -1,73 +0,0 @@
#!/usr/bin/env bash
# Clones the branch of a dependency's repository that has the same name as
# the pull request branch under test, so a change spanning both repositories
# can be tested before either side is merged. Inspired by scripts/fetchdep.sh
# element-web.
#
# Usage: scripts/fetchdep.sh <org> <repo> [ref]
#
# The clone lands in ./<repo>. When the pull request comes from a fork and
# PR_HEAD_REPO_OWNER was set, the fork's own copy of the repository is tried
# before <org>'s. Exits 0 when a branch was cloned and 1 when there is none,
# so the caller can keep the locked version in that case. Without a ref,
# nothing is closed outside a pull request.
set -eu
org=$1
repo=$2
ref=${3:-}
rm -rf "$repo"
# If a ref was passed, try to clone it or die.
if [ -n "$ref" ]; then
echo "fetchdep.sh: Using $org/$repo at $ref"
git clone --depth 1 "https://github.com/$org/$repo.git" "$repo"
git -C "$repo" fetch --depth 1 origin "$ref"
git -C "$repo" -c advice.detachedHead=false checkout FETCH_HEAD
git -C "$repo" log -1
exit 0
fi
# Otherwise ensure that we're on a pull request.
head_ref=${GITHUB_HEAD_REF:-}
if [ -z "$head_ref" ]; then
echo "fetchdep.sh: Not a pull request, nothing to match"
exit 1
fi
# Bots name their branches the same way in every repository, so a match on
# one of theirs would be a coincidence rather than a paired change.
case "$head_ref" in
renovate/* | dependabot/*)
echo "fetchdep.sh: Not matching bot branch $head_ref"
exit 1
;;
esac
# Clones a branch if it exists and ends the script on success.
clone() {
local try_org=$1
local branch=$2
echo "fetchdep.sh: Trying to use $try_org/$repo#$branch"
# Disable auth prompts: https://serverfault.com/a/665959
if GIT_TERMINAL_PROMPT=0 git clone "https://github.com/$try_org/$repo.git" "$repo" --branch "$branch" --depth 1; then
git -C "$repo" log -1
# Surface the match on the workflow run summary.
echo "::notice title=Matched $repo branch::Using $try_org/$repo#$branch at $(git -C "$repo" rev-parse HEAD)"
exit 0
fi
}
# Try cloning from the fork, then from the org.
head_owner=${PR_HEAD_REPO_OWNER:-}
if [ -n "$head_owner" ] && [ "$head_owner" != "${GITHUB_REPOSITORY_OWNER:-}" ]; then
clone "$head_owner" "$head_ref"
fi
clone "$org" "$head_ref"
# Nothing worked. Die.
echo "fetchdep.sh: No branch named $head_ref in $org/$repo"
exit 1
-62
View File
@@ -1,62 +0,0 @@
#!/usr/bin/env bash
# Installs dependencies, then swaps matrix-js-sdk for a branch of the same
# name as the pull request branch under test if one exists. This is what
# lets CI test an Element Call branch together with a matrix-js-sdk branch
# before either is merged. Inspired by scripts/layered.sh in element-web.
#
# Usage: scripts/layered.sh [pnpm install flags]
#
# The layering is only performed when package.json uses the develop branch of
# matrix-js-sdk and the run is for a pull request whose branch has a matrix-js-sdk
# counterpart. Otherwise the dependency is left as pnpm-lock.yaml pins it.
#
# The matrix-js-sdk checkout is nested inside this directory because some CI
# systems do not allow moving above the primary checkout. It is git-ignored.
#
# Set JS_SDK_GITHUB_BASE_REF to a ref to use that exact ref of matrix-js-sdk
# instead of looking for a matching branch.
#
# This script is only meant to be used in CI. For local development use
# see docs/linking.md.
set -ex
# Paths below are relative to the repository root.
cd "$(dirname "$0")/.."
pnpm install --frozen-lockfile "$@"
js_sdk_dep=$(jq -r '.devDependencies["matrix-js-sdk"]' < package.json)
# Only layer when package.json follows the branch head rather than pinning a
# release, so a release build keeps its pinned version.
if [ "$js_sdk_dep" != "github:matrix-org/matrix-js-sdk#develop" ]; then
echo "layered.sh: Skipping matrix-js-sdk fetch and install as package.json pins $js_sdk_dep"
exit 0
fi
if ! scripts/fetchdep.sh matrix-org matrix-js-sdk "${JS_SDK_GITHUB_BASE_REF:-}"; then
echo "layered.sh: No matching matrix-js-sdk branch, keeping the locked version"
exit 0
fi
# matrix-js-sdk pins its pnpm version in `devEngines` only. Under corepack,
# pnpm does not switch versions on its own and corepack, finding no
# `packageManager` field there, would walk up to this project's and use
# ours. Pinning it in the clone makes every pnpm invocation inside that
# directory, including the `prepare` script run by pack, use the right one.
js_sdk_pnpm="pnpm@$(jq -r '.devEngines.packageManager.version' < matrix-js-sdk/package.json)"
jq --arg pm "$js_sdk_pnpm" '.packageManager = $pm' matrix-js-sdk/package.json > matrix-js-sdk/package.json.tmp
mv matrix-js-sdk/package.json.tmp matrix-js-sdk/package.json
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
# Install matrix-js-sdk's build dependencies. `prepare` runs on pack below,
# so it is skipped here.
(cd matrix-js-sdk && pnpm install --frozen-lockfile --ignore-scripts)
# Rather than `pnpm link`, build matrix-js-sdk into a tarball and install
# that. This better reflects a clean install: dependencies may be hoisted
# and shared, and there is no tsconfig.json to pick up sources from.
(cd matrix-js-sdk && pnpm pack --out matrix-js-sdk.tgz)
pnpm install "$(pwd)/matrix-js-sdk/matrix-js-sdk.tgz" "$@"
+3 -2
View File
@@ -26,7 +26,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&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&perParticipantE2EE=true&userId=$matrix_user_id&deviceId=$org.matrix.msc3819.matrix_device_id&baseUrl=$org.matrix.msc4039.matrix_base_url&roomId=$matrix_room_id
```
## Widgets
@@ -42,6 +42,7 @@ 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
@@ -52,7 +53,7 @@ baseUrl = $org.matrix.msc4039.matrix_base_url
Full template use as `<widgetUrl>`:
```
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
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
```
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`)
+1 -1
View File
@@ -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.transport);
logger.info("unregistering room item from room", item.url);
item.livekitRoom.unregisterTextStreamHandler(TEXT_LK_TOPIC);
}
},
-29
View File
@@ -11,7 +11,6 @@ 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(() => {
@@ -71,31 +70,3 @@ 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 (
<RootElementProvider value={otherBody}>
<Modal title="My modal" open={isOpen} onDismiss={() => setOpen(false)}>
<p>This is the content.</p>
</Modal>
</RootElementProvider>
);
}
render(<ModalFn />);
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();
});
-6
View File
@@ -160,12 +160,6 @@ export const Modal: FC<Props> = ({
<DialogPortal container={rootElement}>
<DialogOverlay
className={classNames(overlayStyles.bg, overlayStyles.animate)}
// Radix dismisses on a pointer down outside the dialog, but only
// for event targets that are a `Node` of the window it was loaded
// in. A host can move the dialog into another window (a Document
// Picture-in-Picture window), whose nodes fail that check, so the
// backdrop closes the dialog itself.
onClick={() => onDismiss?.()}
/>
<DialogContent
asChild
+16 -7
View File
@@ -16,21 +16,30 @@ import classNames from "classnames";
import { Modal } from "./Modal";
import styles from "./RTCConnectionStats.module.css";
import mediaViewStyles from "../src/tile/MediaView.module.css";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
interface Props {
audio?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
video?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
transport?: UnstableLivekitTransport;
focusUrl?: string;
rtcBackendIdentity?: string;
}
const extractDomain = (url: string): string => {
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<Props> = ({
audio,
video,
transport,
focusUrl,
rtcBackendIdentity,
...rest
}) => {
const [showModal, setShowModal] = useState(false);
const [modalContents, setModalContents] = useState<
@@ -67,10 +76,10 @@ export const RTCConnectionStats: FC<Props> = ({
<Text as="span" size="xs" title="rtcBackendIdentity">
rtcBackendIdentity:{rtcBackendIdentity}
</Text>
{transport && (
{focusUrl && (
<div>
<Text as="span" size="xs" title="transport">
&nbsp;{JSON.stringify(transport)}
<Text as="span" size="xs" title="focusURL">
&nbsp;{extractDomain(focusUrl)}
</Text>
</div>
)}
-235
View File
@@ -1,235 +0,0 @@
/*
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.
*/
/**
* How Element Call can be told to behave, and what each intent means.
*
* Kept apart from {@link UrlParams} on purpose: everything here is constants,
* types and one function of them, so that a host of the component can import
* these — the enums it needs as values, the defaults an intent implies — without
* loading Element Call itself. UrlParams, which reads them from a URL, needs
* the router, the config and the rest of the app; this module must not.
* `component/api.ts` is built from it as an entry point of its own.
*/
import {
type RTCCallIntent,
type RTCNotificationType,
} from "matrix-js-sdk/lib/matrixrtc";
import { platform } from "./Platform";
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",
JoinExistingCallDMVoice = "join_existing_dm_voice",
Unknown = "unknown",
}
export enum HeaderStyle {
None = "none",
Standard = "standard",
AppBar = "app_bar",
}
export enum BackgroundStyle {
Solid = "solid",
Gradient = "gradient",
}
/**
* The configuration for the app, which can be set via URL parameters.
* Those property are different to the UrlProperties, since they are all optional
* and configure the behavior of the app. Their value is the same if EC is used in
* the same context but with different accounts/users.
*
* Their defaults can be controlled by the `intent` property.
*/
export interface UrlConfiguration {
/**
* Whether the app should keep the user confined to the current call/room.
*/
confineToRoom: boolean;
/**
* Whether the app should pause before joining the call until it sees an
* io.element.join widget action, allowing it to be preloaded.
*/
preload: boolean;
/**
* 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.
*/
header: HeaderStyle;
/**
* Whether the controls should be shown. For screen recording no controls can be desired.
*/
showControls: boolean;
/**
* Whether to hide the screen-sharing button.
*/
hideScreensharing: boolean;
/**
* Whether the app is allowed to use fallback STUN servers for ICE in case the
* user's homeserver doesn't provide any.
*/
allowIceFallback: boolean;
/**
* Whether the global JS controls for audio output devices should be enabled,
* allowing the list of output devices to be controlled by the app hosting
* Element Call.
*/
controlledAudioDevices: boolean;
/**
* Setting this flag skips the lobby and brings you in the call directly.
* In the widget this can be combined with preload to pass the device settings
* with the join widget action.
*/
skipLobby: boolean;
/**
* Setting this flag makes element call show the lobby after leaving a call.
* This is useful for video rooms.
*/
returnToLobby: boolean;
/**
* Whether and what type of notification EC should send, when the user joins the call.
*/
sendNotificationType?: RTCNotificationType;
/**
* Whether the app should automatically leave the call when there
* is no one left in the call.
* This is one part to make the call matrixRTC session behave like a telephone call.
*/
autoLeaveWhenOthersLeft: boolean;
/**
* If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up).
* This is a no-op if not combined with sendNotificationType.
*
* This entails:
* - show ui that it is awaiting an answer
* - play a sound that indicates that it is awaiting an answer
* - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
*/
waitForCallPickup: boolean;
/**
* Whether to enable echo cancellation for audio capture.
* Defaults to true.
*/
echoCancellation?: boolean;
/**
* Whether to enable noise suppression for audio capture.
* Defaults to true.
*/
noiseSuppression?: boolean;
callIntent?: RTCCallIntent;
}
// 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.
/**
* The configuration implied by what the user meant to do — if they pressed a
* Start Call button this would be `start_call`, and if they pressed Join Call,
* `join_existing`.
*
* These are platform-specific defaults, so that a host can start a call by
* saying what the user asked for rather than by setting every parameter itself,
* and so that what each intent means is Element Call's decision, made in one
* place. A host that wants something else states it alongside the intent.
*
* {@link UserIntent.Unknown} means no intent was stated, and gives the
* standalone app's defaults: Element Call owns the whole page, so it offers the
* way out of the room that a hosted call must not.
*/
export function configurationForIntent(intent: UserIntent): UrlConfiguration {
// Only constants and `platform` here, so that this depends on nothing but
// the intent.
let preset: UrlConfiguration = {
confineToRoom: true,
preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
allowIceFallback: true,
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true,
returnToLobby: false,
sendNotificationType: "notification",
autoLeaveWhenOthersLeft: false,
waitForCallPickup: false,
};
switch (intent) {
case UserIntent.StartNewCall:
preset.skipLobby = false;
preset.callIntent = "video";
break;
case UserIntent.JoinExistingCall:
// On desktop this will be overridden based on which button was used to join the call
preset.skipLobby = false;
preset.callIntent = "video";
break;
case UserIntent.StartNewCallVoice:
preset.skipLobby = false;
preset.callIntent = "audio";
break;
case UserIntent.JoinExistingCallVoice:
// On desktop this will be overridden based on which button was used to join the call
preset.skipLobby = false;
preset.callIntent = "audio";
break;
case UserIntent.StartNewCallDMVoice:
preset.callIntent = "audio";
// Fall through
case UserIntent.StartNewCallDM:
preset.skipLobby = true;
preset.sendNotificationType = "ring";
preset.autoLeaveWhenOthersLeft = true;
preset.waitForCallPickup = true;
preset.callIntent = preset.callIntent ?? "video";
break;
case UserIntent.JoinExistingCallDMVoice:
preset.callIntent = "audio";
// Fall through
case UserIntent.JoinExistingCallDM:
// On desktop this will be overridden based on which button was used to join the call
preset.skipLobby = true;
preset.autoLeaveWhenOthersLeft = true;
preset.callIntent = preset.callIntent ?? "video";
break;
// Non widget usecase defaults
default:
preset = {
confineToRoom: false,
preload: false,
header: HeaderStyle.Standard,
showControls: true,
hideScreensharing: false,
allowIceFallback: false,
controlledAudioDevices: false,
skipLobby: false,
returnToLobby: false,
sendNotificationType: undefined,
autoLeaveWhenOthersLeft: false,
waitForCallPickup: false,
};
}
return preset;
}
+5
View File
@@ -230,6 +230,7 @@ describe("UrlParams", () => {
showControls: true,
hideScreensharing: false,
allowIceFallback: false,
perParticipantE2EE: false,
controlledAudioDevices: false,
skipLobby: false,
returnToLobby: false,
@@ -242,6 +243,7 @@ describe("UrlParams", () => {
showControls: true,
hideScreensharing: false,
allowIceFallback: true,
perParticipantE2EE: true,
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true,
returnToLobby: false,
@@ -254,6 +256,7 @@ describe("UrlParams", () => {
showControls: true,
hideScreensharing: false,
allowIceFallback: true,
perParticipantE2EE: true,
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: false,
returnToLobby: false,
@@ -496,6 +499,7 @@ describe("UrlParams", () => {
// A host owns navigation, so Element Call must not offer a way out of
// the room
confineToRoom: true,
perParticipantE2EE: true,
// The lobby first, so that the user picks their devices rather than
// being thrown into the call by the act of being rendered
skipLobby: false,
@@ -508,6 +512,7 @@ describe("UrlParams", () => {
expect(configurationForIntent(UserIntent.Unknown)).toMatchObject({
confineToRoom: false,
header: HeaderStyle.Standard,
perParticipantE2EE: false,
});
});
});
+237 -25
View File
@@ -9,36 +9,47 @@ Please see LICENSE in the repository root for full details.
import { createContext, use, useMemo } from "react";
import { useLocation } from "react-router-dom";
import { logger } from "matrix-js-sdk/lib/logger";
import {
type RTCCallIntent,
type RTCNotificationType,
} from "matrix-js-sdk/lib/matrixrtc";
import { pickBy } from "lodash-es";
import { Config } from "./config/Config";
import { type EncryptionSystem } from "./e2ee/sharedKeyManagement";
import { E2eeType } from "./e2ee/e2eeType";
import {
BackgroundStyle,
configurationForIntent,
HeaderStyle,
type UrlConfiguration,
UserIntent,
} from "./UrlConfiguration";
import { platform } from "./Platform";
import { redact } from "./utils/redact";
// Moved to their own module, so that a host can import them without the rest
// of this one; re-exported here as they always were
export {
BackgroundStyle,
configurationForIntent,
HeaderStyle,
type UrlConfiguration,
UserIntent,
} from "./UrlConfiguration";
interface RoomIdentifier {
roomAlias: string | null;
roomId: string | null;
viaServers: string[];
}
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",
JoinExistingCallDMVoice = "join_existing_dm_voice",
Unknown = "unknown",
}
export enum HeaderStyle {
None = "none",
Standard = "standard",
AppBar = "app_bar",
}
export enum BackgroundStyle {
Solid = "solid",
Gradient = "gradient",
}
/**
* The UrlProperties are used to pass required data to the widget.
* Those are different in different rooms, users, devices. They do not configure the behavior of the
@@ -155,6 +166,109 @@ export interface UrlProperties {
*/
background: BackgroundStyle;
}
/**
* The configuration for the app, which can be set via URL parameters.
* Those property are different to the UrlProperties, since they are all optional
* and configure the behavior of the app. Their value is the same if EC is used in
* the same context but with different accounts/users.
*
* Their defaults can be controlled by the `intent` property.
*/
export interface UrlConfiguration {
/**
* Whether the app should keep the user confined to the current call/room.
*/
confineToRoom: boolean;
/**
* Whether the app should pause before joining the call until it sees an
* io.element.join widget action, allowing it to be preloaded.
*/
preload: boolean;
/**
* 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.
*/
header: HeaderStyle;
/**
* Whether the controls should be shown. For screen recording no controls can be desired.
*/
showControls: boolean;
/**
* Whether to hide the screen-sharing button.
*/
hideScreensharing: boolean;
/**
* Whether the app is allowed to use fallback STUN servers for ICE in case the
* user's homeserver doesn't provide any.
*/
allowIceFallback: boolean;
/**
* Whether the app should use per participant keys for E2EE.
*/
perParticipantE2EE: boolean;
/**
* Whether the global JS controls for audio output devices should be enabled,
* allowing the list of output devices to be controlled by the app hosting
* Element Call.
*/
controlledAudioDevices: boolean;
/**
* Setting this flag skips the lobby and brings you in the call directly.
* In the widget this can be combined with preload to pass the device settings
* with the join widget action.
*/
skipLobby: boolean;
/**
* Setting this flag makes element call show the lobby after leaving a call.
* This is useful for video rooms.
*/
returnToLobby: boolean;
/**
* Whether and what type of notification EC should send, when the user joins the call.
*/
sendNotificationType?: RTCNotificationType;
/**
* Whether the app should automatically leave the call when there
* is no one left in the call.
* This is one part to make the call matrixRTC session behave like a telephone call.
*/
autoLeaveWhenOthersLeft: boolean;
/**
* If the client should behave like it is awaiting an answer if a notification was sent (wait for call pick up).
* This is a no-op if not combined with sendNotificationType.
*
* This entails:
* - show ui that it is awaiting an answer
* - play a sound that indicates that it is awaiting an answer
* - auto-dismiss the call widget once the notification lifetime expires on the receivers side.
*/
waitForCallPickup: boolean;
/**
* Whether to enable echo cancellation for audio capture.
* Defaults to true.
*/
echoCancellation?: boolean;
/**
* Whether to enable noise suppression for audio capture.
* Defaults to true.
*/
noiseSuppression?: boolean;
callIntent?: RTCCallIntent;
}
// 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 {}
class ParamParser {
@@ -240,6 +354,97 @@ export const getUrlParams = (
return params;
};
/**
* The configuration implied by what the user meant to do — if they pressed a
* Start Call button this would be `start_call`, and if they pressed Join Call,
* `join_existing`.
*
* These are platform-specific defaults, so that a host can start a call by
* saying what the user asked for rather than by setting every parameter itself,
* and so that what each intent means is Element Call's decision, made in one
* place. A host that wants something else states it alongside the intent.
*
* {@link UserIntent.Unknown} means no intent was stated, and gives the
* standalone app's defaults: Element Call owns the whole page, so it offers the
* way out of the room that a hosted call must not.
*/
export function configurationForIntent(intent: UserIntent): UrlConfiguration {
// Only constants and `platform` here, so that this depends on nothing but
// the intent.
let preset: UrlConfiguration = {
confineToRoom: true,
preload: false,
header: platform === "desktop" ? HeaderStyle.None : HeaderStyle.AppBar,
showControls: true,
hideScreensharing: false,
allowIceFallback: true,
perParticipantE2EE: true,
controlledAudioDevices: platform === "desktop" ? false : true,
skipLobby: true,
returnToLobby: false,
sendNotificationType: "notification",
autoLeaveWhenOthersLeft: false,
waitForCallPickup: false,
};
switch (intent) {
case UserIntent.StartNewCall:
preset.skipLobby = false;
preset.callIntent = "video";
break;
case UserIntent.JoinExistingCall:
// On desktop this will be overridden based on which button was used to join the call
preset.skipLobby = false;
preset.callIntent = "video";
break;
case UserIntent.StartNewCallVoice:
preset.skipLobby = false;
preset.callIntent = "audio";
break;
case UserIntent.JoinExistingCallVoice:
// On desktop this will be overridden based on which button was used to join the call
preset.skipLobby = false;
preset.callIntent = "audio";
break;
case UserIntent.StartNewCallDMVoice:
preset.callIntent = "audio";
// Fall through
case UserIntent.StartNewCallDM:
preset.skipLobby = true;
preset.sendNotificationType = "ring";
preset.autoLeaveWhenOthersLeft = true;
preset.waitForCallPickup = true;
preset.callIntent = preset.callIntent ?? "video";
break;
case UserIntent.JoinExistingCallDMVoice:
preset.callIntent = "audio";
// Fall through
case UserIntent.JoinExistingCallDM:
// On desktop this will be overridden based on which button was used to join the call
preset.skipLobby = true;
preset.autoLeaveWhenOthersLeft = true;
preset.callIntent = preset.callIntent ?? "video";
break;
// Non widget usecase defaults
default:
preset = {
confineToRoom: false,
preload: false,
header: HeaderStyle.Standard,
showControls: true,
hideScreensharing: false,
allowIceFallback: false,
perParticipantE2EE: false,
controlledAudioDevices: false,
skipLobby: false,
returnToLobby: false,
sendNotificationType: undefined,
autoLeaveWhenOthersLeft: false,
waitForCallPickup: false,
};
}
return preset;
}
/**
* The {@link UrlProperties} for Element Call running as a component inside a
* host application.
@@ -350,6 +555,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
showControls: parser.getFlag("showControls"),
hideScreensharing: parser.getFlag("hideScreensharing"),
allowIceFallback: parser.getFlag("allowIceFallback"),
perParticipantE2EE: parser.getFlag("perParticipantE2EE"),
controlledAudioDevices: parser.getFlag("controlledAudioDevices"),
skipLobby: isWidget ? parser.getFlag("skipLobby") : false,
// In SPA mode the user should always exit to the home screen when hanging
@@ -372,7 +578,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
"intent:",
intent,
"\nproperties:",
redact(properties, "password", "posthogUserId"),
redact(properties, "password"),
"configuration:",
configuration,
);
@@ -500,14 +706,20 @@ export function generateUrlSearchParams(
const params = new URLSearchParams();
// The password shouldn't need URL encoding here (we generate URL-safe ones) but encode
// it in case it came from another client that generated a non url-safe one
if (encryptionSystem?.kind === E2eeType.SHARED_KEY) {
const encodedPassword = encodeURIComponent(encryptionSystem.secret);
if (encodedPassword !== encryptionSystem.secret) {
logger.info(
"Encoded call password used non URL-safe chars: buggy client?",
);
switch (encryptionSystem?.kind) {
case E2eeType.SHARED_KEY: {
const encodedPassword = encodeURIComponent(encryptionSystem.secret);
if (encodedPassword !== encryptionSystem.secret) {
logger.info(
"Encoded call password used non URL-safe chars: buggy client?",
);
}
params.set("password", encodedPassword);
break;
}
params.set("password", encodedPassword);
case E2eeType.PER_PARTICIPANT:
params.set("perParticipantE2EE", "true");
break;
}
params.set("roomId", roomId);
viaServers?.forEach((s) => params.set("viaServers", s));
+2 -2
View File
@@ -15,7 +15,7 @@ exports[`AppBar > renders 1`] = `
tabindex="0"
>
<div
class="_indicator-icon_j097f_17"
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
@@ -58,7 +58,7 @@ exports[`AppBar > renders with title and subtitle 1`] = `
tabindex="0"
>
<div
class="_indicator-icon_j097f_17"
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
+19 -15
View File
@@ -5,24 +5,19 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/* The styles Element Call needs wherever it is shown: the element defaults and
custom properties its own stylesheets build on top of.
/* The styles Element Call needs wherever it is shown: the design tokens, fonts
and element defaults its own stylesheets build on top of.
Split out from index.css so that Element Call as a component can have these
without also being given the standalone page's layout, which would style the
host's own document. Compound's stylesheets — the design tokens, the component
styles and the fonts they use — are not here either: as a component, Element
Call shares the host's copy of Compound (it is a peer dependency, so that what
Compound floats into the host's body, tooltips and menus, is styled by the
host's stylesheet), and index.css loads it for the page.
What remains here still speaks of the document — normalize.css and the
typography below use bare element selectors, and the custom properties are
declared on `:root` — which is right for the page, and is why the component
build rewrites it: there every selector is confined to Element Call's root
element (see component/build/scopeStylesToRoot.ts), with `html`, `body` and
`:root` becoming that element. Nothing needs to be written differently here for
that to work, but nothing here may rely on reaching the host's document either.
host's own document. What remains here still speaks of the
document — normalize.css and the typography below use bare element selectors,
and the custom properties are declared on `:root` — which is right for the
page, and is why the component build rewrites it: there every selector is
confined to Element Call's root element (see component/build/scopeStylesToRoot.ts),
with `html`, `body` and `:root` becoming that element. Nothing needs to be
written differently here for that to work, but nothing here may rely on
reaching the host's document either.
Nothing here should depend on where it lands relative to Element Call's
component stylesheets: the bundler decides that, and it decides differently for
@@ -30,7 +25,16 @@ the app and for the component build. */
@layer normalize, compound-legacy, compound;
@import url("@fontsource/inter/400.css");
@import url("@fontsource/inter/500.css");
@import url("@fontsource/inter/600.css");
@import url("@fontsource/inter/700.css");
@import url("@fontsource/inconsolata/400.css");
@import url("@fontsource/inconsolata/700.css");
@import url("normalize.css/normalize.css") layer(normalize);
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound);
@import url("@vector-im/compound-web/dist/style.css") layer(compound.components);
:root {
--font-scale: 1;
+1 -1
View File
@@ -83,7 +83,7 @@ div.reactionPopupMenuRoot.reactionPopupMenuModal > div > div {
flex: 0 0 auto;
}
.reactionPopupMenu .reactionsMenu .reactionButton {
.reactionButton {
padding: var(--reaction-button-padding);
border-radius: var(--cpd-radius-pill-effect);
font-size: var(--reaction-button-fontsize);
+1 -6
View File
@@ -6,12 +6,11 @@ Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test";
import { constant } from "../state/Behavior";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import type { Alignment, Layout } from "../state/layout-types";
import type { Layout } from "../state/layout-types";
import type { SpotlightTileViewModel } from "../state/TileViewModel";
import type { DeviceLabel } from "../state/MediaDevices";
import { createCallFooterViewModel } from "./CallFooterViewModel";
@@ -65,10 +64,6 @@ function buildMinimalCallViewModel(layout: Layout): CallViewModel {
const gridLayout: Layout = {
type: "grid",
grid: [],
spotlightAlignment$: new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
}),
setVisibleTiles: (_: number) => {},
};
@@ -345,7 +345,6 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
// Keeps the items the menu's own children for assistive tech.
role="none"
className={styles.deviceList}
data-testid="device-list"
style={
{
"--device-list-max-height":
+10 -12
View File
@@ -97,19 +97,11 @@ export interface ConfigOptions {
};
};
/**
* Describes the LiveKit configuration to be used.
*/
// Describes the LiveKit configuration to be used.
livekit?: {
/**
* The link to the service that returns a livekit url and token to use it.
* This is a fallback link in case the homeserver in use does not advertise
* a livekit service url over the transports endpoint.
*
* @deprecated Homeservers should advertise available transports over the
* /_matrix/client/unstable/org.matrix.msc4143/rtc/transports endpoint
* instead.
*/
// The link to the service that returns a livekit url and token to use it.
// This is a fallback link in case the homeserver in use does not advertise
// a livekit service url over the transports endpoint.
livekit_service_url: string;
};
@@ -215,6 +207,12 @@ export interface ConfigOptions {
* Take care when changing these options.
*/
matrix_rtc_session?: {
/**
* How long (in milliseconds) to wait before rotating end-to-end media encryption keys
* when someone leaves a call.
*/
wait_for_key_rotation_ms?: number;
/**
* How long we wait before retrying after a network error on any of the requests.
*/
+100 -150
View File
@@ -5,28 +5,25 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { act, render, screen } from "@testing-library/react";
import { BehaviorSubject } from "rxjs";
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { arrangeTiles, type Bounds } from "./CallLayout";
import { type CallLayout } from "./CallLayout";
import { makeGridLayout } from "./GridLayout";
import { makeSpotlightLandscapeLayout } from "./SpotlightLandscapeLayout";
import { makeSpotlightPortraitLayout } from "./SpotlightPortraitLayout";
import { type DragCallback, type LayoutProps } from "./Grid";
import { type LayoutProps } from "./Grid";
import { constant } from "../state/Behavior";
import {
type Alignment,
type GridLayout,
type SpotlightLandscapeLayout,
type SpotlightPortraitLayout,
} from "../state/layout-types";
import { type Alignment } from "../state/layout-types";
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
import {
GridTileViewModel,
SpotlightTileViewModel,
type TileViewModel,
} from "../state/TileViewModel";
import { makeOneOnOneMobileLayout } from "./OneOnOneMobileLayout";
import { makeSpotlightExpandedLayout } from "./SpotlightExpandedLayout";
import { makeOneOnOneDesktopLayout } from "./OneOnOneDesktopLayout";
// Grid supplies these hooks to the layouts it renders; on their own the
// layouts have no grid to report to
@@ -36,152 +33,105 @@ vi.mock("./Grid", async (importOriginal) => ({
useVisibleTiles: (): void => {},
}));
describe("grid layout", () => {
it("sizes the tiles from the minimum bounds", () => {
const minBounds$ = new BehaviorSubject<Bounds>({ width: 800, height: 600 });
const { scrolling: Scrolling } = makeGridLayout({ minBounds$ });
render(<Scrolling model={gridModel()} Slot={Slot} />);
const layer = screen.getByTestId("slot-0").parentElement!;
expect(layer.style.width).toBe("800px");
expect(layer.style.getPropertyValue("--width")).toBe(
`${Math.floor(arrangeTiles(800, 600, 3).tileWidth)}px`,
);
const minBounds$ = constant({ width: 800, height: 800 });
act(() => minBounds$.next({ width: 400, height: 300 }));
expect(layer.style.width).toBe("400px");
expect(layer.style.getPropertyValue("--width")).toBe(
`${Math.floor(arrangeTiles(400, 300, 3).tileWidth)}px`,
);
});
it("moves the spotlight only when a drag reaches another corner", () => {
const model = gridModel();
const { fixed: Fixed } = makeGridLayout({ minBounds$: bounds });
render(<Fixed model={model} Slot={Slot} />);
const alignments: Alignment[] = [];
model.spotlightAlignment$.subscribe((a) => alignments.push(a));
const drag = drags.get("spotlight")!;
// Still in the bottom right corner
act(() => drag({ x: 0, y: 0, xRatio: 0.9, yRatio: 0.9 }));
// Over to the bottom left
act(() => drag({ x: 0, y: 0, xRatio: 0.1, yRatio: 0.9 }));
// And a little further, still bottom left
act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.8 }));
// Up to the top left
act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.1 }));
expect(alignments).toEqual([
{ inline: "end", block: "end" },
{ inline: "start", block: "end" },
{ inline: "start", block: "start" },
]);
const slot = screen.getByTestId("slot-spotlight");
expect(slot.getAttribute("data-inline-alignment")).toBe("start");
expect(slot.getAttribute("data-block-alignment")).toBe("start");
});
});
describe("spotlight landscape layout", () => {
it("puts the spotlight in the fixed layer and the grid in the scrolling one", () => {
const { fixed: Fixed, scrolling: Scrolling } = makeSpotlightLandscapeLayout(
{ minBounds$: bounds },
);
const model = landscapeModel();
const { unmount } = render(<Fixed model={model} Slot={Slot} />);
expect(screen.getByTestId("slot-spotlight")).toBeInTheDocument();
unmount();
render(<Scrolling model={model} Slot={Slot} />);
expect(slotIds()).toEqual(model.grid.map((tile) => tile.id));
});
});
describe("spotlight portrait layout", () => {
it("sizes the grid tiles from the minimum width", () => {
const minBounds$ = new BehaviorSubject<Bounds>({ width: 300, height: 700 });
const { scrolling: Scrolling } = makeSpotlightPortraitLayout({
minBounds$,
});
const model = portraitModel();
render(<Scrolling model={model} Slot={Slot} />);
expect(slotIds()).toEqual(model.grid.map((tile) => tile.id));
const layer = screen.getByTestId(`slot-${model.grid[0].id}`).parentElement!
.parentElement!;
expect(layer.style.getPropertyValue("--grid-tile-width")).toBe(
`${Math.floor(arrangeTiles(300, 300, 3).tileWidth)}px`,
);
act(() => minBounds$.next({ width: 500, height: 700 }));
expect(layer.style.getPropertyValue("--grid-tile-width")).toBe(
`${Math.floor(arrangeTiles(500, 500, 3).tileWidth)}px`,
);
});
});
const bounds = constant<Bounds>({ width: 800, height: 600 });
// The layouts only pass tile view models through to their slots
const media = {} as UserMediaViewModel;
const spotlight = new SpotlightTileViewModel(
constant([]),
constant(false),
constant("solid"),
const spotlight = {
vm: new SpotlightTileViewModel(
constant([]),
constant(false),
constant("solid"),
),
alignment$: constant<Alignment>({ inline: "end", block: "end" }),
onDrag: () => {},
};
const grid = Array.from(
{ length: 3 },
() => new GridTileViewModel(constant({} as UserMediaViewModel)),
);
const tiles = (): GridTileViewModel[] =>
Array.from({ length: 3 }, () => new GridTileViewModel(constant(media)));
const pip = {
alignment$: constant<Alignment>({ inline: "end", block: "end" }),
onDrag: () => {},
vm: { id: "pip" } as GridTileViewModel,
size$: constant("sm" as const),
};
function gridModel(): GridLayout {
return {
type: "grid",
spotlight,
grid: tiles(),
spotlightAlignment$: new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
}),
setVisibleTiles: () => {},
};
}
function landscapeModel(): SpotlightLandscapeLayout {
return {
type: "spotlight-landscape",
spotlight,
grid: tiles(),
setVisibleTiles: () => {},
};
}
function portraitModel(): SpotlightPortraitLayout {
return {
type: "spotlight-portrait",
spotlight,
grid: tiles(),
setVisibleTiles: () => {},
};
}
// Slots record their drag callback so a test can drag them, and show the id
// of the tile they hold
const drags = new Map<string, DragCallback | undefined>();
const Slot: LayoutProps<unknown, TileViewModel, HTMLDivElement>["Slot"] = ({
id,
model,
onDrag,
children,
...props
}) => {
drags.set(id, onDrag);
return (
<div data-testid={`slot-${id}`} {...props}>
{model instanceof GridTileViewModel ? model.id : "spotlight"}
</div>
);
};
}) => (
<div data-testid={`slot-${id}`} {...props}>
{children}
</div>
);
function slotIds(): string[] {
return screen
.getAllByTestId(/^slot-/)
.map((slot) => slot.textContent ?? "")
.filter((id) => id !== "spotlight");
function snapshotLayout<Model>(layout: CallLayout<Model>, model: Model): void {
const {
foreground,
scrolling: Scrolling,
fixed: Fixed,
} = layout({ minBounds$ });
const scrollingLayer = <Scrolling model={model} Slot={Slot} />;
const fixedLayer = <Fixed model={model} Slot={Slot} />;
const { container } = render(
foreground === "fixed" ? (
<>
{scrollingLayer}
{fixedLayer}
</>
) : (
<>
{fixedLayer}
{scrollingLayer}
</>
),
);
expect(container).toMatchSnapshot();
}
test("grid layout", () =>
snapshotLayout(makeGridLayout, {
type: "grid",
spotlight,
grid,
setVisibleTiles: () => {},
}));
test("spotlight landscape layout", () =>
snapshotLayout(makeSpotlightLandscapeLayout, {
type: "spotlight-landscape",
spotlight,
grid,
setVisibleTiles: () => {},
}));
test("spotlight portrait layout", () =>
snapshotLayout(makeSpotlightPortraitLayout, {
type: "spotlight-portrait",
spotlight,
grid,
setVisibleTiles: () => {},
}));
test("spotlight expanded layout", () =>
snapshotLayout(makeSpotlightExpandedLayout, {
type: "spotlight-expanded",
spotlight: spotlight.vm,
pip,
}));
test("one-on-one desktop layout", () =>
snapshotLayout(makeOneOnOneDesktopLayout, {
type: "one-on-one-desktop",
spotlight: { id: "spotlight" } as GridTileViewModel,
pip,
}));
test("one-on-one mobile layout", () =>
snapshotLayout(makeOneOnOneMobileLayout, {
type: "one-on-one-mobile",
spotlight: spotlight.vm,
pip,
}));
+12 -29
View File
@@ -40,6 +40,7 @@ import { useMergedRefs } from "../useMergedRefs";
import { TileWrapper } from "./TileWrapper";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { useInitial } from "../useInitial";
import { type DragCallback } from "../state/layout-types";
const MAX_ANIMATED_TILES = 50; // Capped for performance reasons
@@ -83,7 +84,10 @@ interface DragState {
cursorY: number;
}
interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
export interface SlotProps<Model> extends Omit<
ComponentProps<"div">,
"onDrag"
> {
id: string;
model: Model;
onDrag?: DragCallback;
@@ -181,27 +185,6 @@ export interface TileProps<Model, R extends HTMLElement> {
model: Model;
}
interface Drag {
/**
* The X coordinate of the dragged tile in grid space.
*/
x: number;
/**
* The Y coordinate of the dragged tile in grid space.
*/
y: number;
/**
* The X coordinate of the dragged tile, as a scalar of the grid width.
*/
xRatio: number;
/**
* The Y coordinate of the dragged tile, as a scalar of the grid height.
*/
yRatio: number;
}
export type DragCallback = (drag: Drag) => void;
interface LayoutMemoProps<
LayoutModel,
TileModel,
@@ -464,13 +447,13 @@ export function Grid<
)
.catch(logger.error);
if (endOfGesture)
callback({
x: tileX,
y: tileY,
xRatio: tileX / (gridBounds.width - tile.width),
yRatio: tileY / (gridBounds.height - tile.height),
});
callback({
x: tileX,
y: tileY,
xRatio: tileX / (gridBounds.width - tile.width),
yRatio: tileY / (gridBounds.height - tile.height),
endOfGesture,
});
};
// Callback for useDrag. We could call useDrag here, but the default
+30 -25
View File
@@ -8,15 +8,21 @@ Please see LICENSE in the repository root for full details.
import {
type CSSProperties,
type ReactNode,
useCallback,
useMemo,
type FC,
type ComponentType,
} from "react";
import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts";
import styles from "./GridLayout.module.css";
import { type CallLayout, arrangeTiles } from "./CallLayout";
import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid";
import { type SlotProps, useUpdateLayout, useVisibleTiles } from "./Grid";
import { useBehavior } from "../useBehavior";
import {
type SpotlightTileViewModel,
type TileViewModel,
} from "../state/TileViewModel.ts";
import { type FloatingTile } from "../state/FloatingTile.ts";
interface GridCSSProperties extends CSSProperties {
"--gap": string;
@@ -24,6 +30,27 @@ interface GridCSSProperties extends CSSProperties {
"--height": string;
}
interface SpotlightSlotProps {
model: FloatingTile & { vm: SpotlightTileViewModel };
Slot: ComponentType<SlotProps<TileViewModel>>;
}
const SpotlightSlot: FC<SpotlightSlotProps> = ({ model, Slot }) => {
useUpdateLayout();
const alignment = useBehavior(model.alignment$);
return (
<Slot
className={styles.slot}
id="spotlight"
model={model.vm}
onDrag={model.onDrag}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
);
};
/**
* An implementation of the "grid" layout, in which all participants are shown
* together in a scrolling grid.
@@ -37,32 +64,10 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
// lives
fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
useUpdateLayout();
const alignment = useBehavior(model.spotlightAlignment$);
const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) => {
const block = yRatio < 0.5 ? "start" : "end";
const inline = xRatio < 0.5 ? "start" : "end";
// A drag reports the same alignment on every move; only a change is
// worth a re-render
const current = model.spotlightAlignment$.value;
if (current.block !== block || current.inline !== inline)
model.spotlightAlignment$.next({ block, inline });
},
[model.spotlightAlignment$],
);
return (
<div ref={ref} className={styles.fixed}>
{model.spotlight && (
<Slot
className={styles.slot}
id="spotlight"
model={model.spotlight}
onDrag={onDragSpotlight}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
<SpotlightSlot model={model.spotlight} Slot={Slot} />
)}
</div>
);
+6 -15
View File
@@ -6,13 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback, useMemo } from "react";
import { type ReactNode, useMemo } from "react";
import classNames from "classnames";
import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts";
import { type CallLayout, arrangeTiles } from "./CallLayout";
import styles from "./OneOnOneDesktopLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
/**
@@ -37,21 +37,12 @@ export const makeOneOnOneDesktopLayout: CallLayout<
}): ReactNode {
useUpdateLayout();
const { width, height } = useBehavior(minBounds$);
const pipAlignment = useBehavior(model.pipAlignment$);
const pipAlignment = useBehavior(model.pip.alignment$);
const { tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, height, 1),
[width, height],
);
const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return (
<div ref={ref} className={styles.layer}>
<Slot
@@ -62,9 +53,9 @@ export const makeOneOnOneDesktopLayout: CallLayout<
>
<Slot
className={classNames(styles.slot, styles.local)}
id={model.pip.id}
model={model.pip}
onDrag={onDragLocalTile}
id={model.pip.vm.id}
model={model.pip.vm}
onDrag={model.pip.onDrag}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
+32 -24
View File
@@ -6,14 +6,43 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback } from "react";
import { type ComponentType, type FC, type ReactNode } from "react";
import classNames from "classnames";
import { type OneOnOneMobileLayout as OneOnOneMobileLayoutModel } from "../state/layout-types.ts";
import { type CallLayout } from "./CallLayout";
import styles from "./OneOnOneMobileLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { type SlotProps, useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
import { type FloatingTile } from "../state/FloatingTile.ts";
import {
type GridTileViewModel,
type TileViewModel,
} from "../state/TileViewModel.ts";
import { type Behavior } from "../state/Behavior.ts";
interface PipSlotProps {
model: FloatingTile & { vm: GridTileViewModel; size$: Behavior<"sm" | "lg"> };
Slot: ComponentType<SlotProps<TileViewModel>>;
}
const PipSlot: FC<PipSlotProps> = ({ model, Slot }) => {
useUpdateLayout();
const size = useBehavior(model.size$);
const alignment = useBehavior(model.alignment$);
return (
<Slot
className={classNames(styles.pip)}
id={model.vm.id}
model={model.vm}
onDrag={model.onDrag}
data-size={size}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
);
};
/**
* An implementation of the "one-on-one" layout for mobile platforms, in which
@@ -44,30 +73,9 @@ export const makeOneOnOneMobileLayout: CallLayout<
Slot,
}): ReactNode {
useUpdateLayout();
const pipSize = useBehavior(model.pipSize$);
const pipAlignment = useBehavior(model.pipAlignment$);
const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return (
<div ref={ref} className={styles.layer}>
{model.pip && (
<Slot
className={classNames(styles.pip)}
id={model.pip.id}
model={model.pip}
onDrag={onDragLocalTile}
data-size={pipSize}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
)}
{model.pip && <PipSlot model={model.pip} Slot={Slot} />}
</div>
);
},
+29 -24
View File
@@ -5,13 +5,39 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback } from "react";
import { type ReactNode, type ComponentType, type FC } from "react";
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/layout-types.ts";
import { type CallLayout } from "./CallLayout";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { type SlotProps, useUpdateLayout } from "./Grid";
import styles from "./SpotlightExpandedLayout.module.css";
import { useBehavior } from "../useBehavior";
import { type FloatingTile } from "../state/FloatingTile.ts";
import {
type GridTileViewModel,
type TileViewModel,
} from "../state/TileViewModel.ts";
interface PipSlotProps {
model: FloatingTile & { vm: GridTileViewModel };
Slot: ComponentType<SlotProps<TileViewModel>>;
}
const PipSlot: FC<PipSlotProps> = ({ model, Slot }) => {
useUpdateLayout();
const alignment = useBehavior(model.alignment$);
return (
<Slot
className={styles.pip}
id={model.vm.id}
model={model.vm}
onDrag={model.onDrag}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
);
};
/**
* An implementation of the "expanded spotlight" layout, in which the spotlight
@@ -28,7 +54,6 @@ export const makeSpotlightExpandedLayout: CallLayout<
Slot,
}): ReactNode {
useUpdateLayout();
return (
<div ref={ref} className={styles.layer}>
<Slot
@@ -46,29 +71,9 @@ export const makeSpotlightExpandedLayout: CallLayout<
Slot,
}): ReactNode {
useUpdateLayout();
const pipAlignment = useBehavior(model.pipAlignment$);
const onDragPip: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return (
<div ref={ref} className={styles.layer}>
{model.pip && (
<Slot
className={styles.pip}
id={model.pip.id}
model={model.pip}
onDrag={onDragPip}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
)}
{model.pip && <PipSlot model={model.pip} Slot={Slot} />}
</div>
);
},
+2 -2
View File
@@ -40,7 +40,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
<Slot
className={styles.slot}
id="spotlight"
model={model.spotlight}
model={model.spotlight.vm}
/>
</div>
<div className={styles.grid} />
@@ -59,7 +59,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
return (
<div ref={ref} className={styles.layer}>
<SpotlightSlot media$={model.spotlight.media$} />
<SpotlightSlot media$={model.spotlight.vm.media$} />
<div className={styles.grid}>
{model.grid.map((m) => (
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
+2 -2
View File
@@ -43,7 +43,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
<Slot
className={styles.slot}
id="spotlight"
model={model.spotlight}
model={model.spotlight.vm}
/>
</div>
</div>
@@ -65,7 +65,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
width,
model.grid.length,
);
const withIndicators = useBehavior(model.spotlight.media$).length > 1;
const withIndicators = useBehavior(model.spotlight.vm.media$).length > 1;
return (
<div
@@ -0,0 +1,186 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`grid layout 1`] = `
<div>
<div
class="_scrolling_e8555d"
style="width: 800px; --gap: 20px; --width: 370px; --height: 277px;"
>
<div
class="_slot_e8555d"
data-testid="slot-0"
/>
<div
class="_slot_e8555d"
data-testid="slot-1"
/>
<div
class="_slot_e8555d"
data-testid="slot-2"
/>
</div>
<div
class="_fixed_e8555d"
>
<div
class="_slot_e8555d"
data-block-alignment="end"
data-inline-alignment="end"
data-testid="slot-spotlight"
/>
</div>
</div>
`;
exports[`one-on-one desktop layout 1`] = `
<div>
<div
class="_layer_185815"
>
<div
class="_container_185815"
data-testid="slot-spotlight"
style="width: 760px; height: 570px;"
>
<div
class="_slot_185815 _local_185815"
data-block-alignment="end"
data-inline-alignment="end"
data-testid="slot-pip"
/>
</div>
</div>
<div />
</div>
`;
exports[`one-on-one mobile layout 1`] = `
<div>
<div
class="_layer_c81c30"
>
<div
class="_spotlight_c81c30"
data-testid="slot-spotlight"
/>
</div>
<div
class="_layer_c81c30"
>
<div
class="_pip_c81c30"
data-block-alignment="end"
data-inline-alignment="end"
data-size="sm"
data-testid="slot-pip"
/>
</div>
</div>
`;
exports[`spotlight expanded layout 1`] = `
<div>
<div
class="_layer_e70191"
>
<div
class="_spotlight_e70191"
data-testid="slot-spotlight"
/>
</div>
<div
class="_layer_e70191"
>
<div
class="_pip_e70191"
data-block-alignment="end"
data-inline-alignment="end"
data-testid="slot-pip"
/>
</div>
</div>
`;
exports[`spotlight landscape layout 1`] = `
<div>
<div
class="_layer_29809c"
>
<div
class="_spotlight_29809c"
>
<div
class="_slot_29809c"
data-testid="slot-spotlight"
/>
</div>
<div
class="_grid_29809c"
/>
</div>
<div
class="_layer_29809c"
>
<div
class="_spotlight_29809c"
/>
<div
class="_grid_29809c"
>
<div
class="_slot_29809c"
data-testid="slot-0"
/>
<div
class="_slot_29809c"
data-testid="slot-1"
/>
<div
class="_slot_29809c"
data-testid="slot-2"
/>
</div>
</div>
</div>
`;
exports[`spotlight portrait layout 1`] = `
<div>
<div
class="_layer_065bbc"
style="--grid-gap: 20px; --grid-tile-width: 370px; --grid-tile-height: 277px;"
>
<div
class="_spotlight_065bbc"
/>
<div
class="_grid_065bbc"
>
<div
class="_slot_065bbc"
data-testid="slot-0"
/>
<div
class="_slot_065bbc"
data-testid="slot-1"
/>
<div
class="_slot_065bbc"
data-testid="slot-2"
/>
</div>
</div>
<div
class="_layer_065bbc"
>
<div
class="_spotlight_065bbc"
>
<div
class="_slot_065bbc"
data-testid="slot-spotlight"
/>
</div>
</div>
</div>
`;
+1 -15
View File
@@ -7,24 +7,10 @@ Please see LICENSE in the repository root for full details.
/* Styles for Element Call as a page of its own. The parts that apply wherever
Element Call is shown live in base.css; these are about owning the document,
and are not loaded when a host embeds Element Call as a component.
That includes Compound: the page brings the design tokens, the component styles
and their fonts, where a host embedding the component supplies its own. The
`compound` layers are ordered by base.css. */
and are not loaded when a host embeds Element Call as a component. */
@import url("./base.css");
@import url("@fontsource/inter/400.css");
@import url("@fontsource/inter/500.css");
@import url("@fontsource/inter/600.css");
@import url("@fontsource/inter/700.css");
@import url("@fontsource/inconsolata/400.css");
@import url("@fontsource/inconsolata/700.css");
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound);
@import url("@vector-im/compound-web/dist/style.css") layer(compound.components);
body {
background-color: var(--cpd-color-bg-canvas-default);
color: var(--cpd-color-text-primary);
+3 -4
View File
@@ -24,7 +24,6 @@ import { testAudioContext } from "../useAudioContext.test";
import * as MediaDevicesContext from "../MediaDevicesContext";
import { LivekitRoomAudioRenderer } from "./MatrixAudioRenderer";
import {
exampleTransportLocator,
mockLocalParticipant,
mockMediaDevices,
mockRemoteParticipant,
@@ -119,7 +118,7 @@ function renderTestComponent(
<LivekitRoomAudioRenderer
validIdentities={participants.map((p) => p.identity)}
livekitRoom={livekitRoom}
transport={exampleTransportLocator}
url={""}
/>
</MediaDevicesProvider>,
);
@@ -151,7 +150,7 @@ it("should not render or warn for the local participant", () => {
<LivekitRoomAudioRenderer
validIdentities={[]}
livekitRoom={{ remoteParticipants: new Map() } as unknown as Room}
transport={exampleTransportLocator}
url={""}
/>
</MediaDevicesProvider>,
);
@@ -178,7 +177,7 @@ it("should warn only once per unexpected participant", () => {
<LivekitRoomAudioRenderer
validIdentities={[]}
livekitRoom={{ remoteParticipants: new Map() } as unknown as Room}
transport={exampleTransportLocator}
url={""}
/>
</MediaDevicesProvider>,
);
+6 -4
View File
@@ -19,10 +19,12 @@ import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState";
import * as controls from "../controls";
import { type TransportLocator } from "./auth";
export interface MatrixAudioRendererProps {
transport: TransportLocator;
/**
* The service URL of the LiveKit room.
*/
url: string;
livekitRoom: LivekitRoom;
/**
* The list of participant identities to render audio for.
@@ -52,7 +54,7 @@ export interface MatrixAudioRendererProps {
* @public
*/
export function LivekitRoomAudioRenderer({
transport,
url,
livekitRoom,
validIdentities,
muted,
@@ -86,7 +88,7 @@ export function LivekitRoomAudioRenderer({
warnedIdentities.current.add(identity);
// Log that there is an invalid identity, that means that someone is publishing audio that is not expected to be in the call.
logger.warn(
`Audio track ${identity} from ${JSON.stringify(transport)} has no matching matrix call member`,
`Audio track ${identity} from ${url} has no matching matrix call member`,
`current members: ${validIdentities.join()}`,
`track will not get rendered`,
);
-137
View File
@@ -1,137 +0,0 @@
/*
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 { ConnectionError, HTTPError, type MatrixClient } from "matrix-js-sdk";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { extractFullConfigFromToken, type SFUConfig } from "./types.ts";
import { doNetworkOperationWithRetry } from "../../utils/matrix.ts";
/**
* In widget mode the host may refuse an action before any request is made
* which surfaces as a plain error. Asking again cannot change its answer,
* so only errors from the network are worth retrying.
*/
function isNetworkError(e: unknown): boolean {
return e instanceof HTTPError || e instanceof ConnectionError;
}
export type ClientGetTokenParts = Pick<
MatrixClient,
"_unstable_getLivekitToken"
>;
export interface GetSFUConfigParams {
/**
* The Matrix client.
*/
client: ClientGetTokenParts;
/**
* Data identifying the local user's session membership.
*/
membership: CallMembershipIdentityParts;
/**
* The WebSocket URL of the SFU for which we wish to get an access token.
*/
url: string;
/**
* The name of the homeserver to which the SFU belongs.
*/
serverName: string;
/**
* The ID of the Matrix room in which the session takes place.
*/
roomId: string;
/**
* The ID of the MatrixRTC slot in which the session takes place.
*/
slotId: string;
}
/**
* Gets an {@link SFUConfig} appropriate for connecting to a given SFU, using
* the MSC4195 LiveKit client-server API endpoints.
*/
export async function getSFUConfig({
client,
membership,
url,
serverName,
roomId,
slotId,
}: GetSFUConfigParams): Promise<SFUConfig> {
const res = await doNetworkOperationWithRetry(
async () =>
client._unstable_getLivekitToken({
url,
server_name: serverName,
room_id: roomId,
slot_id: slotId,
member_id: membership.memberId,
}),
isNetworkError,
);
return extractFullConfigFromToken({ url, jwt: res.jwt });
}
export type ClientDelegationParts = Pick<
MatrixClient,
"_unstable_delegateDelayedLeave" | "baseUrl"
>;
export interface DelegateDelayedLeaveParams {
/**
* The Matrix client.
*/
client: ClientDelegationParts;
/**
* Data identifying the local user's session membership.
*/
membership: CallMembershipIdentityParts;
/**
* The WebSocket URL of the SFU to which we are publishing and wish to
* delegate the delayed leave event.
*/
url: string;
/**
* The ID of the Matrix room in which the session takes place.
*/
roomId: string;
/**
* The ID of the MatrixRTC slot in which the session takes place.
*/
slotId: string;
/**
* The delay ID of the leave event to be delegated.
*/
delayId: string;
}
/**
* Delegates a delayed leave event to a given SFU, so that the event will
* be sent on our behalf whenever we disconnect from the SFU.
*/
export async function delegateDelayedLeave({
client,
membership,
url,
roomId,
slotId,
delayId,
}: DelegateDelayedLeaveParams): Promise<void> {
await doNetworkOperationWithRetry(
async () =>
client._unstable_delegateDelayedLeave({
url,
room_id: roomId,
slot_id: slotId,
member_id: membership.memberId,
delay_id: delayId,
}),
isNetworkError,
);
}
-306
View File
@@ -1,306 +0,0 @@
/*
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 { afterEach, expect, type Mocked, vi, describe, it } from "vitest";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import {
aliceDeviceId,
aliceId,
aliceUserId,
createJWTToken,
} from "../../utils/test-fixtures";
import type * as csApi from "./csApi";
import * as openID from "./openID";
import { delegateDelayedLeave, getSFUConfig } from ".";
import { mocked } from "storybook/test";
import { ConnectionError, MatrixError } from "matrix-js-sdk";
import { NoMatrix2AuthorizationService } from "../../utils/errors";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
vi.mock("./openID", () => ({ getSFUConfig: vi.fn() }));
vi.useFakeTimers();
const csApiJWTToken = createJWTToken(aliceId, "via CS API");
const openIDJWTToken = createJWTToken(aliceId, "via OpenID");
mocked(openID).getSFUConfig.mockResolvedValue({
url: "mock-openID-url",
jwt: openIDJWTToken,
livekitAlias: "via OpenID",
livekitIdentity: aliceId,
});
const client: Mocked<
csApi.ClientGetTokenParts &
csApi.ClientDelegationParts &
openID.ClientOpenIDParts
> = {
baseUrl: "https://matrix.example.org",
_unstable_getLivekitToken: vi.fn().mockResolvedValue({ jwt: csApiJWTToken }),
_unstable_delegateDelayedLeave: vi.fn().mockResolvedValue({}),
getOpenIdToken: vi
.fn()
.mockResolvedValue({ access_token: "mock-openID-token" }),
};
const membership: CallMembershipIdentityParts = {
userId: aliceUserId,
deviceId: aliceDeviceId,
memberId: "123",
};
const roomId = "!room:example.org";
const slotId = "m.call#ROOM";
const unsupportedError = new MatrixError({ errcode: "M_UNSUPPORTED" }, 404);
const urlTransport: UnstableLivekitTransport = {
type: "livekit",
url: "wss://livekit.example.org",
};
const serviceUrlTransport: UnstableLivekitTransport = {
type: "livekit",
livekit_service_url: "https://livekit-jwt.example.org",
};
const urlAndServiceUrlTransport: UnstableLivekitTransport = {
...urlTransport,
...serviceUrlTransport,
};
afterEach(() => vi.clearAllMocks());
describe("getSFUConfig", () => {
const defaultParams = {
client,
membership,
serverName: "example.org",
role: "subscriber" as const,
roomId,
slotId,
logger,
};
it("uses CS API with url transport", async () => {
expect(
await getSFUConfig({ ...defaultParams, transport: urlTransport }),
).toEqual(
expect.objectContaining({
jwt: csApiJWTToken,
livekitAlias: "via CS API",
}),
);
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
it("does not fall back to OpenID with url transport", async () => {
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
await expect(
getSFUConfig({ ...defaultParams, transport: urlTransport }),
).rejects.toThrow(expect.any(NoMatrix2AuthorizationService));
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
it("uses OpenID with livekit_service_url transport", async () => {
expect(
await getSFUConfig({ ...defaultParams, transport: serviceUrlTransport }),
).toEqual(
expect.objectContaining({
jwt: openIDJWTToken,
livekitAlias: "via OpenID",
}),
);
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
expect(openID.getSFUConfig).toHaveBeenCalled();
});
it("uses CS API with url + livekit_service_url transport", async () => {
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
}),
).toEqual(
expect.objectContaining({
jwt: csApiJWTToken,
livekitAlias: "via CS API",
}),
);
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
it("falls back to OpenID with url + livekit_service_url transport", async () => {
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
}),
).toEqual(
expect.objectContaining({
jwt: openIDJWTToken,
livekitAlias: "via OpenID",
}),
);
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(openID.getSFUConfig).toHaveBeenCalled();
});
it("falls back to OpenID when the widget host refuses the CS API", async () => {
// In widget mode a host without MSC4533 rejects the request before it
// reaches the homeserver, with a plain error rather than a Matrix error
client._unstable_getLivekitToken.mockRejectedValueOnce(
new Error(
"The rtc_livekit_get_token action is not supported by the client.",
),
);
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
}),
).toEqual(expect.objectContaining({ jwt: openIDJWTToken }));
expect(openID.getSFUConfig).toHaveBeenCalled();
});
it("surfaces the widget host's refusal with url transport", async () => {
const error = new Error(
"The rtc_livekit_get_token action is not supported by the client.",
);
client._unstable_getLivekitToken.mockRejectedValueOnce(error);
await expect(
getSFUConfig({ ...defaultParams, transport: urlTransport }),
).rejects.toThrow(error);
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
it("uses CS API when publishing with url transport", async () => {
expect(
await getSFUConfig({
...defaultParams,
transport: urlTransport,
role: "publisher",
}),
).toEqual(expect.objectContaining({ jwt: csApiJWTToken }));
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
it("does not fall back to OpenID when publishing", async () => {
client._unstable_getLivekitToken.mockRejectedValueOnce(unsupportedError);
await expect(
getSFUConfig({
...defaultParams,
transport: urlTransport,
role: "publisher",
}),
).rejects.toThrow(expect.any(NoMatrix2AuthorizationService));
expect(client._unstable_getLivekitToken).toHaveBeenCalled();
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
it("uses OpenID when publishing with url + livekit_service_url transport", async () => {
// Subscribers predating MSC4195 expect a legacy participant identity from
// anyone advertising a state event, which only the JWT service assigns
expect(
await getSFUConfig({
...defaultParams,
transport: urlAndServiceUrlTransport,
role: "publisher",
}),
).toEqual(
expect.objectContaining({
jwt: openIDJWTToken,
livekitAlias: "via OpenID",
}),
);
expect(client._unstable_getLivekitToken).not.toHaveBeenCalled();
expect(openID.getSFUConfig).toHaveBeenCalledWith(
expect.objectContaining({ role: "publisher" }),
);
});
it("does not fall back to OpenID on connection error", async () => {
const client_ = {
...client,
_unstable_getLivekitToken: vi
.fn()
.mockRejectedValue(new ConnectionError("No connection")),
};
await Promise.all([
expect(
getSFUConfig({
...defaultParams,
client: client_,
transport: urlAndServiceUrlTransport,
}),
).rejects.toThrow(expect.any(ConnectionError)),
vi.runAllTimersAsync(), // Allow request to be retried
]);
expect(client_._unstable_getLivekitToken).toHaveBeenCalled();
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
});
describe("delegateDelayedLeave", () => {
const defaultParams = {
client,
membership,
roomId,
slotId,
delayId: "delay123",
logger,
};
it("uses CS API with url transport", async () => {
await delegateDelayedLeave({ ...defaultParams, transport: urlTransport });
expect(client._unstable_delegateDelayedLeave).toHaveBeenCalled();
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
it("does not fall back to OpenID with url transport", async () => {
client._unstable_delegateDelayedLeave.mockRejectedValueOnce(
unsupportedError,
);
await expect(
delegateDelayedLeave({ ...defaultParams, transport: urlTransport }),
).rejects.toThrow();
expect(client._unstable_delegateDelayedLeave).toHaveBeenCalled();
expect(openID.getSFUConfig).not.toHaveBeenCalled();
});
it("uses OpenID with livekit_service_url transport", async () => {
await delegateDelayedLeave({
...defaultParams,
transport: serviceUrlTransport,
});
expect(client._unstable_delegateDelayedLeave).not.toHaveBeenCalled();
expect(openID.getSFUConfig).toHaveBeenCalled();
});
it("uses OpenID with url + livekit_service_url transport", async () => {
// Must match the flow that got the token, so the identities line up
await delegateDelayedLeave({
...defaultParams,
transport: urlAndServiceUrlTransport,
});
expect(client._unstable_delegateDelayedLeave).not.toHaveBeenCalled();
expect(openID.getSFUConfig).toHaveBeenCalledWith(
expect.objectContaining({
role: "publisher",
delayId: "delay123",
delayEndpointBaseUrl: client.baseUrl,
}),
);
});
});
-253
View File
@@ -1,253 +0,0 @@
/*
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 CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { ConnectionError, MatrixError } from "matrix-js-sdk";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import * as openID from "./openID";
import * as csApi from "./csApi";
import { type SFUConfig } from "./types";
import { NoMatrix2AuthorizationService } from "../../utils/errors";
export type { ClientOpenIDParts } from "./openID";
export type { ClientGetTokenParts, ClientDelegationParts } from "./csApi";
export type * from "./types";
/**
* Checks whether an error means "this homeserver does not implement the
* (unstable) MSC4195 endpoints".
*
* MSC4195 documents `M_NOT_FOUND` for this, but a homeserver without support
* answers with whatever it uses for unknown endpoints (Synapse: 404 with
* `M_UNRECOGNIZED`), so we accept any 404 as "unsupported".
* @param e The error to check.
*/
function isEndpointUnsupported(e: unknown): boolean {
return (
e instanceof MatrixError &&
(e.httpStatus === 404 ||
e.errcode === "M_NOT_FOUND" ||
e.errcode === "M_UNRECOGNIZED")
);
}
/**
* Checks whether an error means the MSC4195 CS API cannot be used from here:
* the homeserver does not implement it, or the request never reached the
* homeserver because in widget mode the host does not expose the endpoint
* (MSC4533), which the widget API reports as a plain error rather than a
* Matrix error. A connection error is neither, as the endpoint may well exist.
* @param e The error to check.
*/
function isCsApiUnavailable(e: unknown): boolean {
if (isEndpointUnsupported(e)) return true;
return !(e instanceof MatrixError) && !(e instanceof ConnectionError);
}
export interface GetSFUConfigParams {
/**
* The Matrix client.
*/
client: csApi.ClientGetTokenParts & openID.ClientOpenIDParts;
/**
* Data identifying the local user's session membership.
*/
membership: CallMembershipIdentityParts;
/**
* The transport for which we wish to get an access token.
*/
transport: UnstableLivekitTransport;
/**
* The name of the homeserver to which the {@link transport} belongs.
*/
serverName: string;
/**
* The ID of the Matrix room in which the session takes place.
*/
roomId: string;
/**
* The ID of the MatrixRTC slot in which the session takes place.
*/
slotId: string;
/**
* Whether we want to publish or only subscribe on the {@link transport}.
*/
role: "publisher" | "subscriber";
logger: Logger;
}
/**
* Gets an {@link SFUConfig} appropriate for connecting to a given transport.
*/
export async function getSFUConfig({
client,
membership,
transport,
serverName,
roomId,
slotId,
role,
logger,
}: GetSFUConfigParams): Promise<SFUConfig> {
const viaOpenID = async (serviceUrl: string): Promise<SFUConfig> =>
await openID.getSFUConfig({
client,
membership,
serviceUrl,
roomId,
slotId,
role,
logger,
});
const flow = chooseAuthFlow(transport, role);
if (flow.kind === "openID") return await viaOpenID(flow.serviceUrl);
try {
return await csApi.getSFUConfig({
client,
membership,
url: flow.url,
serverName,
roomId,
slotId,
});
} catch (e) {
// Publishers should never fall back. If a homeserver advertises a `url`
// but doesn't support MSC4195 itself, that's a misconfiguration which
// the admin should know about so they can prepare for the future
// deprecation of `livekit_service_url`.
const mayFallBack =
role === "subscriber" && "livekit_service_url" in transport;
if (mayFallBack && isCsApiUnavailable(e)) {
logger.warn(
`Cannot use the MSC4195 get_token endpoint (${e}), falling back to the OpenID flow with service ${transport.livekit_service_url}`,
);
return await viaOpenID(transport.livekit_service_url);
}
if (isEndpointUnsupported(e)) {
logger.error(
`Homeserver does not support the MSC4195 get_token endpoint, cannot get token for transport ${flow.url}`,
);
throw new NoMatrix2AuthorizationService(e as Error);
}
throw e;
}
}
export interface DelegateDelayedLeaveParams {
/**
* The Matrix client.
*/
client: csApi.ClientDelegationParts & openID.ClientOpenIDParts;
/**
* Data identifying the local user's session membership.
*/
membership: CallMembershipIdentityParts;
/**
* The transport to which we are publishing and wish to delegate the delayed
* leave event.
*/
transport: UnstableLivekitTransport;
/**
* The ID of the Matrix room in which the session takes place.
*/
roomId: string;
/**
* The ID of the MatrixRTC slot in which the session takes place.
*/
slotId: string;
/**
* The delay ID of the leave event to be delegated.
*/
delayId: string;
logger: Logger;
}
/**
* Delegates a delayed leave event to a given transport, so that the event will
* be sent on our behalf whenever we disconnect from the transport's SFU.
*/
export async function delegateDelayedLeave({
client,
membership,
transport,
roomId,
slotId,
delayId,
logger,
}: DelegateDelayedLeaveParams): Promise<void> {
// It's important that we use the same flow (CS API or OpenID) as whatever we
// would've used to get a JWT token for this transport, since the participant
// identity computed by the service needs to match.
const flow = chooseAuthFlow(transport, "publisher");
if (flow.kind === "csApi") {
try {
await csApi.delegateDelayedLeave({
client,
membership,
url: flow.url,
roomId,
slotId,
delayId,
});
} catch (e) {
if (isEndpointUnsupported(e))
logger.error(
"Server misconfigured: transport contains a 'url' field but server does not support the MSC4195 delegate_delayed_leave endpoint",
);
throw e;
}
} else {
// This will technically cause the service to issue a new JWT token, but
// it's safe to discard. We're only interested in triggering delegation.
await openID.getSFUConfig({
client,
membership,
serviceUrl: flow.serviceUrl,
roomId,
slotId,
role: "publisher",
delayEndpointBaseUrl: client.baseUrl,
delayId,
logger,
});
}
}
/**
* The flow with which the local member authenticates against a transport.
*/
export type AuthFlow =
| { kind: "csApi"; url: string }
| { kind: "openID"; serviceUrl: string };
/**
* Chooses between the MSC4195 CS API and the legacy OpenID flow against the
* transport's JWT service.
*
* Only the JWT service's `/sfu/get` endpoint assigns the legacy participant
* identity that clients predating MSC4195 expect from anyone advertising a
* membership state event. So a publisher keeps using the JWT service wherever
* the transport still offers one, even if it also has a `url`. Subscribers may
* use the CS API regardless, as nobody matches their identity.
* @param transport The transport to authenticate against.
* @param role Whether we want to publish or only subscribe on the transport.
*/
export function chooseAuthFlow(
transport: UnstableLivekitTransport,
role: "publisher" | "subscriber",
): AuthFlow {
if ("url" in transport) {
const useCsApi =
!("livekit_service_url" in transport) || role === "subscriber";
if (useCsApi) return { kind: "csApi", url: transport.url };
}
return { kind: "openID", serviceUrl: transport.livekit_service_url };
}
-230
View File
@@ -1,230 +0,0 @@
/*
Copyright 2025 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 {
beforeEach,
afterEach,
describe,
expect,
it,
type MockedObject,
vitest,
} from "vitest";
import fetchMock from "fetch-mock";
import { MatrixError } from "matrix-js-sdk";
import {
getSFUConfig,
type GetSFUConfigParams,
type ClientOpenIDParts,
} from "./openID";
import { testJWTToken } from "../../utils/test-fixtures";
import { ownMemberMock } from "../../utils/test";
import { FailToGetOpenIdToken } from "../../utils/errors";
import { logger } from "matrix-js-sdk/lib/logger";
const serviceUrl = "https://sfu.example.org";
const delayParams = {
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
delayId: "mock_delay_id",
};
describe("getSFUConfig", () => {
let matrixClient: MockedObject<ClientOpenIDParts>;
let params: GetSFUConfigParams;
beforeEach(() => {
fetchMock.catch(404);
matrixClient = { getOpenIdToken: vitest.fn() };
params = {
client: matrixClient,
membership: ownMemberMock,
serviceUrl: "https://sfu.example.org",
roomId: "!example_room_id",
slotId: "m.call#ROOM",
role: "publisher",
logger,
};
});
afterEach(async () => {
void (await fetchMock.flush());
vitest.clearAllMocks();
fetchMock.reset();
});
it("should handle fetching a token", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: serviceUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfig(params);
expect(config).toEqual({
jwt: testJWTToken,
url: serviceUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
});
it("should fail if the SFU errors", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: {
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
};
});
try {
await getSFUConfig(params);
} catch (ex: unknown) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
const mxError = (ex as Error).cause as MatrixError;
expect(mxError.message).toEqual(
"MatrixError: [500] Failed to look up user info from homeserver",
);
return;
}
expect.fail("Expected test to throw;");
});
it("should retry without delay params if the JWT service legacy endpoint returns M_BAD_JSON 400", async () => {
let callCount = 0;
fetchMock.post(
"https://sfu.example.org/sfu/get",
(url, opts) => {
callCount++;
const body = JSON.parse(opts.body as string);
// First call: check if it has delay parts and return 400
if (callCount === 1) {
expect(body).toHaveProperty("delay_id", "mock_delay_id");
return {
status: 400,
body: { errcode: "M_BAD_JSON", error: "Unsupported parameters" },
};
}
// Second call: check if delay parts were stripped and return success
expect(body).not.toHaveProperty("delay_id");
expect(body).not.toHaveProperty("delay_timeout");
expect(body).not.toHaveProperty("delay_cs_api_url");
return {
status: 200,
body: { url: serviceUrl, jwt: testJWTToken },
};
},
{ overwriteRoutes: true },
);
// Note: Assuming getSFUConfig eventually calls getLiveKitJWT
const config = await getSFUConfig({ ...params, ...delayParams });
expect(config.jwt).toBe(testJWTToken);
expect(callCount).toBe(2);
});
it("should successfully send delay parameters to the JWT service legacy endpoint", async () => {
fetchMock.post(
"https://sfu.example.org/sfu/get",
(url, opts) => {
const body = JSON.parse(opts.body as string);
// Verify, that the request contains the expected delay parameters
if (
body.delay_id === "mock_delay_id" &&
body.delay_timeout === 3600000 &&
body.delay_cs_api_url === "https://matrix.homeserverserver.org"
) {
return {
status: 200,
body: { url: serviceUrl, jwt: testJWTToken },
};
}
return {
status: 400,
body: { error: "Missing expected delay params" },
};
},
{ overwriteRoutes: true },
);
const config = await getSFUConfig({ ...params, ...delayParams });
// Prüfe das Ergebnis
expect(config).toMatchObject({
jwt: testJWTToken,
url: serviceUrl,
});
});
it("should use the default endpoint when subscribing", async () => {
fetchMock.post("https://sfu.example.org/get_token", (url, opts) => {
const body = JSON.parse(opts.body as string);
expect(body).toMatchObject({
room_id: "!example_room_id",
slot_id: "m.call#ROOM",
member: {
id: ownMemberMock.memberId,
claimed_user_id: ownMemberMock.userId,
claimed_device_id: ownMemberMock.deviceId,
},
});
return { status: 200, body: { url: serviceUrl, jwt: testJWTToken } };
});
const config = await getSFUConfig({ ...params, role: "subscriber" });
expect(config.jwt).toBe(testJWTToken);
expect(fetchMock.called("https://sfu.example.org/sfu/get")).toBe(false);
});
it("should fail when subscribing if the default endpoint is missing", async () => {
// Nothing but the catch-all 404 answers the default endpoint. Subscribers
// must not fall back to the legacy endpoint, which would hand them a
// legacy identity.
await expect(
getSFUConfig({ ...params, role: "subscriber" }),
).rejects.toThrow(FailToGetOpenIdToken);
expect(fetchMock.called("https://sfu.example.org/get_token")).toBe(true);
expect(fetchMock.called("https://sfu.example.org/sfu/get")).toBe(false);
});
it("should retry fetching the openid token", async () => {
let count = 0;
matrixClient.getOpenIdToken.mockImplementation(async () => {
count++;
if (count < 2) {
throw Error("Test failure");
}
return Promise.resolve({
token_type: "Bearer",
access_token: "foobar",
matrix_server_name: "example.org",
expires_in: 30,
});
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: serviceUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfig({ ...params, ...delayParams });
expect(config).toEqual({
jwt: testJWTToken,
url: serviceUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
});
});
-262
View File
@@ -1,262 +0,0 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type IOpenIDToken,
type MatrixClient,
parseErrorResponse,
} from "matrix-js-sdk";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { FailToGetOpenIdToken } from "../../utils/errors";
import { doNetworkOperationWithRetry } from "../../utils/matrix";
import { Config } from "../../config/Config";
import { extractFullConfigFromToken, type SFUConfig } from "./types";
// The bits we need from MatrixClient
export type ClientOpenIDParts = Pick<MatrixClient, "getOpenIdToken">;
export interface GetSFUConfigParams {
client: ClientOpenIDParts;
/**
* Data identifying the local user's session membership.
*/
membership: CallMembershipIdentityParts;
/**
* The base URL of the LiveKit JWT service.
*/
serviceUrl: string;
/**
* The ID of the Matrix room in which the session takes place.
*/
roomId: string;
/**
* The ID of the MatrixRTC slot in which the session takes place.
*/
slotId: string;
/**
* Whether we want to publish or only subscribe on the {@link transport}.
*/
role: "publisher" | "subscriber";
/**
* The base URL of the Matrix homeserver, for delayed leave delegation.
*/
delayEndpointBaseUrl?: string;
/**
* The delay ID of the leave event to be delegated to the JWT service.
*/
delayId?: string;
logger: Logger;
}
/**
* Gets an {@link SFUConfig} appropriate for connecting to the SFU behind a
* given service URL, using the legacy OpenID flow.
* @throws {@link FailToGetOpenIdToken}
*/
export async function getSFUConfig({
client,
membership,
serviceUrl,
roomId,
slotId,
role,
delayEndpointBaseUrl,
delayId,
logger,
}: GetSFUConfigParams): Promise<SFUConfig> {
let openIdToken: IOpenIDToken;
try {
openIdToken = await doNetworkOperationWithRetry(async () =>
client.getOpenIdToken(),
);
} catch (error) {
throw new FailToGetOpenIdToken(
error instanceof Error ? error : new Error("Unknown error"),
);
}
logger.debug("Got openID token", openIdToken);
let sfuConfig: { url: string; jwt: string } | undefined;
let endpoint: "default" | "legacy";
if (role === "publisher") {
// When publishing a legacy transport (one with a `livekit_service_url`),
// subscribers will expect our participant identity to use the legacy
// `@user_id:device_id` format. Only the legacy JWT service endpoint assigns
// identities in this format, so we must use it.
endpoint = "legacy";
} else {
// Use the default endpoint as it affords us slightly better metadata
// protection (pseudonymous participant identities).
endpoint = "default";
}
try {
logger.info(
`Trying to get JWT with ${endpoint} endpoint for focus ${serviceUrl}...`,
);
sfuConfig = await (
endpoint === "legacy" ? getLiveKitJWTLegacy : getLiveKitJWT
)(
membership,
serviceUrl,
roomId,
slotId,
openIdToken,
delayEndpointBaseUrl,
delayId,
);
logger.info(`Got JWT from call's active focus URL.`);
return extractFullConfigFromToken(sfuConfig);
} catch (e) {
logger.error(`Failed fetching jwt with ${endpoint} endpoint:`, e);
throw new FailToGetOpenIdToken(
e instanceof Error ? e : new Error(`Unknown error ${e}`),
);
}
}
/**
* Gets a JWT token appropriate for connecting to the SFU behind a given service
* URL, using the legacy `/sfu/get` endpoint, which assigns us a legacy
* (`<user_id>:<device_id>`) participant identity.
*/
async function getLiveKitJWTLegacy(
membership: CallMembershipIdentityParts,
livekitServiceURL: string,
matrixRoomId: string,
_slotId: string, // Unused - the legacy endpoint is from before slots existed.
openIDToken: IOpenIDToken,
delayEndpointBaseUrl?: string,
delayId?: string,
): Promise<{ url: string; jwt: string }> {
interface IDelayParams {
delay_id?: string;
delay_timeout?: number;
delay_cs_api_url?: string;
}
let bodyDalayParts: IDelayParams = {};
// Also check for empty string
if (delayId && delayEndpointBaseUrl) {
bodyDalayParts = {
delay_id: delayId,
delay_timeout:
Config.get().matrix_rtc_session.delegated_delayed_leave.delay_ms,
delay_cs_api_url: delayEndpointBaseUrl,
};
}
const makeRequest = async (delayParts: IDelayParams): Promise<Response> => {
return await fetch(livekitServiceURL + "/sfu/get", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
// The legacy JWT endpoint uses only the matrix room id to calculate the livekit room alias.
// However, the livekit room alias is provided as part of the JWT payload.
room: matrixRoomId,
openid_token: openIDToken,
device_id: membership.deviceId,
...delayParts,
}),
});
};
const res = await doNetworkOperationWithRetry(async () => {
let response = await makeRequest(bodyDalayParts);
// Old service compatibility check
const oldServiceDoesNotSupportDelayParts =
response.status === 400 && Object.keys(bodyDalayParts).length > 0;
// If http status 400 with M_BAD_JSON and we sent delay parts, retry without them
if (oldServiceDoesNotSupportDelayParts) {
try {
const errorBody = await response.json();
if (errorBody.errcode === "M_BAD_JSON") {
response = await makeRequest({});
}
} catch {
// If we can't parse the error, treat as real error
}
}
return response;
});
if (!res.ok) {
throw parseErrorResponse(res, await res.text());
}
return await res.json();
}
class NotSupportedError extends Error {
public constructor(message: string) {
super(message);
this.name = "NotSupported";
}
}
/**
* Gets a JWT token appropriate for connecting to the SFU behind a given service
* URL, using the default `/get_token` endpoint, which assigns us a hashed
* (pseudonymised) participant identity.
*/
async function getLiveKitJWT(
membership: CallMembershipIdentityParts,
livekitServiceURL: string,
matrixRoomId: string,
slotId: string,
openIDToken: IOpenIDToken,
delayEndpointBaseUrl?: string,
delayId?: string,
): Promise<{ url: string; jwt: string }> {
const { userId, deviceId, memberId } = membership;
const body = {
room_id: matrixRoomId,
slot_id: slotId,
openid_token: openIDToken,
member: {
id: memberId,
claimed_user_id: userId,
claimed_device_id: deviceId,
},
};
let bodyDalayParts = {};
// Also check for empty string
if (delayId && delayEndpointBaseUrl) {
bodyDalayParts = {
delay_id: delayId,
delay_timeout:
Config.get().matrix_rtc_session.delegated_delayed_leave.delay_ms,
delay_cs_api_url: delayEndpointBaseUrl,
};
}
const res = await doNetworkOperationWithRetry(async () => {
return await fetch(livekitServiceURL + "/get_token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ...body, ...bodyDalayParts }),
});
});
if (!res.ok) {
const msg = "SFU Config fetch failed with status code " + res.status;
if (res.status === 404) {
throw new NotSupportedError(msg);
} else {
throw parseErrorResponse(res, await res.text());
}
}
return await res.json();
}
-90
View File
@@ -1,90 +0,0 @@
/*
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 UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
/**
* Data identifying a transport and the homeserver to which it belongs, so
* consumers know how to reach it.
*/
export interface TransportLocator {
transport: UnstableLivekitTransport;
serverName: string;
}
/**
* Configuration and access tokens provided by the SFU on successful authentication.
*/
export interface SFUConfig {
/**
* The WebSocket URL of the LiveKit SFU. This is what we connect the LiveKit
* room to. Note that this is NOT the JWT service URL
* (`livekit_service_url`): the legacy JWT service returns this URL as part of
* its response, while the CS-Api based flow requires us to know it upfront.
*/
url: string;
jwt: string;
livekitAlias: string;
// NOTE: Currently unused.
livekitIdentity: string;
}
/**
* Decoded details from the JWT.
*/
interface SFUJWTPayload {
/**
* Expiration time for the JWT.
* Note: This value is in seconds since Unix epoch.
*/
exp: number;
/**
* Name of the instance which authored the JWT
*/
iss: string;
/**
* Time at which the JWT can start to be used.
* Note: This value is in seconds since Unix epoch.
*/
nbf: number;
/**
* Subject. The Livekit alias in this context.
*/
sub: string;
/**
* The set of permissions for the user.
*/
video: {
canPublish: boolean;
canSubscribe: boolean;
room: string;
roomJoin: boolean;
};
}
/**
* Complements the SFU websocket url and the JWT with the information encoded in
* the JWT payload itself.
* @param sfuConfig The SFU websocket url and the JWT to connect with.
* @returns The full SFU config, including the LiveKit alias and identity.
*/
export function extractFullConfigFromToken(sfuConfig: {
url: string;
jwt: string;
}): SFUConfig {
const [, payloadStr] = sfuConfig.jwt.split(".");
const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload;
return {
jwt: sfuConfig.jwt,
url: sfuConfig.url,
livekitAlias: payload.video.room,
// NOTE: Currently unused.
// Probably also not helpful since we now compute the backendIdentity on joining the call so we can use it for the encryption manager.
// The only reason for us to know it locally is to connect the right users with the lk world. (and to set our own keys)
livekitIdentity: payload.sub,
};
}
+329
View File
@@ -0,0 +1,329 @@
/*
Copyright 2025 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 {
beforeEach,
afterEach,
describe,
expect,
it,
type MockedObject,
vitest,
} from "vitest";
import fetchMock from "fetch-mock";
import { MatrixError } from "matrix-js-sdk";
import { getSFUConfigWithOpenID, type OpenIDClientParts } from "./openIDSFU";
import { testJWTToken } from "../utils/test-fixtures";
import { ownMemberMock } from "../utils/test";
import { FailToGetOpenIdToken } from "../utils/errors";
const sfuUrl = "https://sfu.example.org";
describe("getSFUConfigWithOpenID", () => {
let matrixClient: MockedObject<OpenIDClientParts>;
beforeEach(() => {
fetchMock.catch(404);
matrixClient = {
getOpenIdToken: vitest.fn(),
getDeviceId: vitest.fn(),
};
});
afterEach(() => {
vitest.clearAllMocks();
fetchMock.reset();
});
it("should handle fetching a token", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
);
expect(config).toEqual({
jwt: testJWTToken,
url: sfuUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
void (await fetchMock.flush());
});
it("should fail if the SFU errors", async () => {
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: {
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
};
});
try {
await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
);
} catch (ex: unknown) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
const mxError = (ex as Error).cause as MatrixError;
expect(mxError.message).toEqual(
"MatrixError: [500] Failed to look up user info from homeserver",
);
void (await fetchMock.flush());
return;
}
expect.fail("Expected test to throw;");
});
it("should retry without delay params if the JWT service legacy endpoint returns M_BAD_JSON 400", async () => {
let callCount = 0;
fetchMock.post(
"https://sfu.example.org/sfu/get",
(url, opts) => {
callCount++;
const body = JSON.parse(opts.body as string);
// First call: check if it has delay parts and return 400
if (callCount === 1) {
expect(body).toHaveProperty("delay_id", "mock_delay_id");
return {
status: 400,
body: { errcode: "M_BAD_JSON", error: "Unsupported parameters" },
};
}
// Second call: check if delay parts were stripped and return success
expect(body).not.toHaveProperty("delay_id");
expect(body).not.toHaveProperty("delay_timeout");
expect(body).not.toHaveProperty("delay_cs_api_url");
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
},
{ overwriteRoutes: true },
);
// Note: Assuming getSFUConfigWithOpenID eventually calls getLiveKitJWT
const config = await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
{
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
delayId: "mock_delay_id",
},
);
expect(config.jwt).toBe(testJWTToken);
expect(callCount).toBe(2);
void (await fetchMock.flush());
});
it("should successfully send delay parameters to the JWT service legacy endpoint", async () => {
fetchMock.post(
"https://sfu.example.org/sfu/get",
(url, opts) => {
const body = JSON.parse(opts.body as string);
// Verify, that the request contains the expected delay parameters
if (
body.delay_id === "mock_delay_id" &&
body.delay_timeout === 3600000 &&
body.delay_cs_api_url === "https://homeserverserver.org/cs_api"
) {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
}
return {
status: 400,
body: { error: "Missing expected delay params" },
};
},
{ overwriteRoutes: true },
);
const config = await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
{
delayEndpointBaseUrl: "https://homeserverserver.org/cs_api",
delayId: "mock_delay_id",
},
);
// Prüfe das Ergebnis
expect(config).toMatchObject({
jwt: testJWTToken,
url: sfuUrl,
});
void (await fetchMock.flush());
});
it("should try legacy and then new endpoint with delay delegation", async () => {
fetchMock.post("https://sfu.example.org/get_token", () => {
return {
status: 500,
body: {
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
};
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: {
errcode: "M_LOOKUP_FAILED",
error: "Failed to look up user info from homeserver",
},
};
});
try {
await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
{
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
delayId: "mock_delay_id",
},
);
} catch (ex) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toBeInstanceOf(MatrixError);
const mxError = (ex as Error).cause as MatrixError;
expect(mxError.message).toEqual(
"MatrixError: [500] Failed to look up user info from homeserver",
);
void (await fetchMock.flush());
}
const calls = fetchMock.calls();
expect(calls.length).toBe(2);
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
expect(calls[0][1]).toStrictEqual({
// check if it uses correct delayID!
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":3600000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
expect(calls[1][0]).toStrictEqual("https://sfu.example.org/sfu/get");
expect(calls[1][1]).toStrictEqual({
body: '{"room":"!example_room_id","device_id":"DEVICE","delay_id":"mock_delay_id","delay_timeout":3600000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
headers: {
"Content-Type": "application/json",
},
method: "POST",
});
});
it("dont try legacy if endpoint with delay delegation is sucessful", async () => {
fetchMock.post("https://sfu.example.org/get_token", () => {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 500,
body: { error: "Test failure" },
};
});
try {
await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
{
delayEndpointBaseUrl: "https://matrix.homeserverserver.org",
delayId: "mock_delay_id",
},
);
} catch (ex) {
expect(ex).toBeInstanceOf(FailToGetOpenIdToken);
expect((ex as FailToGetOpenIdToken).cause).toEqual(
new Error("SFU Config fetch failed with status code 500"),
);
void (await fetchMock.flush());
}
const calls = fetchMock.calls();
expect(calls.length).toBe(1);
expect(calls[0][0]).toStrictEqual("https://sfu.example.org/get_token");
expect(calls[0][1]).toStrictEqual({
// check if it uses correct delayID!
body: '{"room_id":"!example_room_id","slot_id":"m.call#ROOM","member":{"id":"@alice:example.org:DEVICE","claimed_user_id":"@alice:example.org","claimed_device_id":"DEVICE"},"delay_id":"mock_delay_id","delay_timeout":3600000,"delay_cs_api_url":"https://matrix.homeserverserver.org"}',
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
});
it("should retry fetching the openid token", async () => {
let count = 0;
matrixClient.getOpenIdToken.mockImplementation(async () => {
count++;
if (count < 2) {
throw Error("Test failure");
}
return Promise.resolve({
token_type: "Bearer",
access_token: "foobar",
matrix_server_name: "example.org",
expires_in: 30,
});
});
fetchMock.post("https://sfu.example.org/sfu/get", () => {
return {
status: 200,
body: { url: sfuUrl, jwt: testJWTToken },
};
});
const config = await getSFUConfigWithOpenID(
matrixClient,
ownMemberMock,
"https://sfu.example.org",
"!example_room_id",
);
expect(config).toEqual({
jwt: testJWTToken,
url: sfuUrl,
livekitIdentity: "@me:example.org:ABCDEF",
livekitAlias: "!example_room_id",
});
void (await fetchMock.flush());
});
});
+315
View File
@@ -0,0 +1,315 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type IOpenIDToken,
type MatrixClient,
parseErrorResponse,
} from "matrix-js-sdk";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Logger } from "matrix-js-sdk/lib/logger";
import {
FailToGetOpenIdToken,
NoMatrix2AuthorizationService,
} from "../utils/errors";
import { doNetworkOperationWithRetry } from "../utils/matrix";
import { Config } from "../config/Config";
import { MatrixRTCMode } from "../config/ConfigOptions";
/**
* Configuration and access tokens provided by the SFU on successful authentication.
*/
export interface SFUConfig {
url: string;
jwt: string;
livekitAlias: string;
// NOTE: Currently unused.
livekitIdentity: string;
}
/**
* Decoded details from the JWT.
*/
interface SFUJWTPayload {
/**
* Expiration time for the JWT.
* Note: This value is in seconds since Unix epoch.
*/
exp: number;
/**
* Name of the instance which authored the JWT
*/
iss: string;
/**
* Time at which the JWT can start to be used.
* Note: This value is in seconds since Unix epoch.
*/
nbf: number;
/**
* Subject. The Livekit alias in this context.
*/
sub: string;
/**
* The set of permissions for the user.
*/
video: {
canPublish: boolean;
canSubscribe: boolean;
room: string;
roomJoin: boolean;
};
}
// The bits we need from MatrixClient
export type OpenIDClientParts = Pick<
MatrixClient,
"getOpenIdToken" | "getDeviceId"
>;
/**
* Gets a bearer token from the homeserver and then use it to authenticate
* to the matrix RTC backend in order to get acces to the SFU.
* It has built-in retry for calls to the homeserver with a backoff policy.
* @param client The Matrix client
* @param membership Our own membership identity parts used to send to jwt service.
* @param serviceUrl The URL of the livekit SFU service
* @param roomId The room id used in the jwt request. This is NOT the livekit_alias. The jwt service will provide the alias. It maps matrix room ids <-> Livekit aliases.
* @param opts Additional options to modify which endpoint with which data will be used to acquire the jwt token.
* @param opts.matrixRTCMode Determines which version of the JWT endpoint to use, which affects whether the
* RTC backend identity is based on string concatenation (legacy) or a hash (Matrix 2.0).
* This function by default uses whatever is possible with the current jwt service installed next to the SFU.
* For remote connections this does not matter, since we will not publish there we can rely on the newest option.
* @param opts.delayEndpointBaseUrl The URL of the matrix homeserver.
* @param opts.delayId The delay id used for the jwt service to manage.
* @param logger optional logger.
* @returns Object containing the token information
* @throws FailToGetOpenIdToken
*/
export async function getSFUConfigWithOpenID(
client: OpenIDClientParts,
membership: CallMembershipIdentityParts,
serviceUrl: string,
roomId: string,
opts?: {
matrixRTCMode?: MatrixRTCMode;
delayEndpointBaseUrl?: string;
delayId?: string;
},
logger?: Logger,
): Promise<SFUConfig> {
let openIdToken: IOpenIDToken;
try {
openIdToken = await doNetworkOperationWithRetry(async () =>
client.getOpenIdToken(),
);
} catch (error) {
throw new FailToGetOpenIdToken(
error instanceof Error ? error : new Error("Unknown error"),
);
}
logger?.debug("Got openID token", openIdToken);
let sfuConfig: { url: string; jwt: string } | undefined;
const tryBothJwtEndpoints = opts?.matrixRTCMode === undefined; // This is for SFUs where we do not publish.
const forceMatrix2Jwt = opts?.matrixRTCMode === MatrixRTCMode.Matrix_2_0;
// We want to start using the new endpoint (with optional delay delegation)
// if we can use both or if we are forced to use the new one.
if (tryBothJwtEndpoints || forceMatrix2Jwt) {
try {
logger?.info(
`Trying to get JWT via default endpoint for focus ${serviceUrl}...`,
);
const sfuConfig = await getLiveKitJWT(
membership,
serviceUrl,
roomId,
openIdToken,
opts?.delayEndpointBaseUrl,
opts?.delayId,
);
return extractFullConfigFromToken(sfuConfig);
} catch (e) {
logger?.debug(`Failed fetching jwt with matrix 2.0 endpoint:`, e);
// Make this throw a hard error in case we force the matrix2.0 endpoint.
if (forceMatrix2Jwt) {
throw new NoMatrix2AuthorizationService(e as Error);
}
}
}
// DEPRECATED
// here we either have a sfuConfig or we already exited because of `if (forceMatrix2) throw ...`
// The only case we can get into this condition is, if `forceMatrix2` is `false`
try {
logger?.info(
`Trying to get JWT with legacy endpoint for focus ${serviceUrl}...`,
);
sfuConfig = await getLiveKitJWTLegacy(
membership.deviceId,
serviceUrl,
roomId,
openIdToken,
opts?.delayEndpointBaseUrl,
opts?.delayId,
);
logger?.info(`Got JWT from call's active focus URL.`);
return extractFullConfigFromToken(sfuConfig);
} catch (ex) {
throw new FailToGetOpenIdToken(
ex instanceof Error ? ex : new Error(`Unknown error ${ex}`),
);
}
}
function extractFullConfigFromToken(sfuConfig: {
url: string;
jwt: string;
}): SFUConfig {
const [, payloadStr] = sfuConfig.jwt.split(".");
const payload = JSON.parse(global.atob(payloadStr)) as SFUJWTPayload;
return {
jwt: sfuConfig.jwt,
url: sfuConfig.url,
livekitAlias: payload.video.room,
// NOTE: Currently unused.
// Probably also not helpful since we now compute the backendIdentity on joining the call so we can use it for the encryption manager.
// The only reason for us to know it locally is to connect the right users with the lk world. (and to set our own keys)
livekitIdentity: payload.sub,
};
}
async function getLiveKitJWTLegacy(
deviceId: string,
livekitServiceURL: string,
matrixRoomId: string,
openIDToken: IOpenIDToken,
delayEndpointBaseUrl?: string,
delayId?: string,
): Promise<{ url: string; jwt: string }> {
interface IDelayParams {
delay_id?: string;
delay_timeout?: number;
delay_cs_api_url?: string;
}
let bodyDalayParts: IDelayParams = {};
// Also check for empty string
if (delayId && delayEndpointBaseUrl) {
bodyDalayParts = {
delay_id: delayId,
delay_timeout:
Config.get().matrix_rtc_session.delegated_delayed_leave.delay_ms,
delay_cs_api_url: delayEndpointBaseUrl,
};
}
const makeRequest = async (delayParts: IDelayParams): Promise<Response> => {
return await fetch(livekitServiceURL + "/sfu/get", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
// The legacy JWT endpoint uses only the matrix room id to calculate the livekit room alias.
// However, the livekit room alias is provided as part of the JWT payload.
room: matrixRoomId,
openid_token: openIDToken,
device_id: deviceId,
...delayParts,
}),
});
};
const res = await doNetworkOperationWithRetry(async () => {
let response = await makeRequest(bodyDalayParts);
// Old service compatibility check
const oldServiceDoesNotSupportDelayParts =
response.status === 400 && Object.keys(bodyDalayParts).length > 0;
// If http status 400 with M_BAD_JSON and we sent delay parts, retry without them
if (oldServiceDoesNotSupportDelayParts) {
try {
const errorBody = await response.json();
if (errorBody.errcode === "M_BAD_JSON") {
response = await makeRequest({});
}
} catch {
// If we can't parse the error, treat as real error
}
}
return response;
});
if (!res.ok) {
throw parseErrorResponse(res, await res.text());
}
return await res.json();
}
class NotSupportedError extends Error {
public constructor(message: string) {
super(message);
this.name = "NotSupported";
}
}
export async function getLiveKitJWT(
membership: CallMembershipIdentityParts,
livekitServiceURL: string,
matrixRoomId: string,
openIDToken: IOpenIDToken,
delayEndpointBaseUrl?: string,
delayId?: string,
): Promise<{ url: string; jwt: string }> {
const { userId, deviceId, memberId } = membership;
const body = {
room_id: matrixRoomId,
slot_id: "m.call#ROOM",
openid_token: openIDToken,
member: {
id: memberId,
claimed_user_id: userId,
claimed_device_id: deviceId,
},
};
let bodyDalayParts = {};
// Also check for empty string
if (delayId && delayEndpointBaseUrl) {
bodyDalayParts = {
delay_id: delayId,
delay_timeout:
Config.get().matrix_rtc_session.delegated_delayed_leave.delay_ms,
delay_cs_api_url: delayEndpointBaseUrl,
};
}
const res = await doNetworkOperationWithRetry(async () => {
return await fetch(livekitServiceURL + "/get_token", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ...body, ...bodyDalayParts }),
});
});
if (!res.ok) {
const msg = "SFU Config fetch failed with status code " + res.status;
if (res.status === 404) {
throw new NotSupportedError(msg);
} else {
throw parseErrorResponse(res, await res.text());
}
}
return await res.json();
}
+2 -2
View File
@@ -17,7 +17,7 @@ import {
import { act } from "react";
import { type RoomMember } from "matrix-js-sdk";
import {
type UnstableLivekitTransport,
type LivekitTransport,
type CallMembership,
} from "matrix-js-sdk/lib/matrixrtc";
@@ -46,7 +46,7 @@ vitest.mock("../useAudioContext");
vitest.mock("../soundUtils");
vitest.mock("../rtcSessionHelpers", async (importOriginal) => ({
...(await importOriginal()),
makeTransport: (): [UnstableLivekitTransport] => [exampleTransport],
makeTransport: (): [LivekitTransport] => [exampleTransport],
}));
afterEach(() => {
+7 -1
View File
@@ -242,7 +242,12 @@ const LoadedCallView: FC<LoadedProps> = ({
const { displayName, avatarUrl } = useProfile(client);
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room);
const { returnToLobby, password: passwordFromUrl, header } = useUrlParams();
const {
perParticipantE2EE,
returnToLobby,
password: passwordFromUrl,
header,
} = useUrlParams();
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
// Save the password once we start the groupCallView
@@ -360,6 +365,7 @@ const LoadedCallView: FC<LoadedProps> = ({
rtcSession,
preload,
skipLobby,
perParticipantE2EE,
mediaDevices,
latestMuteStates,
setJoined,
+12 -16
View File
@@ -665,10 +665,10 @@ export const InCallView: FC<InCallViewProps> = ({
onPointerOut={onPointerOut}
>
{header}
{audioParticipants.map(({ livekitRoom, transport, participants }) => (
{audioParticipants.map(({ livekitRoom, url, participants }) => (
<LivekitRoomAudioRenderer
key={JSON.stringify(transport)}
transport={transport}
key={url}
url={url}
livekitRoom={livekitRoom}
validIdentities={participants}
muted={muteAllAudio}
@@ -694,19 +694,15 @@ export const InCallView: FC<InCallViewProps> = ({
tab={settingsTab}
onTabChange={setSettingsTab}
developerSettingsVm={developerSettingsVm}
livekitRooms={allConnections.map(
({ livekitRoom: room, livekitAlias, transport, role }) => ({
room,
livekitAlias,
role,
url:
// It should be relatively clear to the user which kind of URL
// this is, based on the scheme (ws:// vs. http://)
"url" in transport
? transport.url
: transport.livekit_service_url,
}),
)}
livekitRooms={allConnections
.getConnections()
.map((connectionItem) => ({
room: connectionItem.livekitRoom,
livekitAlias: connectionItem.livekitAlias,
// TODO compute is local or tag it in the livekit room items already
isLocal: undefined,
url: connectionItem.transport.livekit_service_url,
}))}
/>
</>
)}
@@ -181,7 +181,7 @@ exports[`InCallView > rendering > renders 1`] = `
tabindex="0"
>
<div
class="_indicator-icon_j097f_17"
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
@@ -15,7 +15,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
tabindex="0"
>
<div
class="_indicator-icon_j097f_17"
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
@@ -111,7 +111,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
tabindex="0"
>
<div
class="_indicator-icon_j097f_17"
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
@@ -247,7 +247,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
tabindex="0"
>
<div
class="_indicator-icon_j097f_17"
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
@@ -343,7 +343,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
tabindex="0"
>
<div
class="_indicator-icon_j097f_17"
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
@@ -621,7 +621,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
tabindex="0"
>
<div
class="_indicator-icon_j097f_17"
class="_indicator-icon_147l5_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
+40 -63
View File
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { afterEach, describe, expect, it, type Mock, vi } from "vitest";
import { render, waitFor, screen } from "@testing-library/react";
import userEvent, { type UserEvent } from "@testing-library/user-event";
import userEvent from "@testing-library/user-event";
import { TooltipProvider } from "@vector-im/compound-web";
import type { MatrixClient } from "matrix-js-sdk";
@@ -18,14 +18,14 @@ import {
} from "./DeveloperSettingsTab";
import { outOfCallDeveloperSettingsTabViewModel } from "./DeveloperSettingsTabViewModel";
import { createStaticViewModel } from "../state/ViewModel";
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
import {
customTransport as customTransportSetting,
customLivekitUrl as customLivekitUrlSetting,
enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting,
matrixRTCMode as matrixRTCModeSetting,
} from "./settings";
import { MatrixRTCMode } from "../config/ConfigOptions";
import { exampleTransport, mockConfig } from "../utils/test";
import { getSFUConfig } from "../livekit/auth";
import { mockConfig } from "../utils/test";
// Mock url params hook to avoid environment-dependent snapshot churn.
vi.mock("../UrlParams", () => ({
@@ -36,8 +36,8 @@ vi.mock("../UrlParams", () => ({
}));
// IMPORTANT: mock the same specifier used by DeveloperSettingsTab
vi.mock("../livekit/auth", () => ({
getSFUConfig: vi.fn().mockResolvedValue({
vi.mock("../livekit/openIDSFU", () => ({
getSFUConfigWithOpenID: vi.fn().mockResolvedValue({
url: "mock-url",
jwt: "mock-jwt",
}),
@@ -77,21 +77,6 @@ function createMockMatrixClient(): MatrixClient {
} as unknown as MatrixClient;
}
/**
* Types the JSON representation of an object into the given element.
*/
async function typeJson(
user: UserEvent,
element: Element,
object: object,
): Promise<void> {
// `user.type` requires us to escape brackets by doubling them
await user.type(
element,
JSON.stringify(object).replace(/([{[\]])/g, (c) => c + c),
);
}
describe("DeveloperSettingsTab", () => {
it("renders and matches snapshot", async () => {
const client = createMockMatrixClient();
@@ -139,18 +124,17 @@ describe("DeveloperSettingsTab", () => {
expect(container).toMatchSnapshot();
});
describe("custom transport", () => {
describe("custom livekit url", () => {
afterEach(() => {
customTransportSetting.setValue(null);
customLivekitUrlSetting.setValue(null);
});
const client = {
doesServerSupportUnstableFeature: vi.fn().mockResolvedValue(true),
getCrypto: () => ({ getVersion: (): string => "x" }),
getUserId: () => "@u:hs",
getDeviceId: () => "DEVICE",
getDomain: () => "hs",
} as unknown as MatrixClient;
it("will not update custom transport without roomId", async () => {
it("will not update custom livekit url without roomId", async () => {
const user = userEvent.setup();
render(
@@ -163,17 +147,17 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>,
);
const input = screen.getByLabelText("Custom transport JSON");
const input = screen.getByLabelText("Custom Livekit-url");
await user.clear(input);
await typeJson(user, input, exampleTransport);
await user.type(input, "wss://example.livekit.invalid");
const saveButton = screen.getByRole("button", { name: "Save" });
await user.click(saveButton);
expect(getSFUConfig).not.toHaveBeenCalled();
expect(getSFUConfigWithOpenID).not.toHaveBeenCalled();
expect(customTransportSetting.getValue()).toBe(null);
expect(customLivekitUrlSetting.getValue()).toBe(null);
});
it("will not update custom transport without text in input", async () => {
it("will not update custom livekit url without text in input", async () => {
const user = userEvent.setup();
render(
@@ -187,16 +171,16 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>,
);
const input = screen.getByLabelText("Custom transport JSON");
const input = screen.getByLabelText("Custom Livekit-url");
await user.clear(input);
const saveButton = screen.getByRole("button", { name: "Save" });
await user.click(saveButton);
expect(getSFUConfig).not.toHaveBeenCalled();
expect(getSFUConfigWithOpenID).not.toHaveBeenCalled();
expect(customTransportSetting.getValue()).toBe(null);
expect(customLivekitUrlSetting.getValue()).toBe(null);
});
it("will not update custom transport when pressing cancel", async () => {
it("will not update custom livekit url when pressing cancel", async () => {
const user = userEvent.setup();
render(
@@ -210,7 +194,7 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>,
);
const input = screen.getByLabelText("Custom transport JSON");
const input = screen.getByLabelText("Custom Livekit-url");
await user.clear(input);
await user.type(input, "wss://example.livekit.invalid");
@@ -218,11 +202,11 @@ describe("DeveloperSettingsTab", () => {
name: "Reset overwrite",
});
await user.click(cancelButton);
expect(getSFUConfig).not.toHaveBeenCalled();
expect(getSFUConfigWithOpenID).not.toHaveBeenCalled();
expect(customTransportSetting.getValue()).toBe(null);
expect(customLivekitUrlSetting.getValue()).toBe(null);
});
it("will update custom transport", async () => {
it("will update custom livekit url", async () => {
const user = userEvent.setup();
render(
@@ -236,28 +220,24 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>,
);
const input = screen.getByLabelText("Custom transport JSON");
const input = screen.getByLabelText("Custom Livekit-url");
await user.clear(input);
await typeJson(user, input, {
type: "livekit",
url: "wss://example.livekit.valid",
});
await user.type(input, "wss://example.livekit.valid");
const saveButton = screen.getByRole("button", { name: "Save" });
await user.click(saveButton);
expect(getSFUConfig).toHaveBeenCalledWith(
expect.objectContaining({
transport: { type: "livekit", url: "wss://example.livekit.valid" },
roomId: "#testRoom",
}),
expect(getSFUConfigWithOpenID).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"wss://example.livekit.valid",
"#testRoom",
);
expect(customTransportSetting.getValue()).toEqual({
type: "livekit",
url: "wss://example.livekit.valid",
});
expect(customLivekitUrlSetting.getValue()).toBe(
"wss://example.livekit.valid",
);
});
it("will show error on unreachable transport", async () => {
it("will show error on invalid url", async () => {
const user = userEvent.setup();
render(
@@ -271,27 +251,24 @@ describe("DeveloperSettingsTab", () => {
</TooltipProvider>,
);
const input = screen.getByLabelText("Custom transport JSON");
const input = screen.getByLabelText("Custom Livekit-url");
await user.clear(input);
await typeJson(user, input, {
type: "livekit",
url: "wss://example.livekit.valid",
});
await user.type(input, "wss://example.livekit.valid");
const saveButton = screen.getByRole("button", { name: "Save" });
(getSFUConfig as Mock).mockImplementation(() => {
throw new Error("Failed to get SFU config");
(getSFUConfigWithOpenID as Mock).mockImplementation(() => {
throw new Error("Invalid URL");
});
await user.click(saveButton);
expect(
screen.getByText("invalid transport (did not update)"),
screen.getByText("invalid URL (did not update)"),
).toBeInTheDocument();
expect(customTransportSetting.getValue()).toBe(null);
expect(customLivekitUrlSetting.getValue()).toBe(null);
});
});
// Add this test inside the describe("DeveloperSettingsTab", () => { block,
// after the custom transport tests:
// after the custom livekit url tests:
describe("enable extended livekit logs", () => {
afterEach(() => {
+35 -53
View File
@@ -45,7 +45,7 @@ import {
muteAllAudio as muteAllAudioSetting,
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
matrixRTCMode as matrixRTCModeSetting,
customTransport as customTransportSetting,
customLivekitUrl as customLivekitUrlSetting,
advancedScreenShare as advancedScreenShareSetting,
screenShareResolution as screenShareResolutionSetting,
screenShareFramerate as screenShareFramerateSetting,
@@ -67,10 +67,9 @@ import styles from "./DeveloperSettingsTab.module.css";
import settingsStyles from "./SettingsModal.module.css";
import { Slider } from "../Slider";
import { useUrlParams } from "../UrlParams";
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
import { useBehavior } from "../useBehavior";
import { type ViewModel } from "../state/ViewModel.ts";
import { getSFUConfig } from "../livekit/auth/index.ts";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
/**
* The state of MatrixRTC's media key rotation.
@@ -168,18 +167,16 @@ export const DeveloperSettingsTab: FC<Props> = ({
enableExtendedLivekitLogsSetting,
);
const [customTransportUpdateError, setCustomLivekitUrlUpdateError] = useState<
string | null
>(null);
const [customTransport, setCustomTransport] = useSetting(
customTransportSetting,
);
const [customTransportTextBuffer, setCustomLivekitUrlTextBuffer] = useState(
() => transportText(customTransport),
const [customLivekitUrlUpdateError, setCustomLivekitUrlUpdateError] =
useState<string | null>(null);
const [customLivekitUrl, setCustomLivekitUrl] = useSetting(
customLivekitUrlSetting,
);
const [customLivekitUrlTextBuffer, setCustomLivekitUrlTextBuffer] =
useState(customLivekitUrl);
useEffect(() => {
setCustomLivekitUrlTextBuffer(transportText(customTransport));
}, [customTransport]);
setCustomLivekitUrlTextBuffer(customLivekitUrl);
}, [customLivekitUrl]);
const [muteAllAudio, setMuteAllAudio] = useSetting(muteAllAudioSetting);
@@ -494,23 +491,23 @@ export const DeveloperSettingsTab: FC<Props> = ({
<EditInPlace
onSubmit={(e) => e.preventDefault()}
helpLabel={
customTransport === null
? t("developer_mode.custom_transport.from_config")
: t("developer_mode.custom_transport.current") +
JSON.stringify(customTransport)
customLivekitUrl === null
? t("developer_mode.custom_livekit_url.from_config")
: t("developer_mode.custom_livekit_url.current_url") +
customLivekitUrl
}
label={t("developer_mode.custom_transport.label")}
saveButtonLabel={t("developer_mode.custom_transport.save")}
savingLabel={t("developer_mode.custom_transport.saving")}
cancelButtonLabel={t("developer_mode.custom_transport.reset")}
label={t("developer_mode.custom_livekit_url.label")}
saveButtonLabel={t("developer_mode.custom_livekit_url.save")}
savingLabel={t("developer_mode.custom_livekit_url.saving")}
cancelButtonLabel={t("developer_mode.custom_livekit_url.reset")}
onSave={useCallback(
async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
if (
roomId === undefined ||
customTransportTextBuffer === "" ||
customTransportTextBuffer === null
customLivekitUrlTextBuffer === "" ||
customLivekitUrlTextBuffer === null
) {
setCustomTransport(null);
setCustomLivekitUrl(null);
return;
}
@@ -521,29 +518,21 @@ export const DeveloperSettingsTab: FC<Props> = ({
if (userId === null || deviceId === null) {
throw new Error("Invalid user or device ID");
}
const transport = JSON.parse(customTransportTextBuffer);
// Validate the transport by attempting to get a token for it
await getSFUConfig({
await getSFUConfigWithOpenID(
client,
membership: { userId, deviceId, memberId: "" },
transport,
serverName: client.getDomain()!,
{ userId, deviceId, memberId: "" },
customLivekitUrlTextBuffer,
roomId,
slotId: "org.example.fake-slot#room",
role: "publisher",
logger,
});
setCustomLivekitUrlUpdateError(null);
setCustomTransport(transport);
} catch {
setCustomLivekitUrlUpdateError(
"invalid transport (did not update)",
);
setCustomLivekitUrlUpdateError(null);
setCustomLivekitUrl(customLivekitUrlTextBuffer);
} catch {
setCustomLivekitUrlUpdateError("invalid URL (did not update)");
}
},
[customTransportTextBuffer, setCustomTransport, client, roomId],
[customLivekitUrlTextBuffer, setCustomLivekitUrl, client, roomId],
)}
value={customTransportTextBuffer ?? ""}
value={customLivekitUrlTextBuffer ?? ""}
onChange={useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
setCustomLivekitUrlTextBuffer(event.target.value);
@@ -552,14 +541,14 @@ export const DeveloperSettingsTab: FC<Props> = ({
)}
onCancel={useCallback(
(e: React.FormEvent<HTMLFormElement>) => {
setCustomTransport(null);
setCustomLivekitUrl(null);
},
[setCustomTransport],
[setCustomLivekitUrl],
)}
serverInvalid={customTransportUpdateError !== null}
serverInvalid={customLivekitUrlUpdateError !== null}
>
{customTransportUpdateError !== null && (
<ErrorMessage>{customTransportUpdateError}</ErrorMessage>
{customLivekitUrlUpdateError !== null && (
<ErrorMessage>{customLivekitUrlUpdateError}</ErrorMessage>
)}
</EditInPlace>
<Separator />
@@ -697,10 +686,3 @@ export const DeveloperSettingsTab: FC<Props> = ({
</>
);
};
/**
* The text shown in the custom transport field for a given setting value.
*/
function transportText(transport: UnstableLivekitTransport | null): string {
return transport === null ? "" : JSON.stringify(transport);
}
+1 -1
View File
@@ -59,7 +59,7 @@ interface Props {
livekitRooms?: {
room: LivekitRoom;
url: string;
role: "publisher" | "subscriber";
isLocal?: boolean;
}[];
/** Only available while in a call. Used by the developer tab. */
developerSettingsVm?: ViewModel<DeveloperSettingsSnapshot>;
@@ -239,7 +239,7 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_label_1o4d9_60"
for="radix-_r_7_"
>
Custom transport JSON
Custom Livekit-url
</label>
<div
class="_controls_17lij_8"
+1 -26
View File
@@ -5,15 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, beforeEach, vi } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { describe, expect, it, beforeEach } from "vitest";
import { MatrixRTCMode } from "../config/ConfigOptions";
import {
Setting,
parseResolution,
seedSettingsFromConfig,
defaultMatrixRTCMode,
screenShareCodec,
screenShareResolution,
screenShareFramerate,
@@ -41,28 +38,6 @@ describe("parseResolution", () => {
});
});
describe("defaultMatrixRTCMode", () => {
it("is Compatibility when the build sets nothing", () => {
expect(defaultMatrixRTCMode(undefined)).toBe(MatrixRTCMode.Compatibility);
});
it.each([MatrixRTCMode.Compatibility, MatrixRTCMode.Matrix_2_0])(
"takes a valid build value (%s)",
(mode) => {
expect(defaultMatrixRTCMode(mode)).toBe(mode);
},
);
it("ignores an invalid build value and warns", () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
expect(defaultMatrixRTCMode("nonsense")).toBe(MatrixRTCMode.Compatibility);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("VITE_MATRIX_RTC_MODE"),
);
warn.mockRestore();
});
});
describe("Setting", () => {
describe("seedFromConfig", () => {
it("updates value when no localStorage value exists", () => {
+3 -15
View File
@@ -12,7 +12,6 @@ import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { type Behavior } from "../state/Behavior";
import { useBehavior } from "../useBehavior";
import { MatrixRTCMode } from "../config/ConfigOptions";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
export class Setting<T> {
public constructor(
@@ -149,24 +148,13 @@ export const enableExtendedLivekitLogs = new Setting<boolean>(
false,
);
export function defaultMatrixRTCMode(
buildValue: string | undefined = import.meta.env.VITE_MATRIX_RTC_MODE,
): MatrixRTCMode {
if (buildValue === undefined) return MatrixRTCMode.Compatibility;
if (Object.values<string>(MatrixRTCMode).includes(buildValue)) {
return buildValue as MatrixRTCMode;
}
logger.warn(`Ignoring invalid VITE_MATRIX_RTC_MODE: ${buildValue}`);
return MatrixRTCMode.Compatibility;
}
export const matrixRTCMode = new Setting<MatrixRTCMode>(
"matrix-rtc-mode",
defaultMatrixRTCMode(),
MatrixRTCMode.Compatibility,
);
export const customTransport = new Setting<UnstableLivekitTransport | null>(
"custom-transport",
export const customLivekitUrl = new Setting<string | null>(
"custom-livekit-url",
null,
);
-77
View File
@@ -1,77 +0,0 @@
/*
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { prefetchSounds } from "./soundUtils";
const bytesOf = (buffer: ArrayBuffer): number[] => [...new Uint8Array(buffer)];
describe("prefetchSounds", () => {
const fetchSpy = vi.fn<typeof fetch>();
beforeEach(() => {
vi.stubGlobal("fetch", fetchSpy);
});
afterEach(() => {
vi.unstubAllGlobals();
fetchSpy.mockReset();
});
it("fetches sounds given as ordinary URLs", async () => {
fetchSpy.mockResolvedValue(
new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
);
const sounds = await prefetchSounds({
beep: { ogg: "https://example.org/beep.ogg" },
});
expect(fetchSpy).toHaveBeenCalledWith("https://example.org/beep.ogg");
expect(bytesOf(sounds.beep)).toEqual([1, 2, 3]);
});
it("decodes base64 data: URLs without fetching them", async () => {
// "OggS" — the bytes 0x4f 0x67 0x67 0x53
const sounds = await prefetchSounds({
beep: { ogg: "data:audio/ogg;base64,T2dnUw==" },
});
expect(fetchSpy).not.toHaveBeenCalled();
expect(bytesOf(sounds.beep)).toEqual([0x4f, 0x67, 0x67, 0x53]);
});
it("leaves out a sound with a percent-encoded data: URL", async () => {
const sounds = await prefetchSounds({
beep: { ogg: "data:audio/ogg,Ogg%53" },
});
expect(fetchSpy).not.toHaveBeenCalled();
expect(sounds).toEqual({});
});
it("leaves out a sound whose response is not okay", async () => {
fetchSpy.mockResolvedValue(new Response(null, { status: 404 }));
const sounds = await prefetchSounds({
beep: { ogg: "https://example.org/missing.ogg" },
});
expect(sounds).toEqual({});
});
it("leaves out a sound that fails to load, keeping the others", async () => {
fetchSpy.mockRejectedValue(new TypeError("Failed to fetch"));
const sounds = await prefetchSounds({
beep: { ogg: "https://example.org/beep.ogg" },
boop: { ogg: "data:audio/ogg;base64,T2dnUw==" },
});
expect(Object.keys(sounds)).toEqual(["boop"]);
});
it("leaves out a sound with a malformed data: URL", async () => {
const sounds = await prefetchSounds({
beep: { ogg: "data:audio/ogg;base64" },
});
expect(sounds).toEqual({});
});
});
+7 -46
View File
@@ -32,43 +32,6 @@ function getPreferredAudioFormat(): "ogg" | "mp3" {
const preferredFormat = getPreferredAudioFormat();
/**
* Decode a `data:` URL into the bytes it carries.
*
* The component build inlines the sound files as `data:` URLs. Those could be
* `fetch`ed like any other URL, but that fetch is subject to the host page's
* Content Security Policy, and `connect-src` lists rarely allow `data:` (a `*`
* source does not cover it). Reading the bytes out of the URL directly needs no
* network access, so no policy can stop it.
* @param url The `data:` URL to decode.
* @returns The decoded bytes.
*/
function decodeDataUrl(url: string): ArrayBuffer {
const separator = url.indexOf(",");
if (!url.startsWith("data:") || separator === -1)
throw new Error("Not a valid data: URL");
const header = url.slice("data:".length, separator);
// Vite only percent-encodes SVGs; every other inlined asset is base64.
if (!header.split(";").includes("base64"))
throw new Error("Only base64 data: URLs are supported");
// `decoded` is a binary string: one char per byte
const decoded = atob(url.slice(separator + 1));
const bytes = Uint8Array.from(decoded, (c) => c.charCodeAt(0));
return bytes.buffer;
}
/**
* Load the bytes of a single sound file.
* @param url Where the sound is: an ordinary URL to fetch, or a `data:` URL.
* @returns The bytes, or null if the sound could not be loaded.
*/
async function loadSound(url: string): Promise<ArrayBuffer | null> {
if (url.startsWith("data:")) return decodeDataUrl(url);
const response = await fetch(url);
if (isFailure(response)) return null;
return await response.arrayBuffer();
}
/**
* Prefetch sounds to be used by the AudioContext. This can
* be called outside the scope of a component to ensure the
@@ -84,20 +47,18 @@ export async function prefetchSounds<S extends string>(
Object.entries(sounds).map(async ([name, file]) => {
const { mp3, ogg } = file as SoundDefinition;
// Use preferred format, fallback to ogg if no mp3 is provided.
const url = preferredFormat === "ogg" ? ogg : (mp3 ?? ogg);
let buffer: ArrayBuffer | null = null;
try {
buffer = await loadSound(url);
} catch (e) {
logger.warn(`Could not load sound ${name}`, e);
}
if (buffer === null) {
// Load an audio file
const response = await fetch(
preferredFormat === "ogg" ? ogg : (mp3 ?? ogg),
);
if (isFailure(response)) {
// If the sound doesn't load, it's not the end of the world. We won't play
// the sound when requested, but it's better than failing the whole application.
logger.warn(`Could not load sound ${name}, response was not okay`);
return;
}
buffers[name] = buffer;
// Decode it
buffers[name] = await response.arrayBuffer();
}),
);
return buffers as Record<S, ArrayBuffer>;
+12 -12
View File
@@ -31,7 +31,7 @@ import {
type CallMembership,
type IRTCNotificationContent,
MatrixRTCSessionEvent,
type UnstableLivekitTransport,
type LivekitTransport,
} from "matrix-js-sdk/lib/matrixrtc";
import { deepCompare } from "matrix-js-sdk/lib/utils";
@@ -97,7 +97,7 @@ vi.mock(
"../state/CallViewModel/localMember/localTransport",
async (importOriginal) => ({
...(await importOriginal()),
makeTransport: async (): Promise<UnstableLivekitTransport> =>
makeTransport: async (): Promise<LivekitTransport> =>
Promise.resolve(exampleTransport),
}),
);
@@ -176,7 +176,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
case "grid":
return combineLatest(
[
l.spotlight?.media$ ?? constant(undefined),
l.spotlight?.vm.media$ ?? constant(undefined),
...l.grid.map((vm) => vm.media$),
],
(spotlight, ...grid) => ({
@@ -188,7 +188,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
case "spotlight-landscape":
case "spotlight-portrait":
return combineLatest(
[l.spotlight.media$, ...l.grid.map((vm) => vm.media$)],
[l.spotlight.vm.media$, ...l.grid.map((vm) => vm.media$)],
(spotlight, ...grid) => ({
type: l.type,
spotlight: spotlight.map((vm) => vm.id),
@@ -197,7 +197,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
);
case "spotlight-expanded":
return combineLatest(
[l.spotlight.media$, l.pip?.media$ ?? constant(undefined)],
[l.spotlight.media$, l.pip?.vm.media$ ?? constant(undefined)],
(spotlight, pip) => ({
type: l.type,
spotlight: spotlight.map((vm) => vm.id),
@@ -206,7 +206,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
);
case "one-on-one-desktop":
return combineLatest(
[l.spotlight.media$, l.pip.media$],
[l.spotlight.media$, l.pip.vm.media$],
(spotlight, pip) => ({
type: l.type,
spotlight: spotlight.id,
@@ -217,8 +217,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
return combineLatest(
[
l.spotlight.media$,
l.pip?.media$ ?? constant(undefined),
l.pipSize$,
l.pip?.vm.media$ ?? constant(undefined),
l.pip?.size$ ?? constant(undefined),
],
(spotlight, pip, pipSize) => ({
type: l.type,
@@ -480,7 +480,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "lg",
pipSize: undefined,
},
b: {
type: "one-on-one-mobile",
@@ -518,7 +518,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "lg",
pipSize: undefined,
},
});
// It wouldn't be clear whether Alice or Bob is the remote video tile,
@@ -566,7 +566,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "sm",
pipSize: undefined,
},
b: {
type: "spotlight-landscape",
@@ -765,7 +765,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "lg",
pipSize: undefined,
},
b: {
// In a larger window, expect the one-on-one desktop layout
+313 -157
View File
@@ -12,7 +12,6 @@ import {
ExternalE2EEKeyProvider,
type Room as LivekitRoom,
type RoomOptions,
type RemoteParticipant,
} from "livekit-client";
import { type Room as MatrixRoom } from "matrix-js-sdk";
import {
@@ -31,6 +30,7 @@ import {
scan,
startWith,
Subject,
switchAll,
switchMap,
switchScan,
take,
@@ -39,14 +39,11 @@ import {
timer,
takeUntil,
from,
concatWith,
catchError,
switchAll,
} from "rxjs";
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
MembershipManagerEvent,
type UnstableLivekitTransport,
type LivekitTransportConfig,
type MatrixRTCSession,
type RTCCallIntent,
type RTCNotificationType,
@@ -59,7 +56,6 @@ import {
createToggle$,
filterBehavior,
generateItems,
mapScoped,
pauseWhen,
} from "../../utils/observable";
import {
@@ -87,7 +83,6 @@ import {
type ReactionOption,
} from "../../reactions";
import { shallowEquals as shallowArrayEquals } from "../../utils/array";
import { shallowEquals as shallowObjectEquals } from "../../utils/object";
import { type MediaDevices } from "../MediaDevices";
import { constant, type Behavior } from "../Behavior";
import { E2eeType } from "../../e2ee/e2eeType";
@@ -97,26 +92,28 @@ import { HeaderStyle, type UrlParams } from "../../UrlParams";
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { type HostBridge, nullHostBridge } from "../../HostBridge";
import {
type Alignment,
layoutShallowEquals,
type GridLayoutMedia,
type Layout,
type LayoutMedia,
type WindowMode,
type OneOnOneDesktopLayoutMedia,
type OneOnOneMobileLayoutMedia,
type SpotlightExpandedLayoutMedia,
type SpotlightLandscapeLayoutMedia,
type SpotlightPortraitLayoutMedia,
} from "../layout-types.ts";
import {
chooseSpotlightSpeaker,
computeLayoutMedia,
computeSpotlight,
type OneOnOneMedia,
} from "../layoutMedia.ts";
import { ElementCallError, UnknownCallError } from "../../utils/errors.ts";
import { Epoch, type ObservableScope } from "../ObservableScope.ts";
import { type Epoch, type ObservableScope } from "../ObservableScope.ts";
import { createHomeserverConnected$ } from "./localMember/HomeserverConnected.ts";
import {
createLocalMembership$,
enterRTCSession,
TransportState,
} from "./localMember/LocalMember.ts";
import { getLocalTransport } from "./localMember/LocalTransport.ts";
import {
getLocalTransport,
type LocalTransport,
} from "./localMember/LocalTransport.ts";
import {
createKeyRotationSuppressed$,
createMemberships$,
@@ -127,9 +124,9 @@ import {
ECConnectionFactory,
} from "./remoteMembers/ConnectionFactory.ts";
import {
ConnectionMap,
createConnectionMap$,
} from "./remoteMembers/ConnectionMap.ts";
type ConnectionManagerData,
createConnectionManager$,
} from "./remoteMembers/ConnectionManager.ts";
import {
createRemoteMatrixLivekitMembers$,
type LocalMatrixLivekitMember,
@@ -165,7 +162,7 @@ import {
type RingingMediaViewModel,
} from "../media/RingingMediaViewModel.ts";
import { type GridTileViewModel } from "../TileViewModel.ts";
import { type TransportLocator } from "../../livekit/auth";
import { createFloatingTile } from "../FloatingTile.ts";
//TODO
// Larger rename
@@ -218,7 +215,7 @@ export interface CallViewModelOptions {
*/
windowSize$: Behavior<{ width: number; height: number }>;
/** Optional value overriding the local transport, for testing purposes. */
localTransport?: TransportLocator;
localTransport?: LocalTransport;
/** Optional value overriding the connection factory, for testing purposes. */
connectionFactory?: ConnectionFactory;
/** The version & compatibility mode of MatrixRTC that we should use. */
@@ -266,10 +263,21 @@ export function callViewModelOptionsFromParams(
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
export const THROTTLE_SOUND_EFFECT_MS = 500;
// This is the number of participants that we think constitutes a "small" call
// on mobile. No spotlight tile should be shown below this threshold.
const smallMobileCallThreshold = 3;
// How long the footer should be shown for when hovering over or interacting
// with the interface
const showFooterMs = 4000;
/**
* The general shape of the space the call is drawn in. Called a window because
* that is what it is in the standalone app; for a component it is the container
* the host gave us, which may be a small corner of a large window.
*/
export type WindowMode = "normal" | "narrow" | "flat" | "pip";
interface LayoutScanState {
layout: Layout | null;
overflowing: boolean;
@@ -279,7 +287,7 @@ interface LayoutScanState {
export type LivekitRoomItem = {
livekitRoom: LivekitRoom;
participants: string[];
transport: TransportLocator;
url: string;
};
/**
@@ -376,7 +384,7 @@ export interface CallViewModel {
* key is generated when someone joins or leaves.
*/
keyRotationSuppressed$: Behavior<boolean>;
allConnections$: Behavior<Connection[]>;
allConnections$: Behavior<ConnectionManagerData>;
/** Participants sorted by livekit room so they can be used in the audio rendering */
livekitRoomItems$: Behavior<LivekitRoomItem[]>;
/** use the layout instead, this is just for the sdk export. */
@@ -567,14 +575,20 @@ export function createCallViewModel$(
const localTransport$ = options.localTransport
? constant(options.localTransport)
: from(getLocalTransport(client));
: from(
getLocalTransport({
ownMembershipIdentity,
client,
roomId: matrixRoom.roomId,
matrixRTCMode,
}),
);
const connectionFactory =
options.connectionFactory ??
new ECConnectionFactory(
client,
matrixRoom.roomId,
matrixRTCSession.slotId,
mediaDevices,
trackProcessorState$,
livekitKeyProvider,
@@ -582,24 +596,14 @@ export function createCallViewModel$(
options.livekitRoomFactory,
);
const connectionMap$ = scope.behavior<Epoch<ConnectionMap>>(
localTransport$.pipe(
catchError(() => NEVER), // Swallow errors
concatWith(NEVER), // So the Observable doesn't complete prematurely
mapScoped("connectionMap$", (localTransport, scope) =>
createConnectionMap$({
scope,
connectionFactory,
localTransport,
remoteTransports$: membershipsAndTransports.transports$,
logger,
ownMembershipIdentity,
}),
),
switchAll(),
),
new Epoch(new ConnectionMap()),
);
const connectionManager = createConnectionManager$({
scope: scope,
connectionFactory: connectionFactory,
localTransport$,
remoteTransports$: membershipsAndTransports.transports$,
logger: logger,
ownMembershipIdentity,
});
const remoteMatrixLivekitMembers$: Behavior<
Epoch<RemoteMatrixLivekitMember[]>
@@ -607,7 +611,7 @@ export function createCallViewModel$(
scope: scope,
membershipsWithTransport$:
membershipsAndTransports.membershipsWithTransport$,
connectionMap$,
connectionManager: connectionManager,
localUser: { userId, deviceId },
});
@@ -620,7 +624,7 @@ export function createCallViewModel$(
),
muteStates,
joinMatrixRTC: (
transport: UnstableLivekitTransport,
transport: LivekitTransportConfig,
delayedLeaveTimings: ResolvedDelayedLeaveTimings,
) => {
return enterRTCSession(
@@ -642,11 +646,13 @@ export function createCallViewModel$(
mediaDevices,
muteStates,
trackProcessorState$,
logger.getChild(`[Publisher ${JSON.stringify(connection.transport)}]`),
logger.getChild(
"[Publisher " + connection.transport.livekit_service_url + "]",
),
controlledAudioDevices,
);
},
connectionMap$,
connectionManager,
client,
matrixRTCSession,
localTransport$,
@@ -665,6 +671,7 @@ export function createCallViewModel$(
).pipe(map(([delayId]) => delayId ?? null)),
matrixRTCSession.delayId ?? null,
),
matrixRTCMode,
logger: logger.getChild(`[${Date.now()}]`),
});
@@ -732,47 +739,51 @@ export function createCallViewModel$(
});
const allConnections$ = scope.behavior(
connectionMap$.pipe(map((d) => d.value.getConnections())),
connectionManager.connectionManagerData$.pipe(map((d) => d.value)),
);
const livekitRoomItems$ = scope.behavior<LivekitRoomItem[]>(
const livekitRoomItems$ = scope.behavior(
remoteMatrixLivekitMembers$.pipe(
// Flatten the members to a snapshot of their current connections and
// participant objects
switchMap(({ value: members }) => {
if (members.length === 0) return of([]);
return combineLatest(
members.map((m) =>
combineLatest(
[m.connection$, m.participant.value$],
(connection, participant) => ({ connection, participant }),
switchMap((members) => {
const a$ = combineLatest(
members.value.map((member) =>
combineLatest([member.connection$, member.participant.value$]).pipe(
map(([connection, participant]) => {
// do not render audio for local participant
if (!connection || !participant || participant.isLocal)
return null;
const livekitRoom = connection.livekitRoom;
const url = connection.transport.livekit_service_url;
return {
url,
livekitRoom,
participant: participant.identity,
};
}),
),
),
);
return a$;
}),
// Keep only those with a defined connection and participant
map((members) =>
members.filter(
(
m,
): m is { connection: Connection; participant: RemoteParticipant } =>
m.connection !== null && m.participant !== null,
),
),
// Group members by connection
map((members) =>
[...Map.groupBy(members, (m) => m.connection)].map(
([connection, members]) => ({
transport: {
transport: connection.transport,
serverName: connection.serverName,
},
livekitRoom: connection.livekitRoom,
participants: members.map((m) => m.participant.identity),
}),
),
members.reduce<LivekitRoomItem[]>((acc, curr) => {
if (!curr) return acc;
const existing = acc.find((item) => item.url === curr.url);
if (existing) {
existing.participants.push(curr.participant);
} else {
acc.push({
livekitRoom: curr.livekitRoom,
participants: [curr.participant],
url: curr.url,
});
}
return acc;
}, []),
),
),
[],
);
const handsRaised$ = scope.behavior(
@@ -829,8 +840,8 @@ export function createCallViewModel$(
livekitRoom$: scope.behavior(
connection$.pipe(map((c) => c?.livekitRoom)),
),
transport$: scope.behavior(
connection$.pipe(map((c) => c?.transport)),
focusUrl$: scope.behavior(
connection$.pipe(map((c) => c?.transport.livekit_service_url)),
),
mediaDevices,
pretendToBeDisconnected$: localMembership.reconnecting$,
@@ -970,16 +981,33 @@ export function createCallViewModel$(
mediaItems.length === 0
? of([])
: combineLatest(
mediaItems.map((media) =>
media.speaking$.pipe(map((speaking) => ({ media, speaking }))),
mediaItems.map((m) =>
m.speaking$.pipe(map((s) => [m, s] as const)),
),
),
),
scan<
{ media: UserMediaViewModel; speaking: boolean }[],
(readonly [UserMediaViewModel, boolean])[],
UserMediaViewModel | undefined,
undefined
>(chooseSpotlightSpeaker, undefined),
>((prev, mediaItems) => {
// Only remote users that are still in the call should be sticky
const [stickyMedia, stickySpeaking] =
(!prev?.local && mediaItems.find(([m]) => m === prev)) || [];
// Decide who to spotlight:
// If the previous speaker is still speaking, stick with them rather
// than switching eagerly to someone else
return stickySpeaking
? stickyMedia!
: // Otherwise, select any remote user who is speaking
(mediaItems.find(([m, s]) => !m.local && s)?.[0] ??
// Otherwise, stick with the person who was last speaking
stickyMedia ??
// Otherwise, spotlight an arbitrary remote user
mediaItems.find(([m]) => !m.local)?.[0] ??
// Otherwise, spotlight the local user
mediaItems.find(([m]) => m.local)?.[0]);
}, undefined),
),
);
@@ -1023,12 +1051,33 @@ export function createCallViewModel$(
),
);
const spotlightAndPip$ = scope.behavior(
combineLatest(
[ringingMedia$, screenShares$, spotlightSpeaker$, localUserMediaForPip$],
(ringing, screenShares, speaker, localPip) =>
computeSpotlight({ ringing, screenShares, speaker, localPip }),
).pipe(distinctUntilChanged(shallowObjectEquals)),
const spotlightAndPip$ = scope.behavior<{
spotlight: MediaViewModel[];
pip$: Observable<UserMediaViewModel | undefined>;
}>(
ringingMedia$.pipe(
switchMap((ringingMedia) => {
if (ringingMedia !== null)
return of({ spotlight: [ringingMedia], pip$: localUserMediaForPip$ });
return screenShares$.pipe(
switchMap((screenShares) => {
if (screenShares.length > 0)
return of({ spotlight: screenShares, pip$: spotlightSpeaker$ });
return spotlightSpeaker$.pipe(
map((speaker) => ({
spotlight: speaker ? [speaker] : [],
// Hide PiP if redundant (i.e. if local user is already in spotlight)
pip$: localUserMediaForPip$.pipe(
map((m) => (m === speaker ? undefined : m)),
),
})),
);
}),
);
}),
),
);
const spotlight$ = scope.behavior<MediaViewModel[]>(
@@ -1086,7 +1135,56 @@ export function createCallViewModel$(
hasRemoteScreenShares$,
);
const oneOnOneLayoutMedia$: Behavior<OneOnOneMedia | null> = scope.behavior(
const gridLayoutMedia$: Observable<GridLayoutMedia> = combineLatest(
[grid$, spotlight$],
(grid, spotlight) => ({
type: "grid",
edgeToEdge: false,
spotlight: spotlight.some((vm) => vm.type === "screen share")
? spotlight
: undefined,
grid,
}),
);
const spotlightLandscapeLayoutMedia$ = (
edgeToEdge: boolean,
): Observable<SpotlightLandscapeLayoutMedia> =>
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
type: "spotlight-landscape",
edgeToEdge,
spotlight,
grid,
}));
const spotlightPortraitLayoutMedia$: Observable<SpotlightPortraitLayoutMedia> =
combineLatest([grid$, spotlight$], (grid, spotlight) => ({
type: "spotlight-portrait",
edgeToEdge: false,
spotlight,
grid,
}));
const spotlightExpandedLayoutMedia$ = (
edgeToEdge: boolean,
): Observable<SpotlightExpandedLayoutMedia> =>
spotlightAndPip$.pipe(
switchMap(({ spotlight, pip$ }) =>
pip$.pipe(
map((pip) => ({
type: "spotlight-expanded" as const,
edgeToEdge,
spotlight,
pip: pip ?? undefined,
})),
),
),
);
const oneOnOneLayoutMedia$: Behavior<{
local: LocalUserMediaViewModel;
remote: UserMediaViewModel | RingingMediaViewModel;
} | null> = scope.behavior(
combineLatest([userMedia$, screenShares$]).pipe(
switchMap(([userMedia, screenShares]) => {
// One-on-one layout only supports 2 user media, no screen shares
@@ -1124,12 +1222,47 @@ export function createCallViewModel$(
),
);
const localVideoEnabled$ = scope.behavior<boolean>(
const oneOnOneDesktopLayoutMedia$: Observable<OneOnOneDesktopLayoutMedia | null> =
oneOnOneLayoutMedia$.pipe(
switchMap((media) =>
media === null ? of(false) : media.local.videoEnabled$,
),
),
map((media) => {
if (media === null) return null;
return media.remote.type === "ringing"
? {
type: "one-on-one-desktop" as const,
edgeToEdge: false,
spotlight: media.local,
pip: media.remote,
}
: {
type: "one-on-one-desktop" as const,
edgeToEdge: false,
spotlight: media.remote,
pip: media.local,
};
}),
);
const oneOnOneMobileLayoutMedia$: Observable<OneOnOneMobileLayoutMedia | null> =
oneOnOneLayoutMedia$.pipe(
switchMap((media) => {
if (media === null) return of(null);
return media.local.videoEnabled$.pipe(
map((videoEnabled) => ({
type: "one-on-one-mobile" as const,
edgeToEdge: true as const,
spotlight: media.remote,
pip: videoEnabled ? media.local : undefined,
})),
);
}),
);
const pipLayoutMedia$: Observable<LayoutMedia> = spotlight$.pipe(
map((spotlight) => ({
type: "pip",
edgeToEdge: platform !== "desktop",
spotlight,
})),
);
spotlight$
@@ -1164,38 +1297,66 @@ export function createCallViewModel$(
* The media to be used to produce a layout.
*/
const layoutMedia$ = scope.behavior<LayoutMedia>(
// We deliberately don't use combineLatest here. Several of the inputs
// below derive from the same source. So one upstream change cascades
// through them one at a time. combineLatest would see each step of the
// cascade and emit layouts built from inputs that never coexisted.
// Instead, we treat the emissions as a mere "something changed" signal
// and read every input's current value. The derived behaviors were
// subscribed before this one and a BehaviorSubject updates its value
// before notifying. So by the time the first signal arrives every value
// is already consistent. The duplicate signals from the rest of the
// cascade are dropped by distinctUntilChanged.
merge(
windowMode$,
layoutSwitchVm.layout$,
spotlightExpanded$,
oneOnOneLayoutMedia$,
localVideoEnabled$,
spotlightAndPip$,
grid$,
).pipe(
map(() =>
computeLayoutMedia({
windowMode: windowMode$.value,
layoutMode: layoutSwitchVm.layout$.value,
spotlightExpanded: spotlightExpanded$.value,
oneOnOne: oneOnOneLayoutMedia$.value,
localVideoEnabled: localVideoEnabled$.value,
...spotlightAndPip$.value,
grid: grid$.value,
desktop: platform === "desktop",
}),
),
distinctUntilChanged(shallowObjectEquals<LayoutMedia>),
windowMode$.pipe(
switchMap((windowMode) => {
switch (windowMode) {
case "normal":
return layoutSwitchVm.layout$.pipe(
switchMap((layout) => {
switch (layout) {
case "grid":
return oneOnOneDesktopLayoutMedia$.pipe(
switchMap((oneOnOne) =>
oneOnOne === null ? gridLayoutMedia$ : of(oneOnOne),
),
);
case "spotlight":
return spotlightExpanded$.pipe(
switchMap((expanded) =>
expanded
? spotlightExpandedLayoutMedia$(false)
: spotlightLandscapeLayoutMedia$(false),
),
);
}
}),
);
case "narrow":
return oneOnOneMobileLayoutMedia$.pipe(
switchMap((oneOnOne) =>
oneOnOne === null
? combineLatest([grid$, spotlight$], (grid, spotlight) =>
grid.length > smallMobileCallThreshold ||
spotlight.some((vm) => vm.type === "screen share")
? spotlightPortraitLayoutMedia$
: gridLayoutMedia$,
).pipe(switchAll())
: of(oneOnOne),
),
);
case "flat":
return oneOnOneMobileLayoutMedia$.pipe(
switchMap((oneOnOne) =>
oneOnOne === null
? layoutSwitchVm.layout$.pipe(
switchMap((layout) => {
switch (layout) {
case "grid":
// Yes, grid mode actually gets you a "spotlight" layout in
// this window mode.
return spotlightLandscapeLayoutMedia$(true);
case "spotlight":
return spotlightExpandedLayoutMedia$(true);
}
}),
)
: of(oneOnOne),
),
);
case "pip":
return pipLayoutMedia$;
}
}),
),
);
@@ -1387,32 +1548,28 @@ export function createCallViewModel$(
);
/**
* The alignment of the floating spotlight tile, if present.
* State of the floating spotlight tile, if present.
*/
const spotlightAlignment$ = new BehaviorSubject<Alignment>({
const floatingSpotlightTile = createFloatingTile(scope, {
inline: "end",
block: "end",
});
/**
* The size of the small picture-in-picture tile, if present, when in portrait.
* State of the small picture-in-picture tile, if present, when in landscape.
*/
const portraitPipSize$ = scope.behavior(
showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))),
);
/**
* The alignment of the small picture-in-picture tile, if present, when in portrait.
*/
const portraitPipAlignment$ = new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
});
/**
* The alignment of the small picture-in-picture tile, if present, when in landscape.
*/
const landscapePipAlignment$ = new BehaviorSubject<Alignment>({
const landscapePipTile = createFloatingTile(scope, {
inline: "end",
block: "start",
});
/**
* State of the small picture-in-picture tile, if present, when in portrait.
*/
const portraitPipTile = {
...createFloatingTile(scope, { inline: "end", block: "end" }),
size$: scope.behavior(
showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))),
),
};
// There is a cyclical dependency here: the layout algorithms want to know
// which tiles are on screen, but to know which tiles are on screen we have to
@@ -1442,7 +1599,7 @@ export function createCallViewModel$(
case "spotlight-portrait":
[layout, newTiles] = gridLikeLayout(
media,
spotlightAlignment$,
floatingSpotlightTile,
visibleTiles,
setVisibleTiles,
prevTiles,
@@ -1452,26 +1609,25 @@ export function createCallViewModel$(
case "spotlight-expanded":
[layout, newTiles] = spotlightExpandedLayout(
media,
landscapePipAlignment$,
landscapePipTile,
prevTiles,
);
break;
case "one-on-one-desktop":
[layout, newTiles] = oneOnOneDesktopLayout(
media,
landscapePipAlignment$,
landscapePipTile,
prevTiles,
);
pip = layout.pip;
pip = layout.pip.vm;
break;
case "one-on-one-mobile":
[layout, newTiles] = oneOnOneMobileLayout(
media,
portraitPipSize$,
portraitPipAlignment$,
portraitPipTile,
prevTiles,
);
pip = layout.pip;
pip = layout.pip?.vm;
break;
case "pip":
[layout, newTiles] = pipLayout(media, prevTiles);
@@ -1496,7 +1652,7 @@ export function createCallViewModel$(
layoutInternals$.pipe(
map(({ layout }) => layout),
// Drop redundant layout updates before they would hit React.
distinctUntilChanged(shallowObjectEquals<Layout>),
distinctUntilChanged<Layout>(layoutShallowEquals),
),
);
@@ -1662,9 +1818,9 @@ export function createCallViewModel$(
returnObject.publishError = value.media;
else if (
typeof value.media === "object" &&
value.media.connection.state === "error"
value.media.connection instanceof ElementCallError
)
returnObject.connectionError = value.media.connection.error;
returnObject.connectionError = value.media.connection;
return returnObject;
}),
),
@@ -30,7 +30,8 @@ import {
type CallViewModelOptions,
} from "./CallViewModel";
import {
exampleTransportLocator,
exampleSfuConfig,
exampleTransport,
mockConfig,
MockConnection,
mockLivekitRoom,
@@ -122,8 +123,6 @@ export function withCallViewModel(mode: MatrixRTCMode) {
};
const room = mockMatrixRoom({
client: new (class extends EventEmitter {
public readonly baseUrl = "https://matrix.example.com";
public getUserId(): string | undefined {
return localRtcMember.userId;
}
@@ -210,26 +209,26 @@ export function withCallViewModel(mode: MatrixRTCMode) {
livekitRoomFactory,
connectionState$,
windowSize$,
localTransport: exampleTransportLocator,
localTransport: {
transport: exampleTransport,
sfuConfig: exampleSfuConfig,
},
connectionFactory: {
createConnection(
scope,
role,
transport,
serverName,
ownMembershipIdentity,
logger,
sfuConfig,
) {
return new MockConnection(
{
scope,
role,
transport,
serverName,
ownMembershipIdentity,
existingSFUConfig: sfuConfig,
client: room.client,
roomId: room.roomId,
slotId: "m.call#ROOM",
livekitRoomFactory,
},
logger,
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import {
Status as RTCMemberStatus,
type UnstableLivekitTransport,
type LivekitTransportConfig,
type MatrixRTCSession,
} from "matrix-js-sdk/lib/matrixrtc";
import {
@@ -21,13 +21,9 @@ import {
beforeEach,
afterEach,
} from "vitest";
import { BehaviorSubject, of, Subject } from "rxjs";
import { BehaviorSubject, map, of, Subject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import {
ConnectionState as LivekitConnectionState,
type LocalParticipant,
type LocalTrack,
} from "livekit-client";
import { type LocalParticipant, type LocalTrack } from "livekit-client";
import fetchMock from "fetch-mock";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics";
@@ -57,15 +53,13 @@ import {
import { MatrixRTCTransportMissingError } from "../../../utils/errors";
import { Epoch, ObservableScope } from "../../ObservableScope";
import { constant } from "../../Behavior";
import { ConnectionMap } from "../remoteMembers/ConnectionMap";
import {
type ConnectionState,
type Connection,
} from "../remoteMembers/Connection";
import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
import { ConnectionState, type Connection } from "../remoteMembers/Connection";
import { type Publisher } from "./Publisher";
import { initializeWidget } from "../../../widget";
import { nullHostBridge } from "../../../HostBridge";
import * as livekitAuth from "../../../livekit/auth";
import { type LocalTransport } from "./LocalTransport";
import * as openIDSFU from "../../../livekit/openIDSFU";
initializeWidget();
@@ -116,7 +110,6 @@ const delegatedTimings: ResolvedDelayedLeaveTimings = {
};
const mockedClient = {
baseUrl: "https://matrix.example.org",
getDomain: vi.fn().mockReturnValue("example.org"),
getDeviceId: vi.fn().mockReturnValue("AAAA"),
getOpenIdToken: vi.fn().mockResolvedValue({
@@ -125,11 +118,11 @@ const mockedClient = {
matrix_server_name: "localhost",
expires_in: 10000,
}),
_unstable_delegateDelayedLeave: vi.fn().mockResolvedValue({}),
};
describe("enterRTCSession", () => {
const transport: UnstableLivekitTransport = {
const transport: LivekitTransportConfig = {
livekit_alias: "roomId",
livekit_service_url: "http://my-livekit-service-url.com",
type: "livekit",
};
@@ -146,7 +139,7 @@ describe("enterRTCSession", () => {
client: mockedClient,
},
memberships: [],
join: vi.fn(),
joinRTCSession: vi.fn(),
}) as unknown as MatrixRTCSession;
beforeEach(() =>
@@ -158,18 +151,19 @@ describe("enterRTCSession", () => {
it("It joins the correct Session", () => {
enterRTCSession(mockedSession, ownMemberMock, transport, options);
expect(mockedSession.join).toHaveBeenLastCalledWith(
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
{
deviceId: "DEVICE",
memberId: "@alice:example.org:DEVICE",
userId: "@alice:example.org",
},
[transport],
[],
transport,
expect.objectContaining({ manageMediaKeys: true }),
);
});
it("passes keyRotationParticipantLimit from config to join", () => {
it("passes keyRotationParticipantLimit from config to joinRTCSession", () => {
mockConfig({
livekit: { livekit_service_url: "http://my-default-service-url.com" },
matrix_rtc_session: {
@@ -182,8 +176,9 @@ describe("enterRTCSession", () => {
enterRTCSession(mockedSession, ownMemberMock, transport, options);
expect(mockedSession.join).toHaveBeenLastCalledWith(
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
expect.any(Object),
[],
expect.any(Object),
expect.objectContaining({
keyRotationParticipantLimit: 50,
@@ -194,7 +189,8 @@ describe("enterRTCSession", () => {
it("uses the specified delayed leave timings", () => {
enterRTCSession(mockedSession, ownMemberMock, transport, options);
expect(mockedSession.join).toHaveBeenLastCalledWith(
expect(mockedSession.joinRTCSession).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.objectContaining({
@@ -214,7 +210,7 @@ describe("LocalMembership", () => {
}),
matrixRTCSession: {
updateCallIntent: vi.fn().mockReturnValue(Promise.resolve()),
leave: vi.fn(),
leaveRoomSession: vi.fn(),
} as unknown as MatrixRTCSession,
muteStates: mockMuteStates(),
trackProcessorState$: constant({
@@ -222,10 +218,7 @@ describe("LocalMembership", () => {
processor: undefined,
}),
logger: logger,
createPublisherFactory: vi.fn().mockReturnValue({
destroy: vi.fn(),
createAndSetupTracks: vi.fn().mockResolvedValue(undefined),
}),
createPublisherFactory: vi.fn(),
joinMatrixRTC: async (): Promise<void> => {},
homeserverConnected: {
combined$: constant<[boolean, HomeserverDisconnectReason | null]>([
@@ -259,16 +252,29 @@ describe("LocalMembership", () => {
afterEach(async () => {
void (await fetchMock.flush());
fetchMock.reset();
vi.clearAllMocks();
});
it("throws error on missing RTC config error", () => {
withTestScheduler(({ scope, hot, expectObservable }) => {
const localTransport$ = scope.behavior<null | LivekitTransportConfig>(
hot("1ms #", {}, new MatrixRTCTransportMissingError("domain.com")),
null,
);
// we do not need any connection data since we want to fail before reaching that.
const mockConnectionManager = {
transports$: scope.behavior(
localTransport$.pipe(map((t) => new Epoch([t]))),
),
connectionManagerData$: constant(
new Epoch(new ConnectionManagerData()),
),
};
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
// we do not need any connection data since we want to fail before reaching that.
connectionMap$: constant(new Epoch(new ConnectionMap())),
connectionManager: mockConnectionManager,
localTransport$: hot(
"1ms #",
{},
@@ -284,19 +290,24 @@ describe("LocalMembership", () => {
});
it("logs if callIntent cannot be updated", async () => {
const scope = new ObservableScope();
const mockConnectionManager = {
transports$: constant(new Epoch([])),
connectionManagerData$: constant(new Epoch(new ConnectionManagerData())),
};
async function reject(): Promise<void> {
return Promise.reject(new Error("Not connected yet"));
}
const localMembership = createLocalMembership$({
scope: testScope(),
scope,
...defaultCreateLocalMemberValues,
matrixRTCSession: {
slotId: "m.call#ROOM",
updateCallIntent: vi.fn().mockImplementation(reject),
leave: vi.fn(),
leaveRoomSession: vi.fn(),
},
connectionMap$: constant(new Epoch(new ConnectionMap())),
localTransport$: constant(mockTransportLocator),
connectionManager: mockConnectionManager,
localTransport$: constant(mockTransport),
});
const expextedLog =
"'not connected yet' while updating the call intent (this is expected on startup)";
@@ -305,18 +316,22 @@ describe("LocalMembership", () => {
await flushPromises();
defaultCreateLocalMemberValues.muteStates.video.setEnabled$.value?.(true);
expect(internalLogger).toHaveBeenCalledWith(expextedLog);
scope.end();
});
const mockTransport: UnstableLivekitTransport = {
type: "livekit",
url: "https://sfu.example.org",
livekit_service_url: "https://jwt.example.org",
};
const mockTransportConfig = {
livekit_service_url: "a",
} as LivekitTransportConfig;
const mockTransportLocator: livekitAuth.TransportLocator = {
transport: mockTransport,
serverName: "example.org",
};
const mockTransport = {
transport: mockTransportConfig,
sfuConfig: {
jwt: "foo",
livekitAlias: "bar",
livekitIdentity: "baz",
url: "bro",
},
} as LocalTransport;
const connectionTransportAConnected = {
livekitRoom: mockLivekitRoom({
@@ -325,52 +340,31 @@ describe("LocalMembership", () => {
trackPublications: [],
} as unknown as LocalParticipant,
}),
state$: constant({
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
}),
transport: mockTransport,
serverName: "example.org",
state$: constant(ConnectionState.LivekitConnected),
transport: mockTransportConfig,
} as Connection;
const connectionTransportAConnecting = {
...connectionTransportAConnected,
state$: constant({
state: "authenticated",
livekitState: LivekitConnectionState.Connecting,
}),
state$: constant(ConnectionState.LivekitConnecting),
livekitRoom: mockLivekitRoom({}),
} as unknown as Connection;
const delegationSpy = vi
.spyOn(livekitAuth, "delegateDelayedLeave")
.mockResolvedValue();
afterEach(() => delegationSpy.mockClear());
const authCallSpy = vi
.spyOn(openIDSFU, "getSFUConfigWithOpenID")
.mockImplementation(() => mockedClient.getOpenIdToken());
afterEach(() => authCallSpy.mockClear());
const urlOnlyTransport: UnstableLivekitTransport = {
type: "livekit",
url: "https://sfu.example.org",
};
// With a `url` only we publish through the CS API, so the homeserver
// delegates. With a `livekit_service_url` we publish through the JWT
// service, so that delegates.
it.each([
["no", mockTransport, null, timings],
["no", null, timings],
[
"homeserver",
urlOnlyTransport,
"https://matrix.example.org/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave",
delegatedTimings,
],
[
"transport",
mockTransport,
"https://jwt.example.org/delegate_delayed_leave",
delegatedTimings,
],
["transport", "/a/delegate_delayed_leave", delegatedTimings],
])(
"joins session with %s delegation support",
async (_serviceName, transport, delegationUrl, delayedLeaveTimings) => {
async (_serviceName, delegationUrl, delayedLeaveTimings) => {
const scope = testScope();
const joinMatrixRTC = vi.fn();
const delayId$ = new BehaviorSubject<string | null>(null);
@@ -378,17 +372,16 @@ describe("LocalMembership", () => {
if (delegationUrl !== null)
fetchMock.post(delegationUrl, () => ({ status: 401, body: {} }));
const connectionMap = new ConnectionMap();
connectionMap.add(
{ ...connectionTransportAConnected, transport } as Connection,
[],
);
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionMap$: constant(new Epoch(connectionMap)),
connectionManager: {
connectionManagerData$: constant(
new Epoch(new ConnectionManagerData()),
),
},
joinMatrixRTC,
localTransport$: constant({ transport, serverName: "example.org" }),
localTransport$: constant(mockTransport),
delayId$,
});
@@ -397,27 +390,31 @@ describe("LocalMembership", () => {
await flushPromises();
// Joins with timings appropriate for the level of delegation support
expect(joinMatrixRTC).toHaveBeenCalledWith(
transport,
mockTransportConfig,
delayedLeaveTimings,
);
expect(delegationSpy).not.toHaveBeenCalled();
expect(authCallSpy).not.toHaveBeenCalled();
delayId$.next("leave1");
await flushPromises();
if (delegationUrl === null) {
expect(delegationSpy).not.toHaveBeenCalled();
expect(authCallSpy).not.toHaveBeenCalled();
} else {
// Delegation is supported in this test case, so go on to check that
// LocalMember actually performs delegation
const expectDelegation = (delayId: string) =>
expect(delegationSpy).toHaveBeenLastCalledWith({
client: mockedClient,
membership: ownMemberMock,
transport,
roomId: "!test-room-id:example.org",
delayId,
logger: expect.anything(),
});
expect(authCallSpy).toHaveBeenLastCalledWith(
mockedClient,
ownMemberMock,
"a",
"!test-room-id:example.org",
{
matrixRTCMode: MATRIX_RTC_MODE,
delayEndpointBaseUrl: "https://matrix.example.org",
delayId,
},
expect.anything(),
);
expectDelegation("leave1");
delayId$.next("leave2"); // Can change delegated leaves
@@ -456,14 +453,16 @@ describe("LocalMembership", () => {
typeof vi.fn
>;
const connectionMap = new ConnectionMap();
connectionMap.add(connectionTransportAConnected, []);
// connectionMap.add(connectionTransportB, []);
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []);
// connectionManagerData.add(connectionTransportB, []);
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionMap$: constant(new Epoch(connectionMap)),
localTransport$: constant(mockTransportLocator),
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
});
await flushPromises();
expect(publisherFactory).toHaveBeenCalledOnce();
@@ -484,10 +483,12 @@ describe("LocalMembership", () => {
//
it("tracks livekit state correctly", async () => {
const scope = new ObservableScope();
const connectionMap = new ConnectionMap();
const localTransport$ = new Subject<livekitAuth.TransportLocator>();
const connectionManagerData = new ConnectionManagerData();
const localTransport$ = new Subject<LocalTransport>();
const connectionMap$ = new BehaviorSubject(new Epoch(connectionMap));
const connectionManagerData$ = new BehaviorSubject(
new Epoch(connectionManagerData),
);
const publishers: Publisher[] = [];
const publishing$ = new BehaviorSubject<boolean>(false);
@@ -521,7 +522,9 @@ describe("LocalMembership", () => {
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionMap$: connectionMap$,
connectionManager: {
connectionManagerData$,
},
localTransport$,
});
@@ -529,50 +532,38 @@ describe("LocalMembership", () => {
expect(localMembership.localMemberState$.value).toStrictEqual(
TransportState.Waiting,
);
localTransport$.next(mockTransportLocator);
localTransport$.next(mockTransport);
await flushPromises();
expect(localMembership.localMemberState$.value).toStrictEqual({
matrix: RTCMemberStatus.Connected,
media: {
connection: { state: "initializing" },
tracks: TrackState.WaitingForUser,
},
media: { connection: null, tracks: TrackState.WaitingForUser },
});
const connectionMap2 = new ConnectionMap();
connectionMap2.add(
const connectionManagerData2 = new ConnectionManagerData();
connectionManagerData2.add(
// clone because we will mutate this later.
{ ...connectionTransportAConnecting } as unknown as Connection,
[],
);
connectionMap$.next(new Epoch(connectionMap2));
connectionManagerData$.next(new Epoch(connectionManagerData2));
await flushPromises();
expect(localMembership.localMemberState$.value).toStrictEqual({
matrix: RTCMemberStatus.Connected,
media: {
connection: {
state: "authenticated",
livekitState: LivekitConnectionState.Connecting,
},
connection: ConnectionState.LivekitConnecting,
tracks: TrackState.WaitingForUser,
},
});
(
connectionMap2.getConnectionForTransport(mockTransportLocator)!
connectionManagerData2.getConnectionForTransport(mockTransportConfig)!
.state$ as BehaviorSubject<ConnectionState>
).next({
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
});
).next(ConnectionState.LivekitConnected);
expect(localMembership.localMemberState$.value).toStrictEqual({
matrix: RTCMemberStatus.Connected,
media: {
connection: {
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
},
connection: ConnectionState.LivekitConnected,
tracks: TrackState.WaitingForUser,
},
});
@@ -656,8 +647,8 @@ describe("LocalMembership", () => {
[boolean, HomeserverDisconnectReason | null]
>([false, "membership"]);
const connectionMap = new ConnectionMap();
connectionMap.add(connectionTransportAConnected, []);
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []);
createLocalMembership$({
scope,
@@ -666,8 +657,10 @@ describe("LocalMembership", () => {
combined$: hsReason$,
rtsSession$: constant(RTCMemberStatus.Connected),
},
connectionMap$: constant(new Epoch(connectionMap)),
localTransport$: constant(mockTransportLocator),
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
});
await flushPromises();
@@ -691,8 +684,8 @@ describe("LocalMembership", () => {
[boolean, HomeserverDisconnectReason | null]
>([true, null]);
const connectionMap = new ConnectionMap();
connectionMap.add(connectionTransportAConnected, []);
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []);
createLocalMembership$({
scope,
@@ -701,8 +694,10 @@ describe("LocalMembership", () => {
combined$: hsReason$,
rtsSession$: constant(RTCMemberStatus.Connected),
},
connectionMap$: constant(new Epoch(connectionMap)),
localTransport$: constant(mockTransportLocator),
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
});
await flushPromises();
@@ -726,17 +721,16 @@ describe("LocalMembership", () => {
"track",
);
const connectionState$ = new BehaviorSubject<ConnectionState>({
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
});
const connectionState$ = new BehaviorSubject<ConnectionState>(
ConnectionState.LivekitConnected,
);
const mutableConnection = {
...connectionTransportAConnected,
state$: connectionState$,
} as unknown as Connection;
const connectionMap = new ConnectionMap();
connectionMap.add(mutableConnection, []);
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(mutableConnection, []);
createLocalMembership$({
scope,
@@ -748,20 +742,16 @@ describe("LocalMembership", () => {
]),
rtsSession$: constant(RTCMemberStatus.Connected),
},
connectionMap$: constant(new Epoch(connectionMap)),
localTransport$: constant(mockTransportLocator),
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
});
await flushPromises();
connectionState$.next({
state: "authenticated",
livekitState: LivekitConnectionState.Disconnected,
});
connectionState$.next({
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
});
connectionState$.next(ConnectionState.LivekitDisconnected);
connectionState$.next(ConnectionState.LivekitConnected);
expect(trackSpy).toHaveBeenCalledWith(
defaultCreateLocalMemberValues.roomId,
@@ -783,8 +773,8 @@ describe("LocalMembership", () => {
[boolean, HomeserverDisconnectReason | null]
>([true, null]);
const connectionMap = new ConnectionMap();
connectionMap.add(connectionTransportAConnected, []);
const connectionManagerData = new ConnectionManagerData();
connectionManagerData.add(connectionTransportAConnected, []);
createLocalMembership$({
scope,
@@ -793,8 +783,10 @@ describe("LocalMembership", () => {
combined$: hsReason$,
rtsSession$: constant(RTCMemberStatus.Connected),
},
connectionMap$: constant(new Epoch(connectionMap)),
localTransport$: constant(mockTransportLocator),
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
});
await flushPromises();
@@ -852,13 +844,15 @@ describe("LocalMembership", () => {
localMembership: ReturnType<typeof createLocalMembership$>;
} => {
const scope = new ObservableScope();
const connectionMap = new ConnectionMap();
if (connection) connectionMap.add(connection, []);
const connectionManagerData = new ConnectionManagerData();
if (connection) connectionManagerData.add(connection, []);
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionMap$: constant(new Epoch(connectionMap)),
localTransport$: constant(mockTransportLocator),
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: constant(mockTransport),
});
return { scope, localMembership };
};
@@ -867,12 +861,8 @@ describe("LocalMembership", () => {
const error = new Error("NotReadableError");
const setScreenShareEnabled = vi.fn().mockRejectedValue(error);
const connection = {
state$: constant({
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
}),
transport: mockTransport,
serverName: "example.org",
state$: constant(ConnectionState.LivekitConnected),
transport: mockTransportConfig,
livekitRoom: mockLivekitRoom({
localParticipant: mockLocalParticipant({
isScreenShareEnabled: false,
+115 -109
View File
@@ -6,7 +6,6 @@ Please see LICENSE in the repository root for full details.
*/
import {
ConnectionState as LivekitConnectionState,
type Participant,
ParticipantEvent,
type LocalParticipant,
@@ -16,9 +15,11 @@ import {
MediaDeviceFailure,
} from "livekit-client";
import { observeParticipantEvents } from "@livekit/components-core";
import { type MatrixClient } from "matrix-js-sdk";
import {
Status as RTCSessionStatus,
type UnstableLivekitTransport,
type LivekitTransport,
type LivekitTransportConfig,
type MatrixRTCSession,
type RTCCallIntent,
type RTCNotificationType,
@@ -47,8 +48,8 @@ import { deepCompare } from "matrix-js-sdk/lib/utils";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Behavior } from "../../Behavior.ts";
import { type ConnectionMap } from "../remoteMembers/ConnectionMap.ts";
import { type Epoch, type ObservableScope } from "../../ObservableScope.ts";
import { type IConnectionManager } from "../remoteMembers/ConnectionManager.ts";
import { type ObservableScope } from "../../ObservableScope.ts";
import { type Publisher } from "./Publisher.ts";
import { type MuteStates } from "../../MuteStates.ts";
import {
@@ -74,17 +75,13 @@ import {
} from "../../../config/ConfigOptions.ts";
import { Config } from "../../../config/Config.ts";
import {
type ConnectionState,
ConnectionState,
type Connection,
type FailedToStartError,
} from "../remoteMembers/Connection.ts";
import { type HomeserverConnected } from "./HomeserverConnected.ts";
import {
type ClientDelegationParts,
delegateDelayedLeave,
type ClientOpenIDParts,
chooseAuthFlow,
type TransportLocator,
} from "../../../livekit/auth";
import { type LocalTransport } from "./LocalTransport.ts";
import { getSFUConfigWithOpenID } from "../../../livekit/openIDSFU.ts";
export enum TransportState {
/** Not even a transport is available to the LocalMembership */
@@ -117,7 +114,7 @@ export enum TrackState {
export type LocalMemberMediaState =
| {
tracks: TrackState;
connection: ConnectionState;
connection: ConnectionState | FailedToStartError;
}
| PublishState
| ElementCallError;
@@ -144,20 +141,20 @@ interface Props {
// that the inputs for those createSomething$() functions should NOT contain any js-sdk objectes
scope: ObservableScope;
muteStates: MuteStates;
connectionMap$: Behavior<Epoch<ConnectionMap>>;
connectionManager: IConnectionManager;
createPublisherFactory: (connection: Connection) => Publisher;
joinMatrixRTC: (
transport: UnstableLivekitTransport,
transport: LivekitTransportConfig,
delayedLeaveTimings: ResolvedDelayedLeaveTimings,
) => void;
homeserverConnected: HomeserverConnected;
roomId: string;
ownMembershipIdentity: CallMembershipIdentityParts;
localTransport$: Observable<TransportLocator>;
client: ClientDelegationParts & ClientOpenIDParts;
localTransport$: Observable<LocalTransport>;
client: Pick<MatrixClient, "getDeviceId" | "getOpenIdToken">;
matrixRTCSession: Pick<
MatrixRTCSession,
"slotId" | "updateCallIntent" | "leave"
"updateCallIntent" | "leaveRoomSession"
>;
/** Whether to hide the screen-sharing button. */
hideScreensharing: boolean;
@@ -165,6 +162,7 @@ interface Props {
hostBridge: HostBridge;
baseUrl: string;
delayId$: Behavior<string | null>;
matrixRTCMode: MatrixRTCMode;
logger: Logger;
}
@@ -175,7 +173,7 @@ interface Props {
* -
* @param props The properties required to create the local membership.
* @param props.scope The observable scope to use.
* @param props.connectionMap$ The connection map to get connections from.
* @param props.connectionManager The connection manager to get connections from.
* @param props.createPublisherFactory Factory to create a publisher once we have a connection.
* @param props.joinMatrixRTC Callback to join the matrix RTC session once we have a transport.
* @param props.homeserverConnected The homeserver connected state.
@@ -197,7 +195,7 @@ interface Props {
*/
export const createLocalMembership$ = ({
scope,
connectionMap$,
connectionManager,
localTransport$: localTransportWithErrors$,
homeserverConnected,
createPublisherFactory,
@@ -212,6 +210,7 @@ export const createLocalMembership$ = ({
hostBridge,
ownMembershipIdentity,
delayId$,
matrixRTCMode,
}: Props): {
/**
* This request to start audio and video tracks.
@@ -313,17 +312,16 @@ export const createLocalMembership$ = ({
const joinParams$ = scope.behavior(
localTransport$.pipe(
switchMap(async ({ transport }) => {
// Delegation goes through whichever service authenticates us as a
// publisher, so only that service's support counts
const flow = chooseAuthFlow(transport, "publisher");
const delegationSupported =
flow.kind === "openID"
? await checkDelegationSupport(
flow.serviceUrl + "/delegate_delayed_leave",
`transport ${flow.serviceUrl}`,
)
: await homeserverSupportsDelegation;
return { transport, delegationSupported };
const transportSupportsDelegation = checkDelegationSupport(
transport.livekit_service_url + "/delegate_delayed_leave",
`transport ${transport.livekit_service_url}`,
);
return {
transport,
delegationSupported:
(await homeserverSupportsDelegation) ||
(await transportSupportsDelegation),
};
}),
),
null,
@@ -331,14 +329,16 @@ export const createLocalMembership$ = ({
// Drop Epoch data here since we will not combine this anymore
const localConnection$ = scope.behavior(
combineLatest(
[connectionMap$, localTransport$],
({ value: connectionMap }, transport) =>
connectionMap.getConnectionForTransport(transport),
).pipe(
combineLatest([
connectionManager.connectionManagerData$,
localTransport$,
]).pipe(
map(([{ value: connectionData }, { transport }]) =>
connectionData.getConnectionForTransport(transport),
),
tap((connection) => {
logger.info(
`Local connection updated: ${JSON.stringify(connection?.transport)}`,
`Local connection updated: ${connection?.transport?.livekit_service_url}`,
);
}),
),
@@ -410,7 +410,7 @@ export const createLocalMembership$ = ({
scope.reconcile(localConnection$, async (connection) => {
logger.info(
"reconcile based on new localConnection:",
JSON.stringify(connection?.transport),
connection?.transport.livekit_service_url,
);
if (connection !== null) {
const publisher = createPublisherFactory(connection);
@@ -479,13 +479,7 @@ export const createLocalMembership$ = ({
};
const localConnectionState$ = localConnection$.pipe(
switchMap((connection) =>
connection ? connection.state$ : of({ state: "initializing" as const }),
),
);
const authenticated$ = scope.behavior(
localConnectionState$.pipe(map(({ state }) => state === "authenticated")),
switchMap((connection) => (connection ? connection.state$ : of(null))),
);
const mediaState$: Behavior<LocalMemberMediaState> = scope.behavior(
@@ -503,9 +497,7 @@ export const createLocalMembership$ = ({
: TrackState.WaitingForUser;
if (
localConnectionState.state !== "authenticated" ||
localConnectionState.livekitState !==
LivekitConnectionState.Connected ||
localConnectionState !== ConnectionState.LivekitConnected ||
trackState !== TrackState.Ready
)
return {
@@ -573,11 +565,7 @@ export const createLocalMembership$ = ({
combineLatest([
homeserverConnected.combined$,
localConnectionState$.pipe(
map(
(state) =>
state.state === "authenticated" &&
state.livekitState === LivekitConnectionState.Connected,
),
map((state) => state === ConnectionState.LivekitConnected),
),
]).pipe(
map(([[hsConnected, hsReason], livekitConnected]) => {
@@ -672,38 +660,34 @@ export const createLocalMembership$ = ({
// Join and leave the session as needed
scope.reconcile(
scope.behavior(
combineLatest([joinParams$, joinAndPublishRequested$, authenticated$]),
),
async ([joinParams, shouldConnect, authenticated]) => {
scope.behavior(combineLatest([joinParams$, joinAndPublishRequested$])),
async ([joinParams, shouldConnect]) => {
if (!joinParams) return;
// if shouldConnect=false we will do the disconnect as the cleanup from the previous reconcile iteration.
if (!shouldConnect) return;
const sessionConfig = Config.get().matrix_rtc_session;
// Only join once authenticated, to ensure the LiveKit room exists first.
// Otherwise, subscribers authenticating with the OpenID flow may fail to
// join (they lack permission to create the room) and never retry.
if (joinParams && shouldConnect && authenticated) {
try {
joinMatrixRTC(
joinParams.transport,
joinParams.delegationSupported
? sessionConfig.delegated_delayed_leave
: sessionConfig.delayed_leave,
);
} catch (error) {
logger.error("Error entering RTC session", error);
if (error instanceof Error)
setMatrixError(new MembershipManagerError(error));
}
// Clean-up callback to leave the session
return Promise.resolve(async (): Promise<void> => {
try {
await matrixRTCSession.leave(1000);
} catch (e) {
logger.error("Error leaving RTC session", e);
}
});
try {
joinMatrixRTC(
joinParams.transport,
joinParams.delegationSupported
? sessionConfig.delegated_delayed_leave
: sessionConfig.delayed_leave,
);
} catch (error) {
logger.error("Error entering RTC session", error);
if (error instanceof Error)
setMatrixError(new MembershipManagerError(error));
}
return Promise.resolve(async (): Promise<void> => {
try {
// TODO Update matrixRTCSession to allow udpating the transport without leaving the session!
await matrixRTCSession.leaveRoomSession(1000);
} catch (e) {
logger.error("Error leaving RTC session", e);
}
});
},
);
@@ -722,22 +706,23 @@ export const createLocalMembership$ = ({
async ([joinParams, delayId]) => {
if (joinParams?.delegationSupported && delayId !== null) {
try {
logger.info(
`Delegating delayed leave to ${JSON.stringify(joinParams.transport)}…`,
);
await delegateDelayedLeave({
// This will technically cause the service to issue a new JWT token,
// but it's safe to discard. We're only interested in triggering
// delegation.
await getSFUConfigWithOpenID(
client,
membership: ownMembershipIdentity,
transport: joinParams.transport,
slotId: matrixRTCSession.slotId,
ownMembershipIdentity,
joinParams.transport.livekit_service_url,
roomId,
delayId,
{ matrixRTCMode, delayEndpointBaseUrl: baseUrl, delayId },
logger,
});
logger.info("Delayed leave successfully delegated");
);
} catch (e) {
// TODO: Surface this to the user as a service interruption?
logger.error(`Failed to delegate leave`, e);
logger.error(
`Failed to delegate leave to ${joinParams.transport.livekit_service_url}`,
e,
);
}
}
},
@@ -970,7 +955,7 @@ interface EnterRTCSessionOptions {
*
* @param rtcSession - The MatrixRTCSession to join.
* @param ownMembershipIdentity - Options for entering the RTC session.
* @param transport - The UnstableLivekitTransport to use for this session.
* @param transport - The LivekitTransport to use for this session.
* @param options - `encryptMedia`: Whether to encrypt media. `matrixRTCMode`: The
* Matrix RTC mode to use. `delayedLeaveTimings`: The preferred timings for
* delayed leave events. `sendNotificationType`: Whether and what kind of
@@ -981,7 +966,7 @@ interface EnterRTCSessionOptions {
export function enterRTCSession(
rtcSession: MatrixRTCSession,
ownMembershipIdentity: CallMembershipIdentityParts,
transport: UnstableLivekitTransport,
transport: LivekitTransportConfig,
{
encryptMedia,
matrixRTCMode,
@@ -1002,6 +987,21 @@ export function enterRTCSession(
matrix_rtc_session: sessionConfig,
} = Config.get();
const retryInterval = sessionConfig.network_error_retry_ms;
const multiSFU =
matrixRTCMode === MatrixRTCMode.Compatibility ||
matrixRTCMode === MatrixRTCMode.Matrix_2_0;
// For backwards compatibility with Element Call versions that do not do Matrix 2.0,
// we add the livekit alias to the transport.
let backwardCompatibleTransport: LivekitTransport | LivekitTransportConfig;
if (matrixRTCMode === MatrixRTCMode.Matrix_2_0) {
backwardCompatibleTransport = transport;
} else {
backwardCompatibleTransport = {
livekit_alias: rtcSession.room.roomId,
...transport,
};
}
// Set maximumNetworkErrorRetryCount such that we will consider the client
// disconnected as soon as either it fails to sync for longer than the grace
@@ -1014,18 +1014,24 @@ export function enterRTCSession(
// Multi-sfu does not need a preferred foci list. just the focus that is actually used.
// TODO where/how do we track errors originating from the ongoing rtcSession?
rtcSession.join(ownMembershipIdentity, [transport], {
notificationType,
callIntent,
manageMediaKeys: encryptMedia,
delayedLeaveEventRestartMs: delayedLeaveTimings.restart_ms,
delayedLeaveEventDelayMs: delayedLeaveTimings.delay_ms,
delayedLeaveEventRestartLocalTimeoutMs:
delayedLeaveTimings.restart_timeout_ms,
networkErrorRetryMs: sessionConfig.network_error_retry_ms,
membershipEventExpiryMs: sessionConfig.membership_event_expiry_ms,
keyRotationParticipantLimit: sessionConfig.key_rotation_participant_limit,
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
maximumNetworkErrorRetryCount: maximumNetworkErrorRetryCount,
});
rtcSession.joinRTCSession(
ownMembershipIdentity,
multiSFU ? [] : [backwardCompatibleTransport],
multiSFU ? backwardCompatibleTransport : undefined,
{
notificationType,
callIntent,
manageMediaKeys: encryptMedia,
delayedLeaveEventRestartMs: delayedLeaveTimings.restart_ms,
delayedLeaveEventDelayMs: delayedLeaveTimings.delay_ms,
delayedLeaveEventRestartLocalTimeoutMs:
delayedLeaveTimings.restart_timeout_ms,
networkErrorRetryMs: sessionConfig.network_error_retry_ms,
makeKeyDelay: sessionConfig.wait_for_key_rotation_ms,
membershipEventExpiryMs: sessionConfig.membership_event_expiry_ms,
keyRotationParticipantLimit: sessionConfig.key_rotation_participant_limit,
unstableSendStickyEvents: matrixRTCMode === MatrixRTCMode.Matrix_2_0,
maximumNetworkErrorRetryCount: maximumNetworkErrorRetryCount,
},
);
}
@@ -16,54 +16,130 @@ import {
} from "vitest";
import fetchMock from "fetch-mock";
import { mockConfig } from "../../../utils/test";
import { mockConfig, ownMemberMock } from "../../../utils/test";
import { getLocalTransport } from "./LocalTransport";
import { MatrixRTCTransportMissingError } from "../../../utils/errors";
import { customTransport } from "../../../settings/settings";
import { type MatrixClient } from "matrix-js-sdk";
import {
MatrixRTCTransportMissingError,
FailToGetOpenIdToken,
} from "../../../utils/errors";
import * as openIDSFU from "../../../livekit/openIDSFU";
import { customLivekitUrl } from "../../../settings/settings";
import { testJWTToken } from "../../../utils/test-fixtures";
import { MatrixRTCMode } from "../../../config/ConfigOptions";
describe("LocalTransport", () => {
const openIdResponse: openIDSFU.SFUConfig = {
url: "https://lk.example.org",
jwt: testJWTToken,
livekitAlias: "Akph4alDMhen",
livekitIdentity: "@lk_user:ABCDEF",
};
beforeEach(() => vi.clearAllMocks());
it("throws if config is missing", async () => {
await expect(
getLocalTransport({
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
roomId: "!room:example.org",
client: {
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
}),
).rejects.toThrow(new MatrixRTCTransportMissingError("example.org"));
});
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
// Provide a valid config so makeTransportInternal resolves a transport
mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" },
});
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockRejectedValue(
new FailToGetOpenIdToken(new Error("no openid")),
);
await expect(
getLocalTransport({
roomId: "!example_room_id",
client: {
getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
}),
).rejects.toThrow(new FailToGetOpenIdToken(new Error("no openid")));
});
it("returns preferred transport", async () => {
// Use config so transport discovery succeeds
// Use config so transport discovery succeeds, but delay OpenID JWT fetch
mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" },
});
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue({
url: "https://lk.example.org",
jwt: "jwt",
livekitAlias: "Akph4alDMhen",
livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId,
});
expect(
await getLocalTransport({
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
roomId: "!room:example.org",
client: {
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
ownMembershipIdentity: ownMemberMock,
matrixRTCMode: MatrixRTCMode.Compatibility,
}),
).toStrictEqual({
transport: {
livekit_service_url: "https://lk.example.org",
type: "livekit",
},
serverName: "example.org",
sfuConfig: {
jwt: "jwt",
livekitAlias: "Akph4alDMhen",
livekitIdentity: "@alice:example.org:DEVICE",
url: "https://lk.example.org",
},
});
});
type LocalTransportProps = Parameters<typeof getLocalTransport>[0];
describe("transport configuration mechanisms", () => {
let client: MockedObject<
Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">
>;
let localTransportOpts: LocalTransportProps & {
client: MockedObject<LocalTransportProps["client"]>;
};
beforeEach(() => {
mockConfig({});
customTransport.setValue(customTransport.defaultValue);
client = {
getDomain: vi.fn().mockReturnValue("example.org"),
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
customLivekitUrl.setValue(customLivekitUrl.defaultValue);
localTransportOpts = {
ownMembershipIdentity: ownMemberMock,
roomId: "!example_room_id",
matrixRTCMode: MatrixRTCMode.Compatibility,
client: {
getDomain: vi.fn().mockReturnValue("example.org"),
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
};
});
@@ -75,50 +151,93 @@ describe("LocalTransport", () => {
mockConfig({
livekit: { livekit_service_url: "https://lk.example.org" },
});
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
openIdResponse,
);
expect(await getLocalTransport(client)).toStrictEqual({
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({
transport: {
livekit_service_url: "https://lk.example.org",
type: "livekit",
},
serverName: "example.org",
sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen",
livekitIdentity: "@lk_user:ABCDEF",
url: "https://lk.example.org",
},
});
});
it("supports getting transport via user settings", async () => {
customTransport.setValue({
type: "livekit",
livekit_service_url: "https://lk.example.org",
});
customLivekitUrl.setValue("https://lk.example.org");
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
openIdResponse,
);
expect(await getLocalTransport(client)).toStrictEqual({
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({
transport: {
livekit_service_url: "https://lk.example.org",
type: "livekit",
},
serverName: "example.org",
sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen",
livekitIdentity: "@lk_user:ABCDEF",
url: "https://lk.example.org",
},
});
});
it("supports getting transport via backend", async () => {
client._unstable_getRTCTransports.mockResolvedValue([
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
]);
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockResolvedValue(
openIdResponse,
);
expect(await getLocalTransport(client)).toStrictEqual({
expect(await getLocalTransport(localTransportOpts)).toStrictEqual({
transport: {
livekit_service_url: "https://lk.example.org",
type: "livekit",
},
serverName: "example.org",
sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen",
livekitIdentity: "@lk_user:ABCDEF",
url: "https://lk.example.org",
},
});
});
it("fails fast if the openID request fails for backend config", async () => {
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
]);
vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockRejectedValue(
new FailToGetOpenIdToken(new Error("Test driven error")),
);
await expect(getLocalTransport(localTransportOpts)).rejects.toThrow(
expect.any(FailToGetOpenIdToken),
);
});
it("throws if no options are available", async () => {
await expect(
getLocalTransport({
getDomain: () => "example.org",
_unstable_getRTCTransports: async () => Promise.resolve([]),
ownMembershipIdentity: ownMemberMock,
roomId: "!example_room_id",
matrixRTCMode: MatrixRTCMode.Compatibility,
client: {
getDomain: () => "example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
}),
).rejects.toThrow(new MatrixRTCTransportMissingError("example.org"));
});
@@ -5,37 +5,163 @@ SPDX-License-IdFentifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { Config } from "../../../config/Config.ts";
import { MatrixRTCTransportMissingError } from "../../../utils/errors.ts";
import { customTransport as customTransportSetting } from "../../../settings/settings.ts";
import {
FailToGetOpenIdToken,
MatrixRTCTransportMissingError,
NoMatrix2AuthorizationService,
} from "../../../utils/errors.ts";
import {
getSFUConfigWithOpenID,
type SFUConfig,
type OpenIDClientParts,
} from "../../../livekit/openIDSFU.ts";
import { customLivekitUrl } from "../../../settings/settings.ts";
import { RtcTransportAutoDiscovery } from "./RtcTransportAutoDiscovery.ts";
import { type TransportLocator } from "../../../livekit/auth/types.ts";
import { type MatrixRTCMode } from "../../../config/ConfigOptions.ts";
interface Props {
ownMembershipIdentity: CallMembershipIdentityParts;
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports"> &
OpenIDClientParts;
// Used by the jwt service to create the livekit room and compute the livekit alias.
roomId: string;
matrixRTCMode: MatrixRTCMode;
}
// TODO livekit_alias-cleanup
// 1. We need to move away from transports map to connections!!!
//
// 2. We need to stop sending livekit_alias all together
//
//
// 1.
// Transports are just the jwt service adress but do not contain the information which room on this transport to use.
// That requires slot and roomId.
//
// We need one connection per room on the transport.
//
// We need an object that contains:
// transport
// roomId
// slotId
//
// To map to the connections. Prosposal: `ConnectionIdentifier`
//
// 2.
// We need to make sure we do not sent livekit_alias in sticky events and that we drop all code for sending state events!
export interface LocalTransport {
transport: LivekitTransportConfig;
sfuConfig: SFUConfig;
}
export function isLocalTransport(
obj: LivekitTransportConfig | LocalTransport,
): obj is LocalTransport {
return "transport" in obj && "sfuConfig" in obj;
}
/**
* Determines the transport to advertise in our MatrixRTC membership and publish
* media on.
* Connects to the JWT service and determines the transport that the local member should use.
*
* @prop useOldJwtEndpoint Whether to set forceOldJwtEndpoint on the returned transport and to use the old JWT endpoint.
* This is used when the connection manager needs to know if it has to use the legacy endpoint which implies a string concatenated rtcBackendIdentity.
* (which is expected for non sticky event based rtc member events)
* @returns The transport to advertise in our MatrixRTC membership and publish media on.
* @throws MatrixRTCTransportMissingError | FailToGetOpenIdToken
*/
export async function getLocalTransport(
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">,
): Promise<TransportLocator> {
const serverName = client.getDomain();
if (serverName === null) throw new Error("No server name");
export async function getLocalTransport({
ownMembershipIdentity,
client,
roomId,
matrixRTCMode,
}: Props): Promise<LocalTransport> {
const logger = rootLogger.getChild("[LocalTransport]");
const discovery = new RtcTransportAutoDiscovery({
client,
client: client,
resolvedConfig: Config.get(),
logger: rootLogger.getChild("[LocalTransport]"),
logger: logger,
});
const customUrl = customLivekitUrl.value$.value;
// Respect the user's custom transport, if set
const transport =
customTransportSetting.value$.value ??
(await discovery.discoverPreferredTransport());
// Respect the user's custom URL, if set
const transport: LivekitTransportConfig | null = customUrl
? { type: "livekit", livekit_service_url: customUrl }
: await discovery.discoverPreferredTransport();
if (transport === null) throw new MatrixRTCTransportMissingError(serverName);
if (transport === null)
throw new MatrixRTCTransportMissingError(client.getDomain() ?? "");
return { transport, serverName };
try {
return await doOpenIdAndJWTFromUrl(
transport,
matrixRTCMode,
ownMembershipIdentity,
roomId,
client,
logger,
);
} catch (e) {
logger.error(
`Failed to authenticate to transport ${transport.livekit_service_url}`,
e,
);
throw mapAuthErrorToUserFriendlyError(e);
}
}
/**
* Utility to ensure the user can authenticate with the SFU.
* We will call `getSFUConfigWithOpenID` once per transport here as it's our
* only mechanism of validation. This means we will also ask the
* homeserver for a OpenID token a few times. Since OpenID tokens are single
* use we don't want to risk any issues by re-using a token.
*
* @param transport The transport to authenticate with.
* @param matrixRTCMode Whether to force the JWT endpoint to be used.
* @param membership The identity of the local member.
* @param roomId The room ID to use for the JWT.
* @param client The client to use for the OpenID token.
*
* @throws FailToGetOpenIdToken, NoMatrix2AuthorizationService
*/
async function doOpenIdAndJWTFromUrl(
transport: LivekitTransportConfig,
matrixRTCMode: MatrixRTCMode,
membership: CallMembershipIdentityParts,
roomId: string,
client: Pick<MatrixClient, "_unstable_getRTCTransports"> & OpenIDClientParts,
logger?: Logger,
): Promise<LocalTransport> {
const sfuConfig = await getSFUConfigWithOpenID(
client,
membership,
transport.livekit_service_url,
roomId,
{ matrixRTCMode },
logger,
);
return {
transport,
sfuConfig,
};
}
function mapAuthErrorToUserFriendlyError(e: unknown): Error {
if (
e instanceof FailToGetOpenIdToken ||
e instanceof NoMatrix2AuthorizationService
) {
// rethrow as is
return e;
}
// Catch others and rethrow as FailToGetOpenIdToken that has user friendly message.
return new FailToGetOpenIdToken(
e instanceof Error ? e : new Error(String(e)),
);
}

Some files were not shown because too many files have changed in this diff Show More