mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-22 22:29:30 +00:00
Merge branch 'main' into matthew/default-audio-input
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
> Every PR must have a linked issue
|
||||
> that a maintainer has reviewed and approved **before you started writing code**.
|
||||
> PRs that don't meet this requirement will not be reviewed.
|
||||
> See [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md) for ElementCall decided for this approach.
|
||||
> See [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/main/CONTRIBUTING.md) for ElementCall decided for this approach.
|
||||
|
||||
## Content
|
||||
|
||||
@@ -39,7 +39,7 @@ Uncomment the markdown table below and fill in the last line:
|
||||
## Checklist
|
||||
|
||||
- [ ] A linked, pre-approved issue exists for this feature or UI change.
|
||||
- [ ] I have read [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md) in full.
|
||||
- [ ] I have read [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/main/CONTRIBUTING.md) in full.
|
||||
- [ ] Pull request includes screenshots or videos for any UI changes.
|
||||
- [ ] Tests written for new code (and existing touched code where feasible).
|
||||
- [ ] Linter and other CI checks pass.
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- opened
|
||||
- labeled
|
||||
push:
|
||||
branches: [livekit, full-mesh]
|
||||
branches: [main]
|
||||
jobs:
|
||||
build_full_element_call:
|
||||
# Use the full package vite build
|
||||
@@ -22,8 +22,8 @@ jobs:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
deploy_develop:
|
||||
# Deploy livekit branch to call.element.dev after build completes
|
||||
if: github.ref == 'refs/heads/livekit'
|
||||
# Deploy main branch to call.element.dev after build completes
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: build_full_element_call
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -43,8 +43,8 @@ jobs:
|
||||
}
|
||||
})
|
||||
docker_for_develop:
|
||||
# Build docker and publish docker for livekit branch after build completes
|
||||
if: github.ref == 'refs/heads/livekit'
|
||||
# Build docker and publish docker for main branch after build completes
|
||||
if: github.ref == 'refs/heads/main'
|
||||
needs: build_full_element_call
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -10,6 +10,9 @@ public/config.json
|
||||
backend/synapse_tmp/*
|
||||
backend/synapse_tmp_othersite/*
|
||||
/coverage
|
||||
|
||||
# Transient agent scratch files
|
||||
/agent-workspace/
|
||||
config.json
|
||||
|
||||
# Yarn
|
||||
|
||||
@@ -7,14 +7,16 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import i18n from "i18next";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import EN from "../locales/en/app.json";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import { i18n } from "../src/utils/i18n";
|
||||
import "../src/index.css";
|
||||
|
||||
// Bare-minimum i18n config
|
||||
// Bare-minimum i18n config.
|
||||
// Unlike the app, stories register the instance as react-i18next's default
|
||||
// rather than wrapping every story in an <I18nextProvider>.
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# AGENTS.md — Element Call
|
||||
|
||||
MatrixRTC (MSC4143) + LiveKit video calling, shipped standalone, as a widget in
|
||||
Element Web and Element X, as embedded packages, and as a React component in a
|
||||
host's page. It is the MatrixRTC reference implementation.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
- Features and UI changes need a pre-approved issue. No issue, no review.
|
||||
- Exactly one `PR-*` label per PR. CI enforces it; it drives the changelog.
|
||||
- New behaviour ships with unit tests, a story if it renders, and an e2e spec if it
|
||||
is user-facing. All three.
|
||||
- Reuse an existing component. If you genuinely cannot, say so in the PR body and
|
||||
name what you rejected — never add a new one silently.
|
||||
- Nothing reads the page. No `window.location`, `document.body`, `window.inner*`,
|
||||
`@media`, global `i18next` or the `widget` global — take it from a provider.
|
||||
- The change works standalone, as a widget and as a component. Say what you checked.
|
||||
- Every gate below is green before you push.
|
||||
- Hand off after the first implementation, before the quality pass. Then commit on
|
||||
the user's word where the setup allows it, or hand them the message.
|
||||
|
||||
## Read before you
|
||||
|
||||
| … | … |
|
||||
| -------------------------------- | ---------------------------------------------------------- |
|
||||
| start a task of any size | [docs/agents/workflow.md](docs/agents/workflow.md) |
|
||||
| touch call logic or a view model | [docs/agents/architecture.md](docs/agents/architecture.md) |
|
||||
| write any code | [docs/agents/code-style.md](docs/agents/code-style.md) |
|
||||
| write a test, story or e2e spec | [docs/agents/testing.md](docs/agents/testing.md) |
|
||||
| open a PR | [CONTRIBUTING.md](CONTRIBUTING.md) |
|
||||
| implement from a feature spec | [FEATURES_SPEC/AGENTS.md](FEATURES_SPEC/AGENTS.md) |
|
||||
|
||||
## Gates
|
||||
|
||||
```sh
|
||||
pnpm lint # tsc, oxlint, knip, component externals
|
||||
pnpm format # oxfmt
|
||||
pnpm test # vitest: unit and storybook projects
|
||||
pnpm i18n:check
|
||||
```
|
||||
|
||||
`knip` fails on dead code; a file deliberately inert ahead of its consumer goes in
|
||||
`knip.ts` `ignoreFiles` with a reason.
|
||||
|
||||
## A good PR
|
||||
|
||||
- One slice, under ~400 changed lines, green on its own, linked to its issue.
|
||||
- View model + marble tests, thin view, a story per state, an e2e spec.
|
||||
- Any new shared component called out explicitly, with why nothing existing fit.
|
||||
- Template filled for real: what, why, before/after screenshots, repro steps.
|
||||
- Anything that redraws continuously — an animation frame, a stream — says what it
|
||||
costs, or better, counts the redraws in a test.
|
||||
- Branch `<handle>/<topic>`. Plain imperative commit subjects, no prefixes.
|
||||
- Once review starts, fix forward. Never force-push a regeneration over a review.
|
||||
@@ -36,8 +36,9 @@ You can find the latest development version continuously deployed to
|
||||
✅ **Decentralized & Federated** – No central authority; works across Matrix
|
||||
homeservers.
|
||||
✅ **End-to-End Encrypted** – Secure and private calls.
|
||||
✅ **Standalone & Widget Mode** – Use as an independent app or embed in Matrix
|
||||
clients.
|
||||
✅ **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.
|
||||
✅ **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)).
|
||||
@@ -90,7 +91,9 @@ and voice calls within Matrix rooms.
|
||||
|
||||
Element Call offers two packaging options: one for standalone or widget
|
||||
deployment, and another for seamless widget-based integration into messenger
|
||||
apps. Below is an overview of each option.
|
||||
apps. A third, experimental option builds it as a React component library for
|
||||
applications that want to render a call inside their own page rather than in an
|
||||
iframe. Below is an overview of each option.
|
||||
|
||||
**Full Package** – Supports both **Standalone** and **Widget** mode. It is
|
||||
hosted as a static web page and can be accessed via a URL when used as a widget.
|
||||
@@ -107,6 +110,11 @@ recommended method for embedding Element Call.
|
||||
<img src="./docs/embedded_package.drawio.png" alt="Element Call Embedded Package">
|
||||
</p>
|
||||
|
||||
**Component Package (experimental)** – A library build of Element Call as a
|
||||
React component, consumed as a dependency by a host application that already
|
||||
has a Matrix client. See
|
||||
[Element Call as a component](#element-call-as-a-component-experimental) below.
|
||||
|
||||
For more details on the packages, see the
|
||||
[Embedded vs. Standalone Guide](./docs/embedded_standalone.md).
|
||||
|
||||
@@ -213,6 +221,94 @@ See also:
|
||||
|
||||
- [Developing with linked packages](./docs/linking.md)
|
||||
|
||||
#### Element Call as a component (experimental)
|
||||
|
||||
Element Call can also be embedded directly into another React application
|
||||
rather than being loaded in an iframe as a widget. `pnpm build:component`
|
||||
builds it as a library into `component/dist` (the bundle, its stylesheet and
|
||||
type declarations), and
|
||||
|
||||
```sh
|
||||
pnpm dev:component
|
||||
```
|
||||
|
||||
serves a harness on port 3001 that stands in for such an application: it signs
|
||||
in twice against the development backend and shows two calls side by side, in
|
||||
resizable boxes, with page furniture of its own around them. Use it to see how
|
||||
Element Call behaves when it does not own the page — the size it is given,
|
||||
whether it stays inside its container, and what it says to its host, which is
|
||||
logged along the bottom. The harness is served with the same development
|
||||
certificate as the app, so unless the development CA is trusted, the browser
|
||||
needs a certificate exception for `https://localhost:3001` as well (see the
|
||||
note under [Backend](#backend)). It reads the same `public/config.json` as
|
||||
`pnpm dev` if one exists, and runs with Element Call's defaults otherwise.
|
||||
|
||||
The call lays itself out for the size of the element it is mounted in, not the
|
||||
window: a host that shrinks the container to a corner of its page gets the
|
||||
picture-in-picture layout, just as a host that shrank the whole iframe used to.
|
||||
The breakpoints in the stylesheets the component uses are
|
||||
`@container element-call` queries against its root element for the same reason;
|
||||
for the standalone app the root is the page, so they mean what the media queries
|
||||
they replaced did. (The standalone-only views, such as the home and login pages,
|
||||
still use plain media queries, since the component never shows them.)
|
||||
|
||||
The component's stylesheet is confined to the element it is mounted in: the
|
||||
build rewrites every selector so that it matches only Element Call's root or
|
||||
what is inside it, with `html`, `body` and `:root` standing for that root (see
|
||||
`component/build/scopeStylesToRoot.ts`). A host's own page keeps its styles,
|
||||
and Element Call brings its own fonts and design tokens along.
|
||||
|
||||
The component speaks every language the app does. English is bundled in; the
|
||||
other locales are split into chunks the host's bundler loads the first time
|
||||
they are needed. It starts in the browser's language, and follows the host's
|
||||
own language setting through the `language` prop (`supportedLanguages` lists
|
||||
the tags it accepts, and anything else falls back to its base language or to
|
||||
English). The `theme` prop works the same way and takes the same values as the
|
||||
widget's `theme` URL parameter: `light`, `dark`, `light-high-contrast` or
|
||||
`dark-high-contrast`. Both can change while a call is running without
|
||||
disturbing it.
|
||||
|
||||
A host must call and await `initializeElementCall(config)` once before
|
||||
rendering the component: it loads the `Intl` polyfills, applies the
|
||||
deployment-wide `config.json`-style configuration and sets up translations.
|
||||
The component itself takes the host's `client` and the `roomId` to call in, an
|
||||
`intent` saying what the user asked for (which decides whether to show the
|
||||
lobby, ring, and so on), an optional `config` overriding what the intent
|
||||
implies, and an optional `hostBridge` through which Element Call tells the host
|
||||
that the user has joined or hung up, that it wants to stay on screen, and so
|
||||
on. The host makes its own requests (`join`, `hangUp`, `setDeviceMute`) through
|
||||
the handle exposed on `ref`. The full API is documented in the type declarations
|
||||
(`component/index.tsx` and `component/host.ts`).
|
||||
|
||||
A few things differ from the widget on purpose: the component draws a solid
|
||||
background rather than a gradient unless told otherwise, never offers to edit
|
||||
the user's profile (the account is the host's), scopes its keyboard shortcuts to
|
||||
its own root element so that several instances can share a page, and only shows
|
||||
its own post-call and error screens when the host has not supplied a `close()`
|
||||
callback; with one, it asks the host to unmount it instead. The
|
||||
[global JS controls](./docs/controls.md) on `window` are unchanged and remain
|
||||
page-wide, so with several instances on one page they apply to all of them.
|
||||
|
||||
The package is not published yet. A host installs it as a git dependency on the
|
||||
`component` directory of this repository,
|
||||
|
||||
```json
|
||||
"@element-hq/element-call-component": "github:element-hq/element-call#main&path:/component"
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
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` and `livekit-client` itself, since the bundle leaves
|
||||
them external.
|
||||
|
||||
### Backend
|
||||
|
||||
A docker compose file `docker-compose-dev.yml` is provided to start the
|
||||
@@ -274,7 +370,10 @@ running Playwright by following
|
||||
|
||||
However the Playwright tests are run, an element-call instance must be running
|
||||
on https://localhost:3000 (this is configured in `playwright.config.ts`) - this
|
||||
is what will be tested.
|
||||
is what will be tested. The tests under `playwright/component` instead drive
|
||||
the component harness (`pnpm dev:component`) on https://localhost:3001, which
|
||||
Playwright starts as a second web server; it is always a Vite dev server, even
|
||||
when the app itself is served from Docker with `USE_DOCKER`.
|
||||
|
||||
The local backend environment should be running for the test to work:
|
||||
`pnpm backend`
|
||||
@@ -373,7 +472,7 @@ We do this so that we can reuse the labels between repositories.
|
||||
|
||||
## 📝 Copyright & License
|
||||
|
||||
Copyright 2021-2025 New Vector Ltd
|
||||
Copyright 2021-2026 New Vector Ltd
|
||||
|
||||
This software is dual-licensed by New Vector Ltd (Element). It can be used
|
||||
either:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
id: "lk-jwt-service-othersite"
|
||||
as_token: "ec_dev_as_token_othersite_0123456789ab"
|
||||
hs_token: "ec_dev_hs_token_othersite_0123456789ab"
|
||||
sender_localpart: "_lk_jwt_service"
|
||||
namespaces:
|
||||
users:
|
||||
- exclusive: false
|
||||
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.
|
||||
io.element.msc4502.scopes:
|
||||
["urn:matrix:client:io.element.msc4502:rooms:is_joined"]
|
||||
# Route /_matrix/client/(v*|unstable/*)/rtc/livekit/* C-S and S-S requests to this
|
||||
# service.
|
||||
io.element.msc4512.proxy_prefix: "rtc/livekit"
|
||||
io.element.msc4512.proxy_url: "http://auth-server-1:16080"
|
||||
@@ -0,0 +1,17 @@
|
||||
id: "lk-jwt-service"
|
||||
as_token: "ec_dev_as_token_main_0123456789ab"
|
||||
hs_token: "ec_dev_hs_token_main_0123456789ab"
|
||||
sender_localpart: "_lk_jwt_service"
|
||||
namespaces:
|
||||
users:
|
||||
- exclusive: false
|
||||
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.
|
||||
io.element.msc4502.scopes:
|
||||
["urn:matrix:client:io.element.msc4502:rooms:is_joined"]
|
||||
# Route /_matrix/client/(v*|unstable/*)/rtc/livekit/* C-S and S-S requests to this
|
||||
# service.
|
||||
io.element.msc4512.proxy_prefix: "rtc/livekit"
|
||||
io.element.msc4512.proxy_url: "http://auth-server:6080"
|
||||
@@ -42,6 +42,13 @@ experimental_features:
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -42,6 +42,13 @@ experimental_features:
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -42,6 +42,13 @@ experimental_features:
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -42,6 +42,13 @@ experimental_features:
|
||||
msc4354_enabled: true
|
||||
# MatrixRTC
|
||||
msc4143_enabled: true
|
||||
# Enable membership look-up via /is_joined.
|
||||
msc4502_enabled: true
|
||||
# Enable C-S & S-S request proxying for application services.
|
||||
msc4512_enabled: true
|
||||
|
||||
app_service_config_files:
|
||||
- /data/cfg/app-service.yaml
|
||||
|
||||
# The maximum allowed duration by which sent events can be delayed, as
|
||||
# per MSC4140. Must be a positive value if set. Defaults to no
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/* The container a host mounts us into. It fills whatever space the host gives
|
||||
it, and nothing we draw may leave it.
|
||||
|
||||
That takes two separate things, which are easy to mistake for one. `isolation`
|
||||
gives us a stacking context, so nothing inside can be layered above the host's
|
||||
own interface. Containment makes us the containing block for `position: fixed`
|
||||
descendants, and clips what we paint to our own box: without it, the modal
|
||||
scrim and dialog — which are positioned `fixed` and centred, since in the app
|
||||
they are meant to cover the page — resolve against the viewport and appear in
|
||||
the middle of the host's window rather than in the middle of the call.
|
||||
|
||||
The clipping cuts both ways: a menu near the edge of a small container is
|
||||
trimmed rather than overflowing into the host. That is the trade being a
|
||||
component rather than a page makes. */
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
isolation: isolate;
|
||||
contain: layout paint;
|
||||
position: relative;
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
color: var(--cpd-color-text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
block-size: 100%;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
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 { describe, expect, it } from "vitest";
|
||||
import postcss from "postcss";
|
||||
|
||||
import { ROOT_SELECTOR, scopeStylesToRoot } from "./scopeStylesToRoot";
|
||||
|
||||
const inRoot = `:where(${ROOT_SELECTOR}, ${ROOT_SELECTOR} *)`;
|
||||
const isRoot = `:where(${ROOT_SELECTOR})`;
|
||||
|
||||
async function scope(css: string, file = "base.css"): Promise<string> {
|
||||
const result = await postcss([scopeStylesToRoot()]).process(css, {
|
||||
from: file,
|
||||
});
|
||||
return result.css;
|
||||
}
|
||||
|
||||
describe("scopeStylesToRoot", () => {
|
||||
it("makes the root stand in for the document", async () => {
|
||||
expect(await scope("html { line-height: 1.15 }")).toBe(
|
||||
`${isRoot} { line-height: 1.15 }`,
|
||||
);
|
||||
expect(await scope("body { margin: 0 }")).toBe(`${isRoot} { margin: 0 }`);
|
||||
expect(await scope(":root { --a: 1 }")).toBe(`${isRoot} { --a: 1 }`);
|
||||
expect(await scope("body.no-scroll-body { position: fixed }")).toBe(
|
||||
`${isRoot}.no-scroll-body { position: fixed }`,
|
||||
);
|
||||
expect(await scope("body .x { color: red }")).toBe(
|
||||
`${isRoot} .x { color: red }`,
|
||||
);
|
||||
});
|
||||
|
||||
it("collapses selectors that all became the root", async () => {
|
||||
expect(await scope("html, body, input { font: inherit }")).toBe(
|
||||
`${isRoot},input${inRoot} { font: inherit }`,
|
||||
);
|
||||
});
|
||||
|
||||
it("confines everything else to the root and what is inside it", async () => {
|
||||
expect(await scope("h1 { margin: 0 }")).toBe(`h1${inRoot} { margin: 0 }`);
|
||||
expect(await scope(".cpd-theme-dark { --a: 1 }")).toBe(
|
||||
`.cpd-theme-dark${inRoot} { --a: 1 }`,
|
||||
);
|
||||
expect(await scope("* { box-sizing: border-box }")).toBe(
|
||||
`*${inRoot} { box-sizing: border-box }`,
|
||||
);
|
||||
expect(await scope(".a > .b + .c { color: red }")).toBe(
|
||||
`.a>.b+.c${inRoot} { color: red }`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps pseudo-elements last", async () => {
|
||||
expect(await scope("button::-moz-focus-inner { border: 0 }")).toBe(
|
||||
`button${inRoot}::-moz-focus-inner { border: 0 }`,
|
||||
);
|
||||
expect(await scope(".a .b:hover::after { content: '' }")).toBe(
|
||||
`.a .b:hover${inRoot}::after { content: '' }`,
|
||||
);
|
||||
expect(await scope("p:first-letter { color: red }")).toBe(
|
||||
`p${inRoot}:first-letter { color: red }`,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves alone what already names the root", async () => {
|
||||
const css = `${ROOT_SELECTOR}[data-platform="ios"] { --a: 1 }`;
|
||||
expect(await scope(css)).toBe(css);
|
||||
});
|
||||
|
||||
it("reaches into layers and media queries", async () => {
|
||||
expect(
|
||||
await scope(
|
||||
"@layer normalize { h1 { margin: 0 } } @media (min-width: 1px) { p { margin: 0 } }",
|
||||
),
|
||||
).toBe(
|
||||
`@layer normalize { h1${inRoot} { margin: 0 } } @media (min-width: 1px) { p${inRoot} { margin: 0 } }`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not touch keyframes or nested rules", async () => {
|
||||
expect(
|
||||
await scope("@keyframes spin { from { opacity: 0 } to { opacity: 1 } }"),
|
||||
).toBe("@keyframes spin { from { opacity: 0 } to { opacity: 1 } }");
|
||||
expect(
|
||||
await scope(
|
||||
".a { color: red; &:hover { color: blue } .b { color: green } }",
|
||||
),
|
||||
).toBe(
|
||||
`.a${inRoot} { color: red; &:hover { color: blue } .b { color: green } }`,
|
||||
);
|
||||
});
|
||||
|
||||
it("only touches the bare selectors of a CSS module", async () => {
|
||||
const file = "Settings.module.css";
|
||||
expect(await scope("pre { font-size: 1px }", file)).toBe(
|
||||
`pre${inRoot} { font-size: 1px }`,
|
||||
);
|
||||
expect(await scope(".modal pre { font-size: 1px }", file)).toBe(
|
||||
`.modal pre${inRoot} { font-size: 1px }`,
|
||||
);
|
||||
expect(await scope(".box_abc12 { border: 0 }", file)).toBe(
|
||||
".box_abc12 { border: 0 }",
|
||||
);
|
||||
expect(await scope(".a .b_abc12:hover { border: 0 }", file)).toBe(
|
||||
".a .b_abc12:hover { border: 0 }",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
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 AtRule,
|
||||
type Container,
|
||||
type Document,
|
||||
type Plugin,
|
||||
type Rule,
|
||||
} from "postcss";
|
||||
import selectorParser, {
|
||||
type Node,
|
||||
type Pseudo,
|
||||
type Selector,
|
||||
} from "postcss-selector-parser";
|
||||
|
||||
/**
|
||||
* How the stylesheets find Element Call's root element. The attribute is put
|
||||
* there by `useTheme`, on the container the host gives the component.
|
||||
*/
|
||||
export const ROOT_SELECTOR = "[data-element-call-root]";
|
||||
|
||||
// Both are `:where()`, which has no specificity of its own, so the rules keep
|
||||
// exactly the weight they had before being scoped and nothing in Element Call's
|
||||
// cascade changes — only where it applies.
|
||||
//
|
||||
// The root, or anything inside it. Appended to the element a rule is about,
|
||||
// rather than prepended to the whole selector, so that a rule about the root
|
||||
// itself (its theme class, say) still matches.
|
||||
const IN_ROOT = `:where(${ROOT_SELECTOR}, ${ROOT_SELECTOR} *)`;
|
||||
// The root itself, standing in for the document.
|
||||
const IS_ROOT = `:where(${ROOT_SELECTOR})`;
|
||||
|
||||
/**
|
||||
* Confines a stylesheet to Element Call's root element, for the build of
|
||||
* Element Call as a component.
|
||||
*
|
||||
* As a page of its own, Element Call can style the document: normalize.css and
|
||||
* 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.
|
||||
* - Everything else keeps its selector and gains `:where([data-element-call-root],
|
||||
* [data-element-call-root] *)` on the element it styles.
|
||||
* - Selectors that already name the root are left alone, as are keyframe
|
||||
* selectors and rules nested inside another rule, which are relative to it.
|
||||
*
|
||||
* 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 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 {
|
||||
postcssPlugin: "element-call-scope-styles-to-root",
|
||||
Once(root) {
|
||||
const isModule =
|
||||
root.source?.input.file?.endsWith(".module.css") ?? false;
|
||||
root.walkRules((rule) => {
|
||||
if (isRelative(rule)) return;
|
||||
rule.selector = (isModule ? scopeBare : scopeAll).processSync(
|
||||
rule.selector,
|
||||
{ lossless: false },
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a rule's selectors are relative to something other than the document. */
|
||||
function isRelative(rule: Rule): boolean {
|
||||
let parent: Container | Document | undefined = rule.parent;
|
||||
while (parent !== undefined) {
|
||||
if (parent.type === "rule") return true;
|
||||
if (parent.type === "atrule") {
|
||||
const { name } = parent as AtRule;
|
||||
if (name.endsWith("keyframes") || name === "page") return true;
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const processor = (isModule: boolean): ReturnType<typeof selectorParser> =>
|
||||
selectorParser((selectors) => {
|
||||
selectors.each((selector) => {
|
||||
scopeSelector(selector, isModule);
|
||||
});
|
||||
// Mapping `html, body` onto the root leaves the same selector twice
|
||||
const seen = new Set<string>();
|
||||
selectors.each((selector) => {
|
||||
const text = String(selector).trim();
|
||||
if (seen.has(text)) selector.remove();
|
||||
else seen.add(text);
|
||||
});
|
||||
});
|
||||
|
||||
// Everything, for stylesheets that speak of the document; only what a class
|
||||
// does not already confine, for CSS modules
|
||||
const scopeAll = processor(false);
|
||||
const scopeBare = processor(true);
|
||||
|
||||
function scopeSelector(selector: Selector, isModule: boolean): void {
|
||||
if (String(selector).includes(ROOT_SELECTOR)) return;
|
||||
|
||||
const compounds = splitCompounds(selector);
|
||||
if (compounds.length === 0) return;
|
||||
|
||||
// Something said of the document is said of the root instead
|
||||
const document = compounds[0].find(isDocumentSelector);
|
||||
if (document !== undefined) {
|
||||
document.replaceWith(pseudo(IS_ROOT));
|
||||
return;
|
||||
}
|
||||
|
||||
const subject = compounds.at(-1)!;
|
||||
if (isModule && subject.some((node) => node.type === "class")) return;
|
||||
|
||||
// Pseudo-elements have to come last in a compound selector
|
||||
const pseudoElement = subject.find(isPseudoElement);
|
||||
if (pseudoElement === undefined) selector.append(pseudo(IN_ROOT));
|
||||
else selector.insertBefore(pseudoElement, pseudo(IN_ROOT));
|
||||
}
|
||||
|
||||
/** The compound selectors making up a complex selector, in order. */
|
||||
function splitCompounds(selector: Selector): Node[][] {
|
||||
const compounds: Node[][] = [[]];
|
||||
for (const node of selector.nodes) {
|
||||
if (node.type === "combinator") compounds.push([]);
|
||||
else if (node.type !== "comment") compounds.at(-1)!.push(node);
|
||||
}
|
||||
return compounds.filter((compound) => compound.length > 0);
|
||||
}
|
||||
|
||||
function isDocumentSelector(node: Node): boolean {
|
||||
return (
|
||||
(node.type === "tag" && (node.value === "html" || node.value === "body")) ||
|
||||
(node.type === "pseudo" && node.value === ":root")
|
||||
);
|
||||
}
|
||||
|
||||
function isPseudoElement(node: Node): node is Pseudo {
|
||||
if (node.type !== "pseudo") return false;
|
||||
return (
|
||||
node.value.startsWith("::") ||
|
||||
[":before", ":after", ":first-line", ":first-letter"].includes(node.value)
|
||||
);
|
||||
}
|
||||
|
||||
function pseudo(text: string): Pseudo {
|
||||
return selectorParser().astSync(text).nodes[0].nodes[0].clone() as Pseudo;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
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 ElementCallHostBridge } from "../index";
|
||||
|
||||
/**
|
||||
* A host bridge that reports everything it is told, so that the harness can
|
||||
* watch what Element Call says to its host. (What the host says to Element
|
||||
* Call goes through the component's handle, and is logged by the pane.)
|
||||
*/
|
||||
export function createDevHostBridge(
|
||||
log: (message: string) => void,
|
||||
/** What the host does when Element Call asks to be closed. */
|
||||
onClose: () => void,
|
||||
): ElementCallHostBridge {
|
||||
/**
|
||||
* Records something Element Call told the host. Nothing is sent anywhere, so
|
||||
* this is only asynchronous because a real host's answer would have to be.
|
||||
*/
|
||||
const told = async (message: string): Promise<void> => {
|
||||
log(`→ ${message}`);
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
return {
|
||||
setAlwaysOnScreen: async (alwaysOnScreen): Promise<void> =>
|
||||
await told(`setAlwaysOnScreen(${alwaysOnScreen})`),
|
||||
contentLoaded: async (): Promise<void> => await told("contentLoaded"),
|
||||
notifyJoined: async (): Promise<void> => await told("notifyJoined"),
|
||||
notifyHungUp: async (): Promise<void> => await told("notifyHungUp"),
|
||||
notifyDeviceMute: async (state): Promise<void> =>
|
||||
await told(
|
||||
`notifyDeviceMute(audio: ${state.audio_enabled}, video: ${state.video_enabled})`,
|
||||
),
|
||||
// Present because this host really can dismiss Element Call, which is what
|
||||
// makes it offer a close affordance at all
|
||||
close: async (): Promise<void> => {
|
||||
await told("close");
|
||||
onClose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
.credentials {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-inline-size: 420px;
|
||||
margin: 48px auto;
|
||||
padding: 24px;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #d4d4d8;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.harness {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.header,
|
||||
.paneBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border-block-end: 1px solid #d4d4d8;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.middle {
|
||||
display: flex;
|
||||
min-block-size: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
flex: 0 0 220px;
|
||||
padding: 12px;
|
||||
border-inline-end: 1px solid #d4d4d8;
|
||||
background-color: #ffffff;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
/* So that a pane is the size it was dragged to, rather than being stretched
|
||||
to fill the row */
|
||||
align-items: flex-start;
|
||||
align-content: flex-start;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
min-inline-size: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid #d4d4d8;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.paneBar {
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
border-block-end: none;
|
||||
}
|
||||
|
||||
/* The space the host gives Element Call. Resizable so that the sizes it has to
|
||||
cope with can be found by dragging rather than by rebuilding, and `overflow:
|
||||
hidden` both to enable the resize handle and to show up anything inside Element
|
||||
Call that does not fit the box it was given. */
|
||||
.paneCall {
|
||||
inline-size: 560px;
|
||||
block-size: 420px;
|
||||
min-inline-size: 180px;
|
||||
min-block-size: 180px;
|
||||
resize: both;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.log {
|
||||
max-block-size: 180px;
|
||||
overflow-y: auto;
|
||||
padding: 8px 12px;
|
||||
border-block-start: 1px solid #d4d4d8;
|
||||
background-color: #ffffff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log h2 {
|
||||
font-size: 13px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.log ol {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* A host overlay, which Element Call must not be able to draw over */
|
||||
.dialogScrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background-color: rgb(0 0 0 / 50%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
/*
|
||||
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 FormEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import {
|
||||
ElementCall,
|
||||
type ElementCallHandle,
|
||||
supportedLanguages,
|
||||
} from "../index";
|
||||
import { createDevHostBridge } from "./DevHostBridge";
|
||||
import { createSession, joinRoom } from "./session";
|
||||
import styles from "./Harness.module.css";
|
||||
|
||||
interface Credentials {
|
||||
homeserver: string;
|
||||
username: string;
|
||||
password: string;
|
||||
room: string;
|
||||
}
|
||||
|
||||
const CREDENTIALS_KEY = "element-call-component-harness";
|
||||
|
||||
const DEFAULT_CREDENTIALS: Credentials = {
|
||||
homeserver: "https://synapse.m.localhost",
|
||||
username: "",
|
||||
password: "",
|
||||
room: "",
|
||||
};
|
||||
|
||||
/**
|
||||
* The credentials to start with: the last ones used, so that a reload does not
|
||||
* mean typing them again, overridden by anything in the query string.
|
||||
*
|
||||
* A host reading its own URL is entirely proper — it was Element Call doing so
|
||||
* that was the mistake. It lets the end-to-end tests, or a shared link, say
|
||||
* which account and room to use.
|
||||
*/
|
||||
function loadCredentials(): Credentials {
|
||||
let stored: Partial<Credentials> = {};
|
||||
try {
|
||||
const json = localStorage.getItem(CREDENTIALS_KEY);
|
||||
if (json !== null) stored = JSON.parse(json) as Credentials;
|
||||
} catch (e) {
|
||||
logger.warn("Could not read the stored harness credentials", e);
|
||||
}
|
||||
|
||||
const query = new URLSearchParams(location.search);
|
||||
const fromUrl = Object.fromEntries(
|
||||
(["homeserver", "username", "password", "room"] as const)
|
||||
.map((name) => [name, query.get(name)])
|
||||
.filter(([, value]) => value !== null),
|
||||
) as Partial<Credentials>;
|
||||
|
||||
return { ...DEFAULT_CREDENTIALS, ...stored, ...fromUrl };
|
||||
}
|
||||
|
||||
interface Session {
|
||||
label: string;
|
||||
client: MatrixClient;
|
||||
}
|
||||
|
||||
type State =
|
||||
| { phase: "credentials" }
|
||||
| { phase: "starting"; progress: string }
|
||||
| { phase: "started"; roomId: string; sessions: Session[] }
|
||||
| { phase: "failed"; error: string };
|
||||
|
||||
interface LogEntry {
|
||||
pane: string;
|
||||
message: string;
|
||||
at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One Element Call component, with the controls a host would have over it: the
|
||||
* requests it can make of Element Call, and the ability to take it off screen
|
||||
* altogether.
|
||||
*/
|
||||
const Pane: FC<{
|
||||
session: Session;
|
||||
roomId: string;
|
||||
theme: string | undefined;
|
||||
language: string | undefined;
|
||||
log: (pane: string, message: string) => void;
|
||||
}> = ({ session, roomId, theme, language, log }): ReactNode => {
|
||||
const [mounted, setMounted] = useState(true);
|
||||
|
||||
const bridge = useMemo(
|
||||
() =>
|
||||
createDevHostBridge(
|
||||
(message) => log(session.label, message),
|
||||
() => setMounted(false),
|
||||
),
|
||||
[log, session.label],
|
||||
);
|
||||
|
||||
// What the host asks of Element Call goes through the component's handle.
|
||||
// Worth saying out loud when a request is refused — asking to hang up when
|
||||
// there is no call, say — since that is the sort of thing the harness is for.
|
||||
const handle = useRef<ElementCallHandle>(null);
|
||||
const ask = (
|
||||
name: string,
|
||||
make: (handle: ElementCallHandle) => Promise<unknown>,
|
||||
): void => {
|
||||
if (handle.current === null) {
|
||||
log(session.label, `← ${name}: not mounted`);
|
||||
return;
|
||||
}
|
||||
log(session.label, `← ${name}`);
|
||||
make(handle.current).then(
|
||||
(reply) =>
|
||||
log(
|
||||
session.label,
|
||||
`→ ${name} acknowledged${reply === undefined ? "" : `: ${JSON.stringify(reply)}`}`,
|
||||
),
|
||||
(e: unknown) => log(session.label, `→ ${name} refused: ${e}`),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={styles.pane} data-testid="call-pane">
|
||||
<div className={styles.paneBar}>
|
||||
<strong>{session.label}</strong>
|
||||
<code>{session.client.getDeviceId()}</code>
|
||||
<button onClick={(): void => setMounted((m) => !m)}>
|
||||
{mounted ? "Unmount" : "Mount"}
|
||||
</button>
|
||||
<button
|
||||
onClick={(): void =>
|
||||
ask(
|
||||
"setDeviceMute(audio: false)",
|
||||
async (h) => await h.setDeviceMute({ audio_enabled: false }),
|
||||
)
|
||||
}
|
||||
>
|
||||
Mute
|
||||
</button>
|
||||
<button
|
||||
onClick={(): void => ask("hangUp", async (h) => await h.hangUp())}
|
||||
>
|
||||
Hang up
|
||||
</button>
|
||||
</div>
|
||||
{/* Resizable, because how Element Call copes with the size it is given is
|
||||
one of the things we cannot find out from the standalone app */}
|
||||
<div className={styles.paneCall} data-testid="call-container">
|
||||
{mounted && (
|
||||
<ElementCall
|
||||
ref={handle}
|
||||
client={session.client}
|
||||
roomId={roomId}
|
||||
hostBridge={bridge}
|
||||
theme={theme}
|
||||
language={language}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
/** Host furniture, to make it visible if Element Call styles anything but itself. */
|
||||
const HostChrome: FC = (): ReactNode => (
|
||||
<nav className={styles.sidebar}>
|
||||
<h2>Host chrome</h2>
|
||||
<p>
|
||||
This column belongs to the host. If Element Call's stylesheet reaches
|
||||
outside its own container, it shows up here.
|
||||
</p>
|
||||
<hr />
|
||||
<ul>
|
||||
<li>Some room</li>
|
||||
<li>Another room</li>
|
||||
</ul>
|
||||
<button>A host button</button>
|
||||
</nav>
|
||||
);
|
||||
|
||||
/**
|
||||
* A dialog of the host's own, over the top of the calls. Element Call as a
|
||||
* component has to sit underneath this — being unable to is one of the reasons
|
||||
* for a component rather than an iframe.
|
||||
*/
|
||||
const HostDialog: FC<{ onClose: () => void }> = ({ onClose }): ReactNode => (
|
||||
<div className={styles.dialogScrim}>
|
||||
<div className={styles.dialog}>
|
||||
<h2>A dialog belonging to the host</h2>
|
||||
<p>This should cover the calls completely.</p>
|
||||
<button onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Stands in for a host application using the Element Call component: it owns the Matrix
|
||||
* clients, the page and the space each call is given, and reaches Element Call
|
||||
* only through the component's public interface.
|
||||
*
|
||||
* Two calls at once, from two devices of the same account, so that a real call
|
||||
* happens between them and anything Element Call keeps once per process rather
|
||||
* than once per call shows itself.
|
||||
*/
|
||||
export const Harness: FC = (): ReactNode => {
|
||||
const [credentials, setCredentials] = useState(loadCredentials);
|
||||
const [state, setState] = useState<State>({ phase: "credentials" });
|
||||
const [entries, setEntries] = useState<LogEntry[]>([]);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
// The host's language setting, which Element Call follows. Undefined means
|
||||
// the host has none and Element Call uses the browser's.
|
||||
const [language, setLanguage] = useState<string | undefined>(undefined);
|
||||
const [theme, setTheme] = useState<string | undefined>(undefined);
|
||||
|
||||
const log = useCallback((pane: string, message: string): void => {
|
||||
setEntries((entries) =>
|
||||
[
|
||||
...entries,
|
||||
{ pane, message, at: new Date().toLocaleTimeString() },
|
||||
].slice(-100),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const start = useCallback(
|
||||
(event: FormEvent): void => {
|
||||
event.preventDefault();
|
||||
localStorage.setItem(CREDENTIALS_KEY, JSON.stringify(credentials));
|
||||
const { homeserver, username, password, room } = credentials;
|
||||
|
||||
const progress = (message: string): void =>
|
||||
setState({ phase: "starting", progress: message });
|
||||
progress("Starting");
|
||||
|
||||
void (async (): Promise<void> => {
|
||||
try {
|
||||
// One at a time: two logins at once from the same account is the
|
||||
// shape of request homeservers rate limit
|
||||
const sessions: Session[] = [];
|
||||
for (const label of ["Call A", "Call B"])
|
||||
sessions.push({
|
||||
label,
|
||||
client: await createSession(
|
||||
homeserver,
|
||||
username,
|
||||
password,
|
||||
(message) => progress(`${label}: ${message}`),
|
||||
),
|
||||
});
|
||||
|
||||
progress("Joining the room");
|
||||
let roomId = room;
|
||||
for (const { client } of sessions)
|
||||
roomId = await joinRoom(client, roomId);
|
||||
|
||||
setState({ phase: "started", roomId, sessions });
|
||||
} catch (e) {
|
||||
logger.error("The harness could not start", e);
|
||||
setState({ phase: "failed", error: `${e}` });
|
||||
}
|
||||
})();
|
||||
},
|
||||
[credentials],
|
||||
);
|
||||
|
||||
const field = (
|
||||
name: keyof Credentials,
|
||||
label: string,
|
||||
type = "text",
|
||||
): ReactNode => (
|
||||
<label className={styles.field}>
|
||||
{label}
|
||||
<input
|
||||
type={type}
|
||||
value={credentials[name]}
|
||||
onChange={(e): void =>
|
||||
setCredentials((c) => ({ ...c, [name]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
|
||||
if (state.phase !== "started")
|
||||
return (
|
||||
<form className={styles.credentials} onSubmit={start}>
|
||||
<h1>Element Call component harness</h1>
|
||||
<p>
|
||||
Signs in twice and shows the Element Call component twice, in a page
|
||||
that is not Element Call's own.
|
||||
</p>
|
||||
{field("homeserver", "Homeserver")}
|
||||
{field("username", "Username")}
|
||||
{field("password", "Password", "password")}
|
||||
{field("room", "Room ID or alias")}
|
||||
<button type="submit" disabled={state.phase === "starting"}>
|
||||
Start
|
||||
</button>
|
||||
{state.phase === "starting" && <p>{state.progress}</p>}
|
||||
{state.phase === "failed" && (
|
||||
<p className={styles.error}>{state.error}</p>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.harness}>
|
||||
<header className={styles.header}>
|
||||
<h1>Element Call component harness</h1>
|
||||
<code>{state.roomId}</code>
|
||||
<button onClick={(): void => setDialogOpen(true)}>
|
||||
Open a host dialog
|
||||
</button>
|
||||
<label>
|
||||
Theme{" "}
|
||||
<select
|
||||
value={theme ?? ""}
|
||||
onChange={(e): void => setTheme(e.target.value || undefined)}
|
||||
>
|
||||
<option value="">Element Call's choice</option>
|
||||
<option value="light">light</option>
|
||||
<option value="dark">dark</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Language{" "}
|
||||
<select
|
||||
value={language ?? ""}
|
||||
onChange={(e): void => setLanguage(e.target.value || undefined)}
|
||||
>
|
||||
<option value="">Browser default</option>
|
||||
{supportedLanguages.map((tag) => (
|
||||
<option key={tag} value={tag}>
|
||||
{tag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</header>
|
||||
<div className={styles.middle}>
|
||||
<HostChrome />
|
||||
<main className={styles.panes}>
|
||||
{state.sessions.map((session) => (
|
||||
<Pane
|
||||
key={session.label}
|
||||
session={session}
|
||||
roomId={state.roomId}
|
||||
theme={theme}
|
||||
language={language}
|
||||
log={log}
|
||||
/>
|
||||
))}
|
||||
</main>
|
||||
</div>
|
||||
<section className={styles.log} data-testid="bridge-log">
|
||||
<h2>Host bridge</h2>
|
||||
<ol>
|
||||
{entries.map((entry, i) => (
|
||||
<li key={i}>
|
||||
<code>{entry.at}</code> <strong>{entry.pane}</strong>{" "}
|
||||
{entry.message}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
{dialogOpen && <HostDialog onClose={(): void => setDialogOpen(false)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/* The host page's own styles. Deliberately plain, and deliberately not using
|
||||
Element Call's design tokens: the harness should look like it does because of
|
||||
this file, not because Element Call styled it. */
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
background-color: #f4f4f5;
|
||||
color: #18181b;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Element Call component harness</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Deliberately plain: this page stands in for a host application, so
|
||||
anything it looks like must have come from the host's own styles or from
|
||||
Element Call reaching outside its container. -->
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
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 { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
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
|
||||
import "./host.css";
|
||||
|
||||
/**
|
||||
* The development app's own `config.json`, so that the harness runs Element
|
||||
* Call the way `pnpm dev` does. It is not in the repository — developers copy
|
||||
* it from `config/config.devenv.json` — so its absence is expected rather than
|
||||
* an error.
|
||||
*/
|
||||
async function loadConfig(): Promise<ConfigOptions> {
|
||||
try {
|
||||
const response = await fetch("/config.json");
|
||||
if (response.ok) return (await response.json()) as ConfigOptions;
|
||||
logger.warn(
|
||||
`No config.json (${response.status}); running with Element Call's defaults`,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.warn("Could not read config.json", e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
await initializeElementCall(await loadConfig());
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<Harness />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
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 {
|
||||
ClientEvent,
|
||||
createClient,
|
||||
type MatrixClient,
|
||||
MemoryStore,
|
||||
SyncState,
|
||||
} from "matrix-js-sdk";
|
||||
|
||||
/**
|
||||
* Logs in and brings up a client the way a host application would, so that the
|
||||
* component is handed a real one rather than something Element Call built for
|
||||
* itself.
|
||||
*
|
||||
* Everything is kept in memory and a fresh login happens on every reload. That
|
||||
* costs a device on the development homeserver each time, which is harmless,
|
||||
* and buys the harness two clients that cannot tread on each other's storage.
|
||||
* Persisting the login to make reloads quicker would mean persisting the
|
||||
* crypto store too: reusing a device ID with a fresh crypto store generates new
|
||||
* device keys, and uploading them conflicts with the ones the server already
|
||||
* holds.
|
||||
*/
|
||||
export async function createSession(
|
||||
homeserver: string,
|
||||
username: string,
|
||||
password: string,
|
||||
onProgress: (message: string) => void,
|
||||
): Promise<MatrixClient> {
|
||||
onProgress("Logging in");
|
||||
const login = await createClient({ baseUrl: homeserver }).login(
|
||||
"m.login.password",
|
||||
{ identifier: { type: "m.id.user", user: username }, password },
|
||||
);
|
||||
|
||||
const client = createClient({
|
||||
baseUrl: homeserver,
|
||||
accessToken: login.access_token,
|
||||
userId: login.user_id,
|
||||
deviceId: login.device_id,
|
||||
store: new MemoryStore(),
|
||||
useAuthorizationHeader: true,
|
||||
fallbackICEServerAllowed: true,
|
||||
});
|
||||
|
||||
onProgress(`Setting up crypto for ${login.device_id}`);
|
||||
await client.initRustCrypto({ useIndexedDB: false });
|
||||
|
||||
onProgress(`Syncing ${login.device_id}`);
|
||||
await client.startClient();
|
||||
await new Promise<void>((resolve) => {
|
||||
const onSync = (state: SyncState): void => {
|
||||
if (state !== SyncState.Prepared && state !== SyncState.Syncing) return;
|
||||
client.off(ClientEvent.Sync, onSync);
|
||||
resolve();
|
||||
};
|
||||
client.on(ClientEvent.Sync, onSync);
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* The room to call in, joining it if this session is not in it yet — a host
|
||||
* hands Element Call a room it already knows about, so the harness has to get
|
||||
* itself into that position first.
|
||||
*/
|
||||
export async function joinRoom(
|
||||
client: MatrixClient,
|
||||
roomIdOrAlias: string,
|
||||
): Promise<string> {
|
||||
const room = await client.joinRoom(roomIdOrAlias);
|
||||
return room.roomId;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
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 { renderHook } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
type ElementCallHandle,
|
||||
type ElementCallHostBridge,
|
||||
useComponentHostBridge,
|
||||
} from "./host";
|
||||
|
||||
describe("useComponentHostBridge", () => {
|
||||
test("keeps one identity while the host supplies new objects", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ supplied }: { supplied: ElementCallHostBridge }) =>
|
||||
useComponentHostBridge(supplied, undefined, undefined),
|
||||
{ initialProps: { supplied: {} } },
|
||||
);
|
||||
const first = result.current;
|
||||
rerender({ supplied: { notifyJoined: async () => {} } });
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
|
||||
test("forwards to whatever the host most recently supplied", async () => {
|
||||
const before = vi.fn().mockResolvedValue(undefined);
|
||||
const after = vi.fn().mockResolvedValue(undefined);
|
||||
const { result, rerender } = renderHook(
|
||||
({ supplied }: { supplied: ElementCallHostBridge }) =>
|
||||
useComponentHostBridge(supplied, undefined, undefined),
|
||||
{ initialProps: { supplied: { notifyJoined: before } } },
|
||||
);
|
||||
rerender({ supplied: { notifyJoined: after } });
|
||||
|
||||
await result.current.notifyJoined();
|
||||
expect(before).not.toHaveBeenCalled();
|
||||
expect(after).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("is quiet about what the host did not implement", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useComponentHostBridge(undefined, undefined, undefined),
|
||||
);
|
||||
await expect(result.current.contentLoaded()).resolves.toBeUndefined();
|
||||
await expect(
|
||||
result.current.notifyDeviceMute({
|
||||
audio_enabled: true,
|
||||
video_enabled: false,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(result.current.supportsReactions).toBe(true);
|
||||
// Starting the user unmuted unasked is something a host has to opt into
|
||||
expect(result.current.allowJoinUnmutedViaIntent).toBe(false);
|
||||
});
|
||||
|
||||
test("lets the host allow joining unmuted on the intent", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ supplied }: { supplied: ElementCallHostBridge }) =>
|
||||
useComponentHostBridge(supplied, undefined, undefined),
|
||||
{ initialProps: { supplied: {} } },
|
||||
);
|
||||
expect(result.current.allowJoinUnmutedViaIntent).toBe(false);
|
||||
|
||||
// Read through to whatever the host most recently said
|
||||
rerender({ supplied: { allowJoinUnmutedViaIntent: true } });
|
||||
expect(result.current.allowJoinUnmutedViaIntent).toBe(true);
|
||||
});
|
||||
|
||||
test("only has a close when the host has one, since that is a signal", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ supplied }: { supplied: ElementCallHostBridge }) =>
|
||||
useComponentHostBridge(supplied, undefined, undefined),
|
||||
{ initialProps: { supplied: {} } },
|
||||
);
|
||||
expect(result.current.close).toBeUndefined();
|
||||
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
rerender({ supplied: { close } });
|
||||
expect(result.current.close).toBeDefined();
|
||||
});
|
||||
|
||||
test("never offers profile changes, since the account is the host's", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useComponentHostBridge(undefined, undefined, undefined),
|
||||
);
|
||||
expect(result.current.supportsProfileChanges).toBe(false);
|
||||
});
|
||||
|
||||
describe("the handle", () => {
|
||||
test("delivers a request to what is listening and resolves on its reply", async () => {
|
||||
const ref = createRef<ElementCallHandle>();
|
||||
const { result } = renderHook(() =>
|
||||
useComponentHostBridge(undefined, ref, undefined),
|
||||
);
|
||||
|
||||
const received = vi.fn();
|
||||
result.current.deviceMute$.subscribe(({ data, reply }) => {
|
||||
received(data);
|
||||
reply({ audio_enabled: data.audio_enabled!, video_enabled: true });
|
||||
});
|
||||
|
||||
await expect(
|
||||
ref.current!.setDeviceMute({ audio_enabled: false }),
|
||||
).resolves.toEqual({ audio_enabled: false, video_enabled: true });
|
||||
expect(received).toHaveBeenCalledWith({ audio_enabled: false });
|
||||
});
|
||||
|
||||
test("refuses a request nothing in Element Call is listening for", async () => {
|
||||
const ref = createRef<ElementCallHandle>();
|
||||
renderHook(() => useComponentHostBridge(undefined, ref, undefined));
|
||||
|
||||
await expect(ref.current!.hangUp()).rejects.toThrow(
|
||||
"Nothing in Element Call can hang up right now",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the theme", () => {
|
||||
test("reaches a subscriber that arrives after it was set", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useComponentHostBridge(undefined, undefined, "light"),
|
||||
);
|
||||
const names: (string | undefined)[] = [];
|
||||
result.current.themeChange$.subscribe(({ data }) =>
|
||||
names.push(data.name),
|
||||
);
|
||||
expect(names).toEqual(["light"]);
|
||||
});
|
||||
|
||||
test("follows the prop", () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ theme }: { theme: string | undefined }) =>
|
||||
useComponentHostBridge(undefined, undefined, theme),
|
||||
{ initialProps: { theme: "light" } },
|
||||
);
|
||||
const names: (string | undefined)[] = [];
|
||||
result.current.themeChange$.subscribe(({ data }) =>
|
||||
names.push(data.name),
|
||||
);
|
||||
|
||||
rerender({ theme: "dark" });
|
||||
expect(names).toEqual(["light", "dark"]);
|
||||
});
|
||||
|
||||
test("says nothing when the host leaves the theme to Element Call", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useComponentHostBridge(undefined, undefined, undefined),
|
||||
);
|
||||
const names: (string | undefined)[] = [];
|
||||
result.current.themeChange$.subscribe(({ data }) =>
|
||||
names.push(data.name),
|
||||
);
|
||||
expect(names).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
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 a host application and the Element Call component talk to each other.
|
||||
*
|
||||
* Inside Element Call the host is a {@link HostBridge}, which carries the
|
||||
* host's requests as rxjs observables because that is what the widget API and
|
||||
* the view models work in. A host should not have to know about rxjs, or agree
|
||||
* with us on a version of it, so a component host sees neither: it implements
|
||||
* plain async callbacks for what Element Call tells it, and makes its own
|
||||
* requests through an imperative handle on the component, the way it would
|
||||
* call `play()` on a video element. This module adapts the one to the other.
|
||||
*/
|
||||
|
||||
import { type Ref, useEffect, useImperativeHandle } from "react";
|
||||
import { ReplaySubject, Subject } from "rxjs";
|
||||
|
||||
import {
|
||||
type DeviceMuteRequest,
|
||||
type DeviceMuteState,
|
||||
type HostBridge,
|
||||
type HostRequest,
|
||||
} from "../src/HostBridge";
|
||||
import { type JoinCallData } from "../src/widget";
|
||||
import { useInitial } from "../src/useInitial";
|
||||
import { useLatest } from "../src/useLatest";
|
||||
|
||||
/**
|
||||
* What Element Call tells the application hosting it as a component.
|
||||
* Everything is optional: a host implements what it wants to hear about.
|
||||
*
|
||||
* Compared by nothing — Element Call always calls whichever one it was most
|
||||
* recently given, so this may be written inline.
|
||||
*/
|
||||
export interface ElementCallHostBridge {
|
||||
/**
|
||||
* Asks the host to keep Element Call on screen (or stop doing so), so that a
|
||||
* call in progress is not torn down when the user navigates elsewhere.
|
||||
*/
|
||||
setAlwaysOnScreen?(alwaysOnScreen: boolean): Promise<void>;
|
||||
/** Tells the host that Element Call has finished loading. */
|
||||
contentLoaded?(): Promise<void>;
|
||||
/** Tells the host that the user has joined the call. */
|
||||
notifyJoined?(): Promise<void>;
|
||||
/** Tells the host that the user has hung up. */
|
||||
notifyHungUp?(): Promise<void>;
|
||||
/** Tells the host the user's current audio and video mute state. */
|
||||
notifyDeviceMute?(state: DeviceMuteState): Promise<void>;
|
||||
/**
|
||||
* Asks the host to close Element Call: to unmount the component. Its
|
||||
* presence is what makes Element Call offer a close button on its error
|
||||
* screens, and leave the host to decide what is shown once a call has ended.
|
||||
* Without it, Element Call shows its own post-call screen, if it has one for
|
||||
* the situation, or nothing.
|
||||
*/
|
||||
close?(): Promise<void>;
|
||||
/**
|
||||
* Whether Element Call may send and receive reactions in this room.
|
||||
* Defaults to true.
|
||||
*/
|
||||
readonly supportsReactions?: boolean;
|
||||
/**
|
||||
* Whether the user may start unmuted when the intent skips the lobby, so
|
||||
* that they never see their devices before joining. Defaults to false: the
|
||||
* user starts muted and unmutes themselves. A host that chose the intent on
|
||||
* the user's behalf, and is sure they expect to be heard and seen at once,
|
||||
* says so here — as a Matrix client hosting Element Call as a widget does.
|
||||
*/
|
||||
readonly allowJoinUnmutedViaIntent?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a host can ask of a mounted Element Call, reached through the
|
||||
* component's `ref`. Each request resolves once Element Call has acted on it,
|
||||
* and rejects if nothing in Element Call is in a position to act: hanging up
|
||||
* when there is no call, say.
|
||||
*/
|
||||
export interface ElementCallHandle {
|
||||
/**
|
||||
* Joins the call, when Element Call was configured to `preload` and is
|
||||
* waiting to be told to. Says which devices to join with.
|
||||
*/
|
||||
join(devices: JoinCallData): Promise<void>;
|
||||
/** Leaves the call. */
|
||||
hangUp(): Promise<void>;
|
||||
/**
|
||||
* Changes the mute state, for whichever of audio and video is given, and
|
||||
* reports the state that results.
|
||||
*/
|
||||
setDeviceMute(request: DeviceMuteRequest): Promise<DeviceMuteState>;
|
||||
}
|
||||
|
||||
/** Hands a request to Element Call and waits for it to be acknowledged. */
|
||||
async function request<Data, Reply>(
|
||||
listeners: Subject<HostRequest<Data, Reply>>,
|
||||
what: string,
|
||||
data: Data,
|
||||
): Promise<Reply> {
|
||||
if (!listeners.observed)
|
||||
throw new Error(`Nothing in Element Call can ${what} right now`);
|
||||
return await new Promise((resolve) =>
|
||||
listeners.next({ data, reply: resolve }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@link HostBridge} the rest of Element Call sees, built from what a
|
||||
* component host supplies and wired to the handle it is given.
|
||||
*
|
||||
* The bridge is created once and never changes identity — everything that
|
||||
* depends on it would otherwise restart when the host re-rendered with a new
|
||||
* object — and forwards each call to whatever the host most recently passed.
|
||||
*/
|
||||
export function useComponentHostBridge(
|
||||
supplied: ElementCallHostBridge | undefined,
|
||||
ref: Ref<ElementCallHandle> | undefined,
|
||||
/** The theme the host wants, or undefined to leave it to Element Call. */
|
||||
theme: string | undefined,
|
||||
): HostBridge {
|
||||
const latest = useLatest(supplied ?? {});
|
||||
|
||||
const requests = useInitial(() => ({
|
||||
// The theme is state, not an event: a `theme` prop rather than a request
|
||||
// on the handle. It travels this channel because that is how the rest of
|
||||
// Element Call hears about a host's theme, and replays so that whatever
|
||||
// subscribes after the host has set it — everything, on first render —
|
||||
// still hears the current one.
|
||||
themeChange$: new ReplaySubject<HostRequest<{ name?: string }>>(1),
|
||||
join$: new Subject<HostRequest<JoinCallData>>(),
|
||||
hangUp$: new Subject<HostRequest<Record<string, never>>>(),
|
||||
deviceMute$: new Subject<HostRequest<DeviceMuteRequest, DeviceMuteState>>(),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (theme !== undefined)
|
||||
requests.themeChange$.next({ data: { name: theme }, reply: () => {} });
|
||||
}, [requests, theme]);
|
||||
|
||||
const bridge = useInitial((): HostBridge => ({
|
||||
setAlwaysOnScreen: async (alwaysOnScreen) => {
|
||||
await latest.current.setAlwaysOnScreen?.(alwaysOnScreen);
|
||||
},
|
||||
contentLoaded: async () => {
|
||||
await latest.current.contentLoaded?.();
|
||||
},
|
||||
notifyJoined: async () => {
|
||||
await latest.current.notifyJoined?.();
|
||||
},
|
||||
notifyHungUp: async () => {
|
||||
await latest.current.notifyHungUp?.();
|
||||
},
|
||||
notifyDeviceMute: async (state) => {
|
||||
await latest.current.notifyDeviceMute?.(state);
|
||||
},
|
||||
// Whether these exist is itself information, so they are read through
|
||||
// rather than wrapped unconditionally
|
||||
get close() {
|
||||
const close = latest.current.close;
|
||||
return close === undefined
|
||||
? undefined
|
||||
: async (): Promise<void> => await close();
|
||||
},
|
||||
// Not offered to a component host: the client it hands over holds the
|
||||
// credentials to fetch media itself. A widget's client does not, which
|
||||
// is what the internal bridge's `downloadMedia` is for.
|
||||
get supportsReactions(): boolean {
|
||||
return latest.current.supportsReactions ?? true;
|
||||
},
|
||||
get allowJoinUnmutedViaIntent(): boolean {
|
||||
return latest.current.allowJoinUnmutedViaIntent ?? false;
|
||||
},
|
||||
// Whatever the host says or does not say, the account is its own: it
|
||||
// signed the user in and handed us the client. So Element Call never
|
||||
// offers to edit the profile from inside a component.
|
||||
supportsProfileChanges: false,
|
||||
...requests,
|
||||
}));
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
(): ElementCallHandle => ({
|
||||
join: async (devices) =>
|
||||
await request(requests.join$, "join a call", devices),
|
||||
hangUp: async () => await request(requests.hangUp$, "hang up", {}),
|
||||
setDeviceMute: async (muteRequest) =>
|
||||
await request(
|
||||
requests.deviceMute$,
|
||||
"change the mute state",
|
||||
muteRequest,
|
||||
),
|
||||
}),
|
||||
[requests],
|
||||
);
|
||||
|
||||
return bridge;
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
/*
|
||||
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
|
||||
*
|
||||
* Element Call as a React component, for an application that wants to show a
|
||||
* call inside itself rather than in an iframe.
|
||||
*
|
||||
* The host supplies the client and says which room to call in; Element Call
|
||||
* supplies the call. Everything it would otherwise take from the page it is on
|
||||
* — the URL, the document body, a Matrix session of its own — comes from the
|
||||
* host instead, or is confined to the container it is mounted in.
|
||||
*/
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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, 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";
|
||||
import { ErrorBoundary } from "@sentry/react";
|
||||
import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill";
|
||||
import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill.js";
|
||||
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
|
||||
import EN from "../locales/en/app.json";
|
||||
import { CallView } from "../src/room/CallView";
|
||||
import { ErrorPage } from "../src/FullScreenView";
|
||||
import { ClientProvider } from "../src/ClientContext";
|
||||
import { HostBridgeProvider } from "../src/HostBridge";
|
||||
import { RootElementProvider, useRootElement } from "../src/RootElementContext";
|
||||
import {
|
||||
configurationForIntent,
|
||||
componentProperties,
|
||||
type UrlConfiguration,
|
||||
type UrlParams,
|
||||
UrlParamsProvider,
|
||||
type UrlProperties,
|
||||
UserIntent,
|
||||
useUrlParams,
|
||||
} from "../src/UrlParams";
|
||||
import { MediaDevicesContext } from "../src/MediaDevicesContext";
|
||||
import { MediaDevices } from "../src/state/MediaDevices";
|
||||
import { ObservableScope } from "../src/state/ObservableScope";
|
||||
import { ProcessorProvider } from "../src/livekit/TrackProcessorContext";
|
||||
import { Config } from "../src/config/Config";
|
||||
import { type ConfigOptions } from "../src/config/ConfigOptions";
|
||||
import { i18n } from "../src/utils/i18n";
|
||||
import { useTheme } from "../src/useTheme";
|
||||
import { useStableValue } from "../src/useStableValue";
|
||||
import styles from "./ElementCall.module.css";
|
||||
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";
|
||||
|
||||
// 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,
|
||||
* `Intl` polyfills for older browsers, and its configuration.
|
||||
*
|
||||
* Await this once, before rendering {@link ElementCall}.
|
||||
*/
|
||||
export async function initializeElementCall(
|
||||
config: ConfigOptions = {},
|
||||
): Promise<void> {
|
||||
const polyfills: Promise<unknown>[] = [];
|
||||
if (shouldPolyfillSegmenter())
|
||||
polyfills.push(import("@formatjs/intl-segmenter/polyfill-force"));
|
||||
if (shouldPolyfillDurationFormat())
|
||||
polyfills.push(import("@formatjs/intl-durationformat/polyfill-force.js"));
|
||||
await Promise.all(polyfills);
|
||||
|
||||
Config.initWith(config);
|
||||
await i18n
|
||||
.use(translationsBackend)
|
||||
.use(new LanguageDetector())
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
defaultNS: "app",
|
||||
keySeparator: ".",
|
||||
nsSeparator: false,
|
||||
pluralSeparator: "_",
|
||||
contextSeparator: "|",
|
||||
supportedLngs: [...supportedLanguages],
|
||||
interpolation: { escapeValue: false },
|
||||
// English is bundled in, so the fallback never has to be loaded; every
|
||||
// other language arrives from the backend when first asked for.
|
||||
partialBundledLanguages: true,
|
||||
resources: { en: { app: EN } },
|
||||
detection: {
|
||||
// The browser's language, until the host says otherwise through the
|
||||
// `language` prop. Nothing is remembered: the choice is the host's.
|
||||
order: ["navigator"],
|
||||
caches: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Applies the theme and background to the container, before it is painted. */
|
||||
const Decoration: FC<{ children: JSX.Element }> = ({ children }) => {
|
||||
useTheme();
|
||||
const { background } = useUrlParams();
|
||||
const rootElement = useRootElement();
|
||||
useLayoutEffect(() => {
|
||||
rootElement.setAttribute("data-background", background);
|
||||
}, [rootElement, background]);
|
||||
return children;
|
||||
};
|
||||
|
||||
export const ElementCall: FC<ElementCallProps> = ({
|
||||
client,
|
||||
roomId,
|
||||
intent = UserIntent.JoinExistingCall,
|
||||
config,
|
||||
hostBridge: suppliedHostBridge,
|
||||
ref,
|
||||
theme,
|
||||
language,
|
||||
}): ReactNode => {
|
||||
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme);
|
||||
|
||||
useEffect(() => {
|
||||
if (language !== undefined)
|
||||
i18n
|
||||
.changeLanguage(language)
|
||||
.catch((e) => logger.error(`Could not switch to ${language}`, e));
|
||||
}, [language]);
|
||||
|
||||
// The container is what Element Call decorates and portals into, so nothing
|
||||
// inside can render until we have it.
|
||||
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
// Element Call has no URL of its own to read any of this from, and the
|
||||
// host's URL is not Element Call's business, so the defaults come from the
|
||||
// intent with the host's wishes over the top.
|
||||
//
|
||||
// Everything downstream — the mute state, the call view model and with it
|
||||
// the media connection — is keyed on the identity of this object, so it has
|
||||
// to be stable for as long as its contents are. A host writing `config`
|
||||
// inline would otherwise tear the call down on every render.
|
||||
const stableConfig = useStableValue(config);
|
||||
const params = useMemo(
|
||||
(): UrlParams => ({
|
||||
...componentProperties,
|
||||
roomId,
|
||||
...configurationForIntent(intent),
|
||||
...stableConfig,
|
||||
}),
|
||||
[roomId, intent, stableConfig],
|
||||
);
|
||||
|
||||
// Created in an effect so that the scope it lives in ends when the component
|
||||
// is unmounted (or these options change), rather than keeping its device
|
||||
// observers running for the rest of the page's life. Null until then, which
|
||||
// is one render.
|
||||
const { controlledAudioDevices, callIntent } = params;
|
||||
const [mediaDevices, setMediaDevices] = useState<MediaDevices | null>(null);
|
||||
useEffect(() => {
|
||||
const scope = new ObservableScope();
|
||||
setMediaDevices(
|
||||
new MediaDevices(scope, { controlledAudioDevices, callIntent }),
|
||||
);
|
||||
return (): void => {
|
||||
setMediaDevices(null);
|
||||
scope.end();
|
||||
};
|
||||
}, [controlledAudioDevices, callIntent]);
|
||||
|
||||
const room = client.getRoom(roomId);
|
||||
const rtcSession = useMemo(
|
||||
() => (room === null ? null : client.matrixRTC.getRoomSession(room)),
|
||||
[client, room],
|
||||
);
|
||||
|
||||
if (rtcSession === null)
|
||||
logger.error(
|
||||
`Element Call was asked to call in ${roomId}, which its host's client does not know about`,
|
||||
);
|
||||
|
||||
// Everything the call needs is in hand once these exist, and the first
|
||||
// render with them is where the call itself appears: the moment the host
|
||||
// is told that Element Call has loaded, as the widget tells its client once
|
||||
// its own initialisation is over. Once per mount, however often the pieces
|
||||
// are later swapped out.
|
||||
const ready =
|
||||
container !== null && rtcSession !== null && mediaDevices !== null;
|
||||
const announcedLoaded = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!ready || announcedLoaded.current) return;
|
||||
announcedLoaded.current = true;
|
||||
hostBridge
|
||||
.contentLoaded()
|
||||
.catch((e) => logger.error("Could not tell the host we had loaded", e));
|
||||
}, [ready, hostBridge]);
|
||||
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<HostBridgeProvider value={hostBridge}>
|
||||
<UrlParamsProvider value={params}>
|
||||
<div ref={setContainer} className={styles.root}>
|
||||
{ready && (
|
||||
<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>
|
||||
</HostBridgeProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
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 { describe, expect, test } from "vitest";
|
||||
|
||||
import { supportedLanguages, translationsBackend } from "./localization";
|
||||
|
||||
const read = async (
|
||||
language: string,
|
||||
namespace = "app",
|
||||
): Promise<Record<string, unknown>> =>
|
||||
await new Promise((resolve, reject) =>
|
||||
translationsBackend.read(language, namespace, (error, data) => {
|
||||
if (error) reject(error);
|
||||
else resolve(data as Record<string, unknown>);
|
||||
}),
|
||||
);
|
||||
|
||||
describe("component translations", () => {
|
||||
test("offer every language in locales/, tagged as its directory is", () => {
|
||||
expect(supportedLanguages).toContain("en");
|
||||
expect(supportedLanguages).toContain("de");
|
||||
expect(supportedLanguages).toContain("zh-Hans");
|
||||
expect(new Set(supportedLanguages).size).toBe(supportedLanguages.length);
|
||||
});
|
||||
|
||||
test("load a language's translations on demand", async () => {
|
||||
const de = await read("de");
|
||||
expect(de).toHaveProperty("action");
|
||||
expect(de).not.toEqual(await read("en"));
|
||||
});
|
||||
|
||||
test("refuse a language there are no translations for", async () => {
|
||||
await expect(read("xx")).rejects.toThrow("No app translations for xx");
|
||||
});
|
||||
|
||||
test("refuse a namespace there are no translations for", async () => {
|
||||
await expect(read("en", "other")).rejects.toThrow(
|
||||
"No other translations for en",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Translations for Element Call as a component.
|
||||
*
|
||||
* The standalone app fetches its locale files at runtime from URLs its own
|
||||
* build emits, which a host serving the library from somewhere else could not
|
||||
* resolve. The component instead has the bundler split every locale into a
|
||||
* chunk of its own, loaded the first time its language is asked for; English,
|
||||
* the fallback, is bundled in so that the first paint never waits for it.
|
||||
*/
|
||||
|
||||
import { type BackendModule, type ResourceKey } from "i18next";
|
||||
|
||||
import { languageOfLocalePath } from "../src/utils/i18n";
|
||||
|
||||
/** Every locale, as a lazily imported module. */
|
||||
const translations = import.meta.glob<{ default: ResourceKey }>(
|
||||
"../locales/*/app.json",
|
||||
);
|
||||
|
||||
/**
|
||||
* The languages Element Call can be shown in, as BCP 47 tags — `en`, `de`,
|
||||
* `zh-Hans` and so on. A language that is not one of these falls back to its
|
||||
* base language where there is one (`de-AT` to `de`), and to English otherwise.
|
||||
*/
|
||||
export const supportedLanguages: readonly string[] = [
|
||||
...new Set(Object.keys(translations).map(languageOfLocalePath)),
|
||||
];
|
||||
|
||||
/** Loads translations on demand. */
|
||||
export const translationsBackend: BackendModule = {
|
||||
type: "backend",
|
||||
init(): void {},
|
||||
read(language: string, namespace: string, callback): void {
|
||||
const load = translations[`../locales/${language}/${namespace}.json`];
|
||||
if (load === undefined) {
|
||||
callback(new Error(`No ${namespace} translations for ${language}`), null);
|
||||
return;
|
||||
}
|
||||
load().then(
|
||||
(module) => callback(null, module.default),
|
||||
(error: unknown) =>
|
||||
callback(
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
null,
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@element-hq/element-call-component",
|
||||
"version": "0.0.0",
|
||||
"description": "Element Call as a React component. Consumed straight from the repository as a git dependency (github:element-hq/element-call#<ref>&path:/component): the host's package manager runs `prepare`, which builds `dist/`.",
|
||||
"license": "SEE LICENSE IN ../README.md",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/element-hq/element-call",
|
||||
"directory": "component"
|
||||
},
|
||||
"type": "module",
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/element-call.js",
|
||||
"module": "./dist/element-call.js",
|
||||
"types": "./dist/types/component/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types/component/index.d.ts",
|
||||
"default": "./dist/element-call.js"
|
||||
},
|
||||
"./style.css": "./dist/element-call.css"
|
||||
},
|
||||
"sideEffects": [
|
||||
"*.css"
|
||||
],
|
||||
"scripts": {
|
||||
"prepare": "cd .. && pnpm install --frozen-lockfile && pnpm build:component"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"livekit-client": "^2.18.1",
|
||||
"matrix-js-sdk": "*",
|
||||
"react": "^19",
|
||||
"react-dom": "^19"
|
||||
}
|
||||
}
|
||||
Generated
+9
@@ -0,0 +1,9 @@
|
||||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: false
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.: {}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Makes `component/` a pnpm project of its own rather than a directory inside
|
||||
# the repository's workspace. That matters when a host installs the component
|
||||
# straight from this repository (`github:element-hq/element-call#…&path:/component`):
|
||||
# pnpm prepares such a dependency by running `pnpm install` in this directory,
|
||||
# and only a project root gets its `prepare` script (see package.json) run, which
|
||||
# is what builds `dist/`. Inside the repository's own workspace the install
|
||||
# would silently target the repository instead and build nothing.
|
||||
#
|
||||
# Consequence for development: pnpm commands run from within this directory see
|
||||
# this project, not the repository; run them from the repository root.
|
||||
|
||||
# Nothing to install here: the peers in package.json are the host's, and the
|
||||
# build runs against the repository's own node_modules (see `prepare`).
|
||||
autoInstallPeers: false
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// Declaration output for the component package (`pnpm build:component:types`).
|
||||
// The root tsconfig only type-checks; this one emits `.d.ts` files, and nothing
|
||||
// else, for everything the component's entry point reaches. The layout under
|
||||
// `dist/types` mirrors the repository (`component/index.d.ts`, `src/…`), which
|
||||
// is what `package.json` points its `types` at.
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
"declarationMap": false,
|
||||
"rootDir": "..",
|
||||
"outDir": "./dist/types"
|
||||
},
|
||||
// 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": []
|
||||
}
|
||||
@@ -9,8 +9,6 @@
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"delayed_leave_event_delay_ms": 18000,
|
||||
"delayed_leave_event_restart_ms": 4000,
|
||||
"network_error_retry_ms": 100
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"delayed_leave_event_delay_ms": 18000,
|
||||
"delayed_leave_event_restart_ms": 4000,
|
||||
"network_error_retry_ms": 100
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"delayed_leave_event_delay_ms": 18000,
|
||||
"delayed_leave_event_restart_ms": 4000,
|
||||
"network_error_retry_ms": 100
|
||||
},
|
||||
"posthog": {
|
||||
|
||||
@@ -9,8 +9,6 @@
|
||||
"matrix_rtc_session": {
|
||||
"wait_for_key_rotation_ms": 3000,
|
||||
"membership_event_expiry_ms": 180000000,
|
||||
"delayed_leave_event_delay_ms": 18000,
|
||||
"delayed_leave_event_restart_ms": 4000,
|
||||
"network_error_retry_ms": 100
|
||||
}
|
||||
}
|
||||
|
||||
+28
-4
@@ -3,7 +3,7 @@ networks:
|
||||
|
||||
services:
|
||||
auth-service:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.7.0
|
||||
pull_policy: always
|
||||
hostname: auth-server
|
||||
environment:
|
||||
@@ -15,6 +15,14 @@ services:
|
||||
# a self-signed certificate
|
||||
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
|
||||
- LIVEKIT_FULL_ACCESS_HOMESERVERS=*
|
||||
# Registers this instance as an application service on the main homeserver
|
||||
- LIVEKIT_AS_REGISTRATION_FILE=/etc/lk-jwt-service/app-service.yaml
|
||||
- LIVEKIT_HS_SERVER_NAME=synapse.m.localhost
|
||||
# Neither homeserver serves /.well-known/matrix/client, so the C-S API
|
||||
# location has to be given explicitly.
|
||||
- LIVEKIT_CS_API_URL_OVERRIDES=synapse.m.localhost=http://homeserver:8008,synapse.othersite.m.localhost=http://homeserver-1:18008
|
||||
volumes:
|
||||
- ./backend/app-service.yaml:/etc/lk-jwt-service/app-service.yaml:Z
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
@@ -25,7 +33,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
auth-service-1:
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.4.4
|
||||
image: ghcr.io/element-hq/lk-jwt-service:0.7.0
|
||||
pull_policy: always
|
||||
hostname: auth-server-1
|
||||
environment:
|
||||
@@ -37,6 +45,14 @@ services:
|
||||
# a self-signed certificate
|
||||
- LIVEKIT_INSECURE_SKIP_VERIFY_TLS=YES_I_KNOW_WHAT_I_AM_DOING
|
||||
- LIVEKIT_FULL_ACCESS_HOMESERVERS=*
|
||||
# Registers this instance as an application service on the other homeserver.
|
||||
- LIVEKIT_AS_REGISTRATION_FILE=/etc/lk-jwt-service/app-service.yaml
|
||||
- LIVEKIT_HS_SERVER_NAME=synapse.othersite.m.localhost
|
||||
# Neither homeserver serves /.well-known/matrix/client, so the C-S API
|
||||
# location has to be given explicitly.
|
||||
- LIVEKIT_CS_API_URL_OVERRIDES=synapse.m.localhost=http://homeserver:8008,synapse.othersite.m.localhost=http://homeserver-1:18008
|
||||
volumes:
|
||||
- ./backend/app-service-othersite.yaml:/etc/lk-jwt-service/app-service.yaml:Z
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
@@ -94,7 +110,10 @@ services:
|
||||
|
||||
synapse:
|
||||
hostname: homeserver
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
# develop (not latest) is required for application service C-S/S-S proxying
|
||||
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
|
||||
# release yet.
|
||||
image: ghcr.io/element-hq/synapse:develop
|
||||
pull_policy: always
|
||||
environment:
|
||||
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
|
||||
@@ -107,12 +126,16 @@ services:
|
||||
volumes:
|
||||
- ./backend/synapse_tmp:/data:Z
|
||||
- ./backend/dev_homeserver.yaml:/data/cfg/homeserver.yaml:Z
|
||||
- ./backend/app-service.yaml:/data/cfg/app-service.yaml:Z
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
synapse-1:
|
||||
hostname: homeserver-1
|
||||
image: ghcr.io/element-hq/synapse:latest
|
||||
# develop (not latest) is required for application service C-S/S-S proxying
|
||||
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
|
||||
# release yet.
|
||||
image: ghcr.io/element-hq/synapse:develop
|
||||
pull_policy: always
|
||||
environment:
|
||||
- SYNAPSE_CONFIG_PATH=/data/cfg/homeserver.yaml
|
||||
@@ -125,6 +148,7 @@ services:
|
||||
volumes:
|
||||
- ./backend/synapse_tmp_othersite:/data:Z
|
||||
- ./backend/dev_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
|
||||
- ./backend/app-service-othersite.yaml:/data/cfg/app-service.yaml:Z
|
||||
networks:
|
||||
- ecbackend
|
||||
|
||||
|
||||
@@ -3,17 +3,23 @@
|
||||
services:
|
||||
synapse:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/synapse:latest@sha256:da325af40104051923899e5f5a2f1d537e6e3ccf2f0f38285689ae9bbfdb190a
|
||||
# develop (not latest) is required for application service C-S/S-S proxying
|
||||
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
|
||||
# release yet.
|
||||
image: ghcr.io/element-hq/synapse:develop@sha256:337921fd22be310d453344b2265f6c9942315446595df3cf81866f71ca2055a7
|
||||
volumes:
|
||||
- ./backend/playwright_homeserver.yaml:/data/cfg/homeserver.yaml:Z
|
||||
synapse-1:
|
||||
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
|
||||
image: ghcr.io/element-hq/synapse:latest@sha256:da325af40104051923899e5f5a2f1d537e6e3ccf2f0f38285689ae9bbfdb190a
|
||||
# develop (not latest) is required for application service C-S/S-S proxying
|
||||
# (MSC4512) and membership look-ups (MSC4502), which are not in a stable
|
||||
# release yet.
|
||||
image: ghcr.io/element-hq/synapse:develop@sha256:337921fd22be310d453344b2265f6c9942315446595df3cf81866f71ca2055a7
|
||||
volumes:
|
||||
- ./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:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:c76d29903090eeb08ff625272277ede02ea8ee93cbaa067b77adfa89c89a494d
|
||||
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:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:c76d29903090eeb08ff625272277ede02ea8ee93cbaa067b77adfa89c89a494d
|
||||
|
||||
@@ -3,8 +3,18 @@
|
||||
This folder contains documentation for setup, usage, and development of Element Call.
|
||||
|
||||
- [Embedded vs standalone mode](./embedded_standalone.md)
|
||||
- [Element Call as a React component (experimental)](../README.md#element-call-as-a-component-experimental)
|
||||
- [Url format and parameters](./url_params.md)
|
||||
- [Global JS controls](./controls.md)
|
||||
- [MatrixRTC modes](./matrix_rtc_modes.md)
|
||||
- [Self-Hosting](./self_hosting.md)
|
||||
- [Developing with linked packages](./linking.md)
|
||||
|
||||
### Writing code for Element Call (agent-readable conventions)
|
||||
|
||||
Entry point: [AGENTS.md](../AGENTS.md).
|
||||
|
||||
- [Agent workflow](./agents/workflow.md)
|
||||
- [Architecture](./agents/architecture.md)
|
||||
- [Code style](./agents/code-style.md)
|
||||
- [Testing](./agents/testing.md)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Architecture
|
||||
|
||||
## Logic lives in view models; components render
|
||||
|
||||
- Call logic lives in `src/state/`. A component that derives call state is
|
||||
misplaced logic.
|
||||
- Composing behaviors: a factory taking `(scope, ...deps$)`, returning named
|
||||
behaviors plus callbacks. Copy `src/state/LayoutSwitchViewModel.ts`. Prefer this
|
||||
for anything new.
|
||||
- Owning a resource: a class taking the scope in its constructor — `MediaDevices`,
|
||||
`MuteStates`, `TileStore`, `Publisher`, `Connection`. Both styles are current;
|
||||
don't convert one to the other in passing.
|
||||
- `foo$` is an observable. `Behavior<T>`, an observable with a current value, is
|
||||
the default for anything a view reads.
|
||||
|
||||
## The view model / view contract
|
||||
|
||||
- Components take `vm: ViewModel<Snapshot>` and read state through `useBehavior`.
|
||||
See `CallFooter`, `InCallView`, `LobbyView`, `SettingsModal`.
|
||||
- A snapshot is `Actions & State`; every field becomes a `foo$` behavior, and none
|
||||
is optional.
|
||||
- An unavailable action is `undefined`, not a separate `canDoThing` flag — the
|
||||
presence of the callback drives the rendering.
|
||||
- Subscribe in an effect only to drive a side effect off an event stream, as
|
||||
`ReactionAudioRenderer` does; never to read state a behavior already holds.
|
||||
|
||||
## Scopes own lifetimes
|
||||
|
||||
- `ObservableScope` bounds every subscription a view model creates.
|
||||
- Reference only the scope defined in the same function; one captured from an
|
||||
enclosing scope outlives its owner, and `no-observablescope-leak` rejects it.
|
||||
|
||||
## Nothing reads the page
|
||||
|
||||
Element Call can be mounted several times inside a host's React tree, so it owns
|
||||
neither window, URL, document nor router. Each seam defaults to the old standalone
|
||||
and widget behaviour.
|
||||
|
||||
| Never | Use |
|
||||
| --------------------------------------------- | ------------------------------------------------------------- |
|
||||
| the `widget` global | `useHostBridge()` — `src/HostBridge.ts` |
|
||||
| `getUrlParams()`, `window.location` | `useUrlParams()` |
|
||||
| `useNavigate("/")`, `<Link to="/">` | `useLeaveToHome()`, `LeaveToHomeLink` |
|
||||
| `document.body` | `useRootElement()` |
|
||||
| `window.innerWidth/Height`, `useMediaQuery` | `useRootSizeMatches()` in views, `windowSize$` in view models |
|
||||
| global `i18next` | the instance in `src/utils/i18n.ts` |
|
||||
| config or analytics reading their environment | `Config.initWith()`, `PosthogAnalytics.configure()` |
|
||||
|
||||
- View models take values as options, never `getUrlParams()`
|
||||
(`callViewModelOptionsFromParams`, `CallViewModelOptions.hostBridge`).
|
||||
- The host bridge is the only channel to the host: `createWidgetHostBridge(widget)`,
|
||||
`nullHostBridge` standalone, `useComponentHostBridge`. State a capability
|
||||
(`supportsReactions`, `supportsProfileChanges`); never infer it from being a
|
||||
widget.
|
||||
- Shortcuts and portals attach to the root element, so two instances don't fight.
|
||||
- Known debt, not precedent: `Grid` measures `window.innerHeight`; `ErrorView`'s
|
||||
reload and `getAbsoluteRoomUrl` use `window.location`; recaptcha appends to
|
||||
`document.body` on the standalone login path.
|
||||
|
||||
## Context differences are options, not checks
|
||||
|
||||
- Named options with per-intent defaults (`configurationForIntent`), never a
|
||||
runtime check for who is hosting.
|
||||
- `controlledAudioDevices`, not the platform, is what makes `MediaDevices` pick
|
||||
`AndroidControlledAudioOutput` / `IOSControlledAudioOutput` over the web
|
||||
`AudioOutput`. Intent presets set it on non-desktop; standalone leaves it off.
|
||||
- URL params are a published contract: change one, update `docs/url_params.md`.
|
||||
|
||||
## Build targets
|
||||
|
||||
Code that builds in only one is a bug.
|
||||
|
||||
- `build:full` — standalone app, also widget mode.
|
||||
- `build:embedded` — `@element-hq/element-call-embedded`.
|
||||
- `build:sdk` — SDK library, entry `sdk/main.ts`.
|
||||
- `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` / `livekit-client` subpath the externals list omits.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Code style
|
||||
|
||||
_Clean Code_ (Robert C. Martin): small functions, intention-revealing names, low
|
||||
noise.
|
||||
|
||||
## Reuse before you build
|
||||
|
||||
Look before you write, in this order:
|
||||
|
||||
1. **Compound Web** — `@vector-im/compound-web` for buttons, tooltips, alerts,
|
||||
dialogs, menus, form controls, avatars; icons in
|
||||
`@vector-im/compound-design-tokens/assets/web/icons`.
|
||||
2. **This repo** — `src/button/`, `src/components/`, `src/input/`, `src/form/`,
|
||||
`src/tabs/`, and `Modal`, `Avatar`, `Toast`, `Slider`, `ErrorView`,
|
||||
`FullScreenView` at the root of `src/`.
|
||||
3. **The nearest existing feature** — grep for the same shape first. A hook is
|
||||
often what you want to extract, not a component.
|
||||
|
||||
If nothing fits, name the new shared component in the PR body and say what you
|
||||
rejected and why — never add one silently. Extend or parameterise before forking,
|
||||
and extract shared logic into a hook both callers use before copying it; if you
|
||||
copy, the PR says so.
|
||||
|
||||
## Ordering
|
||||
|
||||
- **Newspaper order.** Headline first, detail down. A file opens with what it
|
||||
exists to provide.
|
||||
- **Stepdown rule.** Caller above callee. A helper used by one function sits
|
||||
beneath it, which needs a `function` declaration, not a `const` arrow. Existing
|
||||
arrow components above their callers are not a pattern to extend — nor to churn.
|
||||
- **Suites.** Test cases first, helpers below. Older suites invert this; follow the
|
||||
rule in new ones, don't reorder old ones.
|
||||
- **Names.** What a thing means, not what it is made of: `naturalLayout$`, not
|
||||
`computedLayout$`. Comments explain why, never what.
|
||||
|
||||
## TypeScript, React and RxJS idiom win on a clash
|
||||
|
||||
- Hooks stay unconditional at the top of a component.
|
||||
- `useCallback` / `useMemo` dependency arrays sometimes force inlining.
|
||||
- An RxJS pipeline stays one expression. Name the behavior, not each operator.
|
||||
- A long factory is fine when it reads as a list of named behaviors.
|
||||
`CallViewModel.ts` is long because the domain is.
|
||||
- Marble test tables are dense on purpose.
|
||||
|
||||
## Enforced by lint
|
||||
|
||||
- Copyright header on every file: `Copyright <current year> Element Creations Ltd.`
|
||||
plus the AGPL / commercial SPDX line.
|
||||
- `logger` from `matrix-js-sdk/lib/logger`, never `console`, and no top-level
|
||||
`logger.getChild()`. The `console` ban is only enforced under `src/*/**`.
|
||||
- No floating or misused promises; async functions typed `Promise<T>`.
|
||||
- Inline type imports (`import { type Foo }`), so matrix-js-sdk stays lazily
|
||||
loadable.
|
||||
- Deep-import `matrix-js-sdk/lib/<module>` as the codebase does. Banned is the bare
|
||||
`matrix-js-sdk/lib`, `lib/index` and anything under `src/`.
|
||||
|
||||
## CSS
|
||||
|
||||
- Compound components and `--cpd-*` tokens in CSS modules. A hardcoded colour or px
|
||||
spacing is a design question, not a licence to inline a hex.
|
||||
- Size against the root, not the window: `@container element-call (…)` and
|
||||
`cqw` / `cqh`, never `@media (width)` or `vw` / `vh`. Media queries stay correct
|
||||
only in standalone-only views — home, login.
|
||||
- Style `[data-element-call-root]`, never `body` or `:root`; the component build
|
||||
makes those stand for the root (`component/build/scopeStylesToRoot.ts`).
|
||||
|
||||
## Strings and a11y
|
||||
|
||||
- Strings through `t()`: add the key, run `pnpm i18n`, fill `locales/en/app.json`.
|
||||
Other locales come from Localazy; never hand-edit them.
|
||||
- `t` from `useTranslation()`, or `src/utils/i18n.ts` outside React. Never the
|
||||
`i18next` global — several instances share a page.
|
||||
- Accessible names on controls, `aria-pressed` on toggles, keyboard reachability.
|
||||
`jsx-a11y` rules are errors; `vitest-axe` is available.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Testing
|
||||
|
||||
Three layers; a user-facing feature needs all three. Unit proves the logic,
|
||||
Storybook proves the rendering, e2e proves the feature. Codecov gates 80% on the
|
||||
lines a PR touches.
|
||||
|
||||
## Unit
|
||||
|
||||
- View model tests use `withTestScheduler` + `testScope()` from `src/utils/test.ts`
|
||||
and read as ASCII timelines. Copy `LayoutSwitchViewModel.test.ts`.
|
||||
- Component tests use Testing Library, queried by role and accessible name.
|
||||
- Reuse the factories in `src/utils/test.ts` — `mockRemoteParticipant`,
|
||||
`mockMatrixRoom`, `mockLivekitRoom`, `MockRTCSession`. Hand-rolled mocks drift.
|
||||
- Snapshots live in `__snapshots__/`; update with `pnpm test <Name> -u`.
|
||||
- How often something redraws is testable: drive the frames and count commits, not
|
||||
render calls. An effect with no dependency array runs once per commit.
|
||||
- `component/**/*.test.ts` runs in the same jsdom project as `src`.
|
||||
|
||||
## Storybook
|
||||
|
||||
A deliverable, not documentation: `pnpm test:storybook` runs every story as a real
|
||||
browser test in the same CI job as the unit suite. **A UI change without a story is
|
||||
incomplete.** Only three stories exist so far, so copy `CallFooter.stories.tsx`.
|
||||
|
||||
- Drive the component from a snapshot via `useStaticViewModel`, never internals.
|
||||
- One named story per state that matters — loading, error, empty, denied, mobile.
|
||||
- Assert in a `play` function with `userEvent` / `expect` from `storybook/test`.
|
||||
- Mobile is `globals: { viewport: { value: "mobile2" } }`, not a second component.
|
||||
- Expose interesting props through `argTypes`.
|
||||
|
||||
`.storybook/preview.tsx` supplies `TooltipProvider`, `src/index.css` and English
|
||||
translations. The browser is Playwright's, so a fresh clone needs
|
||||
`pnpm playwright install` once.
|
||||
|
||||
## End-to-end
|
||||
|
||||
- `playwright/*.spec.ts` — standalone, on Chromium and Firefox.
|
||||
- `playwright/widget/` — widget mode, where cross-context bugs surface.
|
||||
- `playwright/component/` — the component in a host page, via the harness on port
|
||||
3001 that Playwright starts as a second web server. Catches container-relative
|
||||
layout, styles escaping the root, two instances on a page, host-bridge reports.
|
||||
- `playwright/mobile/` — Pixel 7, `mobile` project only.
|
||||
|
||||
Test what a user observes: the peer sees the change, it survives a reconnect, it is
|
||||
right after a reload. Reuse `playwright/spa-helpers.ts`,
|
||||
`playwright/widget/test-helpers.ts`, `playwright/fixtures/`.
|
||||
|
||||
```sh
|
||||
pnpm test # unit + storybook
|
||||
pnpm backend # Synapse + LiveKit, required for e2e
|
||||
pnpm test:playwright # or :open
|
||||
pnpm dev:component # component harness, port 3001
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
# Agent workflow
|
||||
|
||||
## Scratch files live in `agent-workspace/`
|
||||
|
||||
Git-ignored. One kebab-case subfolder per task, matching the branch topic:
|
||||
`agent-workspace/<slug>/`.
|
||||
|
||||
| File | Purpose |
|
||||
| --------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `plan.md` | The concrete approach — files, symbols, edit order — once direction is confirmed |
|
||||
| `commit-msg.txt` | `git commit -F agent-workspace/<slug>/commit-msg.txt` |
|
||||
| `pr-body.md` | `gh pr create --body-file agent-workspace/<slug>/pr-body.md` |
|
||||
| `implementation-summary.md` | What was built, decisions, trade-offs |
|
||||
| `NN-prompt.md` | Raw prompt text, numbered, when worth keeping |
|
||||
|
||||
- Nothing here is durable. The folder is ignored, so anything worth keeping is
|
||||
promoted into the PR body, a doc, or a feature spec before the task ends.
|
||||
- Never write scratch files to the repo root. Stray `load_test_summary.md` and
|
||||
`config.json_` files are what this folder prevents.
|
||||
- Start `pr-body.md` from `.github/PULL_REQUEST_TEMPLATE.md` and fill every
|
||||
section.
|
||||
- Commit subjects are plain imperative English. No conventional-commits prefixes.
|
||||
- A `plan.md` and a feature spec sit at different altitudes; neither replaces the
|
||||
other. A spec in `FEATURES_SPEC/` is durable and deliberately abstract —
|
||||
behaviour, decisions, acceptance criteria — so the feature can be rebuilt against
|
||||
a `main` nobody has seen yet. A plan is one slice of it landing on today's
|
||||
`main`: the paths, symbols and edit order the spec's decisions and criteria must
|
||||
not name. `FEATURES_SPEC/AGENTS.md` wins wherever the two genuinely overlap.
|
||||
|
||||
## Hand off before the quality pass
|
||||
|
||||
- Implement the change, run the narrowest check that rules out an obviously broken
|
||||
handoff, then stop and ask whether the direction is right.
|
||||
- Full `pnpm lint`, the whole suite, coverage and benchmarks come after the
|
||||
direction is confirmed.
|
||||
- Wider refactors, extra tests and documentation polish are follow-up work, not
|
||||
part of the first handoff.
|
||||
- If a check is needed before feedback, keep it to the touched code and say why.
|
||||
|
||||
## Commit and PR readiness
|
||||
|
||||
- Commit once the user confirms direction, or asks for one. Not before. Where the
|
||||
setup has no git identity, a sandbox included, write the message to
|
||||
`commit-msg.txt` and hand it over instead.
|
||||
- Ready means every gate in [AGENTS.md](../../AGENTS.md#gates) is green and the
|
||||
change is covered at the layers [testing.md](./testing.md) asks for. Read the diff
|
||||
against [code-style.md](./code-style.md) first.
|
||||
- Re-run the whole checklist after any fix. Only on green, either way.
|
||||
- Once a human has started reviewing, fix forward — never force-push a
|
||||
regeneration over a review in progress.
|
||||
@@ -3,6 +3,9 @@
|
||||
Element Call is available as two different packages: Full Package and Embedded Package.
|
||||
|
||||
The Full Package is designed for standalone use, while the Embedded Package is designed for widget mode only.
|
||||
There is also an experimental third option, a build of Element Call as a React component for applications
|
||||
that want to render a call inside their own page rather than in an iframe; see
|
||||
[Element Call as a component](../README.md#element-call-as-a-component-experimental) in the README.
|
||||
|
||||
The table below provides a comparison of the two packages:
|
||||
|
||||
|
||||
@@ -9,7 +9,13 @@ import { type KnipConfig } from "knip";
|
||||
|
||||
export default {
|
||||
vite: {
|
||||
config: ["vite.config.ts", "vite-embedded.config.ts", "vite-sdk.config.ts"],
|
||||
config: [
|
||||
"vite.config.ts",
|
||||
"vite-embedded.config.ts",
|
||||
"vite-sdk.config.ts",
|
||||
"vite-component.config.ts",
|
||||
"vite-component-dev.config.ts",
|
||||
],
|
||||
},
|
||||
entry: ["src/main.tsx", "eslint/index.js", "i18next.config.ts"],
|
||||
ignoreBinaries: [
|
||||
|
||||
+11
-3
@@ -6,6 +6,7 @@
|
||||
"dev": "pnpm dev:full",
|
||||
"dev:full": "vite",
|
||||
"dev:embedded": "vite --config vite-embedded.config.js",
|
||||
"dev:component": "vite --config vite-component-dev.config.ts",
|
||||
"build": "pnpm build:full",
|
||||
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
|
||||
"build:full:production": "pnpm build:full",
|
||||
@@ -16,13 +17,19 @@
|
||||
"build:sdk:development": "pnpm build:sdk --mode development",
|
||||
"build:sdk": "pnpm build:full --config vite-sdk.config.js",
|
||||
"build:sdk:production": "pnpm build:sdk",
|
||||
"build:component": "pnpm build:component:js && pnpm build:component:types",
|
||||
"build:component:js": "pnpm build:full --config vite-component.config.js",
|
||||
"build:component:types": "tsc -p component/tsconfig.build.json",
|
||||
"build:component:production": "pnpm build:component",
|
||||
"build:component:development": "pnpm build:component:js --mode development && pnpm build:component:types",
|
||||
"serve": "vite preview",
|
||||
"format": "oxfmt",
|
||||
"format:check": "oxfmt --check; rc=$?; [[ $rc -ne 0 ]] && printf '\\033[46;30m INFO \\033[0m To fix, run: pnpm format\\n' >&2; exit $rc",
|
||||
"lint": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip",
|
||||
"lint:oxlint": "oxlint src playwright",
|
||||
"lint:oxlint-fix": "oxlint --fix src playwright",
|
||||
"lint": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip && pnpm lint:externals",
|
||||
"lint:oxlint": "oxlint src component playwright",
|
||||
"lint:oxlint-fix": "oxlint --fix src component playwright",
|
||||
"lint:knip": "knip",
|
||||
"lint:externals": "node scripts/check-component-externals.mjs",
|
||||
"lint:types": "tsc",
|
||||
"i18n": "npx i18next-cli extract",
|
||||
"i18n:check": "npx i18next-cli extract --ci",
|
||||
@@ -107,6 +114,7 @@
|
||||
"pako": "^2.0.4",
|
||||
"postcss": "^8.4.41",
|
||||
"postcss-preset-env": "^10.0.0",
|
||||
"postcss-selector-parser": "^7.1.1",
|
||||
"posthog-js": "1.408.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19",
|
||||
|
||||
+31
-9
@@ -11,6 +11,8 @@ import { join } from "path";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { COMPONENT_HARNESS_URL } from "./playwright/component/harness.ts";
|
||||
|
||||
const baseURL = process.env.USE_DOCKER
|
||||
? "http://localhost:8080"
|
||||
: "https://localhost:3000";
|
||||
@@ -84,6 +86,11 @@ export default defineConfig({
|
||||
// enumerateDevices work on CI runners without real hardware.
|
||||
"media.navigator.streams.fake": true,
|
||||
"media.navigator.permission.disabled": true,
|
||||
// Vite serves HTTPS over HTTP/2, and Firefox intermittently stalls
|
||||
// on Node's HTTP/2 server with a page that never finishes loading
|
||||
// (one run in five or so, locally). Every server in the suite
|
||||
// still speaks HTTP/1.1, so nothing is lost by insisting on it.
|
||||
"network.http.http2.enabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -115,14 +122,29 @@ export default defineConfig({
|
||||
],
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "./scripts/playwright-webserver-command.sh",
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
ignoreHTTPSErrors: true,
|
||||
gracefulShutdown: {
|
||||
signal: "SIGTERM",
|
||||
timeout: 500,
|
||||
webServer: [
|
||||
{
|
||||
command: "./scripts/playwright-webserver-command.sh",
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
ignoreHTTPSErrors: true,
|
||||
gracefulShutdown: {
|
||||
signal: "SIGTERM",
|
||||
timeout: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// The harness that embeds Element Call as a component. Always a Vite dev
|
||||
// server, whether or not the app itself is being served from Docker,
|
||||
// since there is nothing to build: it is a development page only.
|
||||
command: "pnpm dev:component",
|
||||
url: COMPONENT_HARNESS_URL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
ignoreHTTPSErrors: true,
|
||||
gracefulShutdown: {
|
||||
signal: "SIGTERM",
|
||||
timeout: 500,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/*
|
||||
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 Locator, type Page, test } from "@playwright/test";
|
||||
|
||||
import {
|
||||
createUserAndRoom,
|
||||
expectWithin,
|
||||
resizeContainer,
|
||||
startHarness,
|
||||
} from "./harness.ts";
|
||||
import { SpaHelpers } from "../spa-helpers.ts";
|
||||
|
||||
/**
|
||||
* Element Call embedded as a React component, driven through the development
|
||||
* harness in `component/dev`.
|
||||
*
|
||||
* What these cover that the widget tests cannot is everything that follows from
|
||||
* sharing a page with a host: whether Element Call stays inside the space it
|
||||
* was given, and whether two of it can exist at once. As a widget, the iframe
|
||||
* guaranteed both.
|
||||
*/
|
||||
|
||||
// Each test signs in twice, sets up crypto twice and syncs twice before
|
||||
// anything is on screen, and then waits for media to connect; the waits below
|
||||
// are sized for that, so the tests have to be too
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
/** The settings button, whichever of the two the footer is currently showing. */
|
||||
function settingsButton(pane: Locator): Locator {
|
||||
return pane
|
||||
.getByTestId("settings-bottom-left")
|
||||
.or(pane.getByTestId("settings-bottom-center"))
|
||||
.filter({ visible: true })
|
||||
.first();
|
||||
}
|
||||
|
||||
test("holds a call between two components on one page", async ({ page }) => {
|
||||
const { username, roomId } = await createUserAndRoom("twocomponents");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
|
||||
// Each component shows a lobby of its own, and neither has joined anything
|
||||
// just by being rendered
|
||||
for (const index of [0, 1])
|
||||
await expect(panes.nth(index).getByTestId("lobby_joinCall")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
for (const index of [0, 1])
|
||||
await panes.nth(index).getByTestId("lobby_joinCall").click();
|
||||
|
||||
// Two devices of one account, so each component should see itself and the
|
||||
// other. This is the part that proves two Element Calls in one page are two
|
||||
// calls, and not one shared thing wearing two hats.
|
||||
for (const index of [0, 1])
|
||||
await expect(panes.nth(index).getByTestId("videoTile")).toHaveCount(2, {
|
||||
timeout: 60_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps its modals inside the container it was given", async ({ page }) => {
|
||||
const { username, roomId } = await createUserAndRoom("containment");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
const pane = panes.first();
|
||||
const container = pane.getByTestId("call-container");
|
||||
|
||||
// In the flat container the harness gives it by default, Element Call hides
|
||||
// its controls a few seconds after the call starts, as it would in a flat
|
||||
// window. A full-size container keeps them on screen to be clicked.
|
||||
await resizeContainer(container, { width: 900, height: 640 });
|
||||
await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 });
|
||||
await expect(pane.getByTestId("footer-container")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// Both of these are positioned `fixed`, and were centred on the window
|
||||
// rather than the container until it was made a containing block. The
|
||||
// settings dialog spilled over the host's interface; the reaction picker sat
|
||||
// at 82vh, which put it below the container entirely and so out of sight.
|
||||
await settingsButton(pane).click();
|
||||
await expectWithin(pane.getByRole("dialog"), container);
|
||||
await pane.getByTestId("modal_close").click();
|
||||
|
||||
await pane.getByRole("button", { name: "Reactions" }).click();
|
||||
await expectWithin(
|
||||
pane.getByRole("dialog", { name: "Pick reaction" }),
|
||||
container,
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps the call inside the container, wherever the host put it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { username, roomId } = await createUserAndRoom("tileswithin");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
const pane = panes.first();
|
||||
const container = pane.getByTestId("call-container");
|
||||
|
||||
// Large enough for the layout switch to be offered
|
||||
await resizeContainer(container, { width: 900, height: 640 });
|
||||
await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 });
|
||||
await expect(pane.getByTestId("footer-container")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// The spotlight layout draws its tile in the fixed grid, which is positioned
|
||||
// against Element Call's root rather than laid out in flow. The harness puts
|
||||
// the container below a header of its own, so a grid offset measured from
|
||||
// the top of the page instead of from the root would land the tile on top of
|
||||
// the footer and out of the bottom of the container.
|
||||
await pane.getByRole("radio", { name: "Spotlight" }).check();
|
||||
const tile = pane.getByTestId("videoTile").first();
|
||||
await expect(tile).toBeVisible({ timeout: 60_000 });
|
||||
await expectWithin(tile, container);
|
||||
});
|
||||
|
||||
test("leaves the host's own page unstyled", async ({ page }) => {
|
||||
const { username, roomId } = await createUserAndRoom("hoststyles");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
await expect(panes.first().getByTestId("lobby_joinCall")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// Element Call's stylesheet is written for a page of its own: normalize.css
|
||||
// gives `html` a line height, Compound gives `body` its font and feature
|
||||
// settings, and the design tokens live on `:root`. None of that may reach the
|
||||
// host's document — the harness sets none of these itself, so anything other
|
||||
// than the browser's defaults here came from us.
|
||||
const host = await page.evaluate(() => {
|
||||
const html = getComputedStyle(document.documentElement);
|
||||
const body = getComputedStyle(document.body);
|
||||
return {
|
||||
lineHeight: html.lineHeight,
|
||||
fontFeatureSettings: body.fontFeatureSettings,
|
||||
token: html.getPropertyValue("--cpd-color-text-primary"),
|
||||
};
|
||||
});
|
||||
expect(host).toEqual({
|
||||
lineHeight: "normal",
|
||||
fontFeatureSettings: "normal",
|
||||
token: "",
|
||||
});
|
||||
|
||||
// While inside the container, the same rules do apply
|
||||
const root = panes.first().locator("[data-element-call-root]");
|
||||
await expect(root).toHaveCSS("font-feature-settings", /"kern"/);
|
||||
});
|
||||
|
||||
test("tells its host what it is doing", async ({ page }) => {
|
||||
const { username, roomId } = await createUserAndRoom("hostbridge");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
const pane = panes.first();
|
||||
const log = page.getByTestId("bridge-log");
|
||||
|
||||
// Every component reports to its host through the bridge, whether that host
|
||||
// is a widget container or an application embedding it directly
|
||||
await expect(log).toContainText("contentLoaded", { timeout: 60_000 });
|
||||
|
||||
await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 });
|
||||
await expect(log).toContainText("notifyJoined", { timeout: 60_000 });
|
||||
await expect(log).toContainText("setAlwaysOnScreen(true)", {
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
// And takes instructions back: the host asking for a mute should come back
|
||||
// as the component reporting the new state
|
||||
await pane.getByRole("button", { name: "Mute" }).click();
|
||||
await expect(log).toContainText("notifyDeviceMute(audio: false", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("lays itself out for the space it is given, not the page", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { username, roomId } = await createUserAndRoom("containersize");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
const pane = panes.first();
|
||||
const container = pane.getByTestId("call-container");
|
||||
const call = pane.locator("[data-layout]");
|
||||
|
||||
await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 });
|
||||
await expect(call).toBeVisible({ timeout: 60_000 });
|
||||
await expect(call).not.toHaveAttribute("data-layout", "pip");
|
||||
|
||||
// As a widget, Element Call's container and its window were one and the same:
|
||||
// a host wanting a picture-in-picture made the iframe small, and Element Call
|
||||
// saw the window shrink. A component gets no such signal from the window,
|
||||
// which stays as large as it ever was; only the container changes.
|
||||
await resizeContainer(container, { width: 300, height: 300 });
|
||||
await expect(call).toHaveAttribute("data-layout", "pip");
|
||||
|
||||
await resizeContainer(container, { width: 900, height: 700 });
|
||||
await expect(call).not.toHaveAttribute("data-layout", "pip");
|
||||
});
|
||||
|
||||
/**
|
||||
* The shape of a call at whatever size it has been given: the layout it chose,
|
||||
* how much of the height the tile and the footer take, and which controls the
|
||||
* footer shows. Two calls with the same shape look the same, participants aside.
|
||||
*/
|
||||
async function callShape(scope: Page | Locator): Promise<{
|
||||
layout: string | null;
|
||||
tileHeight: number;
|
||||
footerHeight: number;
|
||||
buttons: (string | null)[];
|
||||
}> {
|
||||
const call = scope.locator("[data-layout]");
|
||||
const footer = scope.getByTestId("footer-container");
|
||||
await expect(footer).toBeVisible();
|
||||
// The tile arrives with the media connection, which can take a while
|
||||
const tile = scope.getByTestId("videoTile").first();
|
||||
await expect(tile).toBeVisible({ timeout: 60_000 });
|
||||
const tileBox = (await tile.boundingBox())!;
|
||||
const footerBox = (await footer.boundingBox())!;
|
||||
// Buttons and switches alike: the mute controls are switches
|
||||
const buttons = await footer
|
||||
.locator("button")
|
||||
.filter({ visible: true })
|
||||
.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("aria-label")),
|
||||
);
|
||||
return {
|
||||
layout: await call.getAttribute("data-layout"),
|
||||
tileHeight: Math.round(tileBox.height),
|
||||
footerHeight: Math.round(footerBox.height),
|
||||
buttons,
|
||||
};
|
||||
}
|
||||
|
||||
test("looks the same in a small container as in a small window", async ({
|
||||
page,
|
||||
browser,
|
||||
}) => {
|
||||
// Two calls to set up, one of them through the harness's two logins
|
||||
test.setTimeout(300_000);
|
||||
const size = { width: 300, height: 300 };
|
||||
|
||||
// The reference is Element Call owning a window of that size, which is what
|
||||
// a mobile app's webview or a browser's picture-in-picture gives it, and
|
||||
// what its small-window styling was written for.
|
||||
// No permissions to grant: each browser is launched with fake media that is
|
||||
// handed out without asking (see playwright.config.ts), and Firefox rejects
|
||||
// a request for `camera` or `microphone` outright
|
||||
const referenceContext = await browser.newContext({
|
||||
viewport: size,
|
||||
ignoreHTTPSErrors: true,
|
||||
});
|
||||
const referencePage = await referenceContext.newPage();
|
||||
await referencePage.goto("/");
|
||||
await SpaHelpers.createCall(referencePage, "Reference", "smallwindow", true);
|
||||
const reference = await callShape(referencePage);
|
||||
await referencePage.screenshot({
|
||||
path: test.info().outputPath("small-window.png"),
|
||||
});
|
||||
|
||||
// The component gets a container of that size, in a window that is far larger
|
||||
const { username, roomId } = await createUserAndRoom("smallcontainer");
|
||||
const panes = await startHarness(page, username, roomId);
|
||||
const pane = panes.first();
|
||||
const container = pane.getByTestId("call-container");
|
||||
await resizeContainer(container, size);
|
||||
await pane.getByTestId("lobby_joinCall").click({ timeout: 60_000 });
|
||||
await expect(pane.locator("[data-layout]")).toBeVisible({ timeout: 60_000 });
|
||||
const component = await callShape(pane);
|
||||
await container.screenshot({
|
||||
path: test.info().outputPath("small-container.png"),
|
||||
});
|
||||
await referenceContext.close();
|
||||
|
||||
// The breakpoints in Element Call's stylesheets are container queries, so a
|
||||
// small container gets the compact footer a small window does, rather than
|
||||
// the full-width one the window's own size would call for
|
||||
expect(component).toEqual(reference);
|
||||
// And that footer is the compact one: a single row of controls, not the
|
||||
// full-height bar with its logo and layout switch that a large window gets
|
||||
expect(component.footerHeight).toBeLessThan(size.height / 3);
|
||||
expect(component.tileHeight + component.footerHeight).toBeLessThanOrEqual(
|
||||
size.height,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
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 Locator, type Page } from "@playwright/test";
|
||||
|
||||
import { SynapseAdmin } from "../utils/synapse-admin.ts";
|
||||
|
||||
/**
|
||||
* Where the component harness is served — `component/dev`, which embeds Element
|
||||
* Call the way a host application would. Not the `baseURL` the rest of the
|
||||
* suite uses: these tests drive a page that contains Element Call rather than
|
||||
* Element Call itself.
|
||||
*/
|
||||
export const COMPONENT_HARNESS_URL = "https://localhost:3001";
|
||||
|
||||
const HOMESERVER_URL = "https://synapse.m.localhost";
|
||||
const PASSWORD = "foobarbaz1!";
|
||||
|
||||
/**
|
||||
* Registers a user through the Synapse admin API and creates a room for it to
|
||||
* call in, without touching a browser. The harness signs into this account
|
||||
* twice, giving two devices in one page and so a real call between the two
|
||||
* components.
|
||||
*/
|
||||
export async function createUserAndRoom(
|
||||
name: string,
|
||||
): Promise<{ username: string; roomId: string }> {
|
||||
const username = `${name}_${Date.now()}`;
|
||||
const { access_token: accessToken } = await SynapseAdmin.forHomeserver(
|
||||
HOMESERVER_URL,
|
||||
).registerUser(username, PASSWORD, name);
|
||||
|
||||
const response = await fetch(
|
||||
`${HOMESERVER_URL}/_matrix/client/v3/createRoom`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ name: `${name}'s call`, preset: "private_chat" }),
|
||||
},
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
`Could not create a room: ${response.status} ${await response.text()}`,
|
||||
);
|
||||
const { room_id: roomId } = (await response.json()) as { room_id: string };
|
||||
|
||||
return { username, roomId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the harness signed in as the given user, and waits for both embedded
|
||||
* calls to appear.
|
||||
*
|
||||
* @returns The two containers the host gave Element Call, in order.
|
||||
*/
|
||||
export async function startHarness(
|
||||
page: Page,
|
||||
username: string,
|
||||
roomId: string,
|
||||
): Promise<Locator> {
|
||||
const query = new URLSearchParams({
|
||||
homeserver: HOMESERVER_URL,
|
||||
username,
|
||||
password: PASSWORD,
|
||||
room: roomId,
|
||||
});
|
||||
await page.goto(`${COMPONENT_HARNESS_URL}/?${query.toString()}`);
|
||||
await page.getByRole("button", { name: "Start" }).click();
|
||||
|
||||
const panes = page.getByTestId("call-pane");
|
||||
// Two logins, two crypto setups and two initial syncs happen first
|
||||
await expect(panes).toHaveCount(2, { timeout: 120_000 });
|
||||
return panes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts that one element is drawn entirely inside another.
|
||||
*
|
||||
* This is the check that being a component rather than an iframe costs us: an
|
||||
* iframe could not paint outside itself whatever its stylesheets said, whereas
|
||||
* a component shares the page and has to be made to stay put.
|
||||
*/
|
||||
export async function expectWithin(
|
||||
inner: Locator,
|
||||
outer: Locator,
|
||||
): Promise<void> {
|
||||
await expect(inner).toBeVisible();
|
||||
const innerBox = await inner.boundingBox();
|
||||
const outerBox = await outer.boundingBox();
|
||||
if (innerBox === null || outerBox === null)
|
||||
throw new Error("Expected both elements to be laid out");
|
||||
|
||||
// A pixel of slack, for subpixel layout
|
||||
const slack = 1;
|
||||
expect(innerBox.x).toBeGreaterThanOrEqual(outerBox.x - slack);
|
||||
expect(innerBox.y).toBeGreaterThanOrEqual(outerBox.y - slack);
|
||||
expect(innerBox.x + innerBox.width).toBeLessThanOrEqual(
|
||||
outerBox.x + outerBox.width + slack,
|
||||
);
|
||||
expect(innerBox.y + innerBox.height).toBeLessThanOrEqual(
|
||||
outerBox.y + outerBox.height + slack,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives one of the harness's containers a new size. Element Call lays itself
|
||||
* out for the size of its container, so this is how a test puts it into a
|
||||
* particular mode: a flat or narrow one, a picture-in-picture, or a full-size
|
||||
* window, without the window itself changing at all.
|
||||
*/
|
||||
export async function resizeContainer(
|
||||
container: Locator,
|
||||
size: { width: number; height: number },
|
||||
): Promise<void> {
|
||||
await container.evaluate((element, { width, height }) => {
|
||||
element.style.width = `${width}px`;
|
||||
element.style.height = `${height}px`;
|
||||
}, size);
|
||||
}
|
||||
@@ -109,10 +109,15 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
await TestHelpers.dismissInviteUnknownUserModal(ewPage1);
|
||||
|
||||
// Accept the invite
|
||||
await TestHelpers.closeReleaseAnnouncement(
|
||||
ewPage2,
|
||||
"Introducing Sections",
|
||||
);
|
||||
await TestHelpers.expandAllSections(ewPage2);
|
||||
await expect(
|
||||
ewPage2.getByRole("option", { name: "Welcome Room" }),
|
||||
TestHelpers.roomListItem(ewPage2, "Welcome Room"),
|
||||
).toBeVisible();
|
||||
await ewPage2.getByRole("option", { name: "Welcome Room" }).click();
|
||||
await TestHelpers.roomListItem(ewPage2, "Welcome Room").click();
|
||||
await ewPage2.getByRole("button", { name: "Accept" }).click();
|
||||
await expect(
|
||||
ewPage2
|
||||
@@ -152,8 +157,12 @@ export const widgetTest = test.extend<MyFixtures>({
|
||||
).toBeVisible();
|
||||
|
||||
// Accept the DM invite from brooks
|
||||
// This how playwright record selects the DM invite in the room list
|
||||
await ewPage2.getByRole("button", { name: "Open room" }).click();
|
||||
await TestHelpers.closeReleaseAnnouncement(
|
||||
ewPage2,
|
||||
"Introducing Sections",
|
||||
);
|
||||
await TestHelpers.expandAllSections(ewPage2);
|
||||
await TestHelpers.roomListItem(ewPage2, brooksDisplayName).click();
|
||||
await ewPage2.getByRole("button", { name: "Start chatting" }).click();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { SpaHelpers } from "./spa-helpers";
|
||||
|
||||
// Skip test for Firefox, due to page.keyboard.press("Tab") not reliable on headless mode
|
||||
test.skip(
|
||||
({ browserName }) => browserName === "firefox",
|
||||
@@ -17,6 +19,12 @@ test("can only interact with header and footer while reconnecting", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
// The reconnecting state is entered via the probablyLeft timer, which
|
||||
// mirrors the delayed leave event's timeout. With delegation that timeout
|
||||
// is one hour, putting it out of reach of the clock fast-forward below.
|
||||
// Keep the leave client-managed so its short timeout applies.
|
||||
await SpaHelpers.disableLeaveDelegation(page);
|
||||
await page.getByTestId("home_callName").click();
|
||||
await page.getByTestId("home_callName").fill("Test call");
|
||||
await page.getByTestId("home_displayName").click();
|
||||
|
||||
@@ -19,6 +19,7 @@ async function setupTwoUserSpaCall(
|
||||
browser: Browser,
|
||||
page: Page,
|
||||
browserName: string,
|
||||
opts: { disableGuestLeaveDelegation?: boolean } = {},
|
||||
): Promise<{ guestPage: Page }> {
|
||||
test.skip(
|
||||
browserName === "firefox",
|
||||
@@ -52,6 +53,9 @@ async function setupTwoUserSpaCall(
|
||||
|
||||
await guestPage.goto("/");
|
||||
|
||||
if (opts.disableGuestLeaveDelegation)
|
||||
await SpaHelpers.disableLeaveDelegation(guestPage);
|
||||
|
||||
let pevaraHasSentStickyEvent = false;
|
||||
|
||||
const pevaraResolver = Promise.withResolvers<void>();
|
||||
@@ -102,7 +106,13 @@ test("One to One rejoin after improper leave does not crash EC", async ({
|
||||
page,
|
||||
browserName,
|
||||
}) => {
|
||||
const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName);
|
||||
// With delegation, the backend sends the guest's delayed leave event within
|
||||
// moments of the improper leave, so the stale membership this test needs
|
||||
// would be cleaned up before the rejoin. Keep the guest's leave
|
||||
// client-managed so the stale membership lingers.
|
||||
const { guestPage } = await setupTwoUserSpaCall(browser, page, browserName, {
|
||||
disableGuestLeaveDelegation: true,
|
||||
});
|
||||
|
||||
await SpaHelpers.expectVideoTilesCount(page, 2);
|
||||
await SpaHelpers.expectVideoTilesCount(guestPage, 2);
|
||||
|
||||
@@ -111,6 +111,24 @@ async function setRtcModeFromSettings(
|
||||
await page.getByTestId("modal_close").click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the delayed-leave delegation support probes fail so that the client
|
||||
* manages its delayed leave event itself instead of delegating it to the
|
||||
* backend.
|
||||
*
|
||||
* Must be installed before the page joins a call.
|
||||
*/
|
||||
async function disableLeaveDelegation(page: Page): Promise<void> {
|
||||
// Covers both the transport probe (<livekit_service_url>/delegate_delayed_leave)
|
||||
// and the homeserver probe (MSC4195, .../rtc/livekit/delegate_delayed_leave).
|
||||
await page.route("**/delegate_delayed_leave", async (route) =>
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
headers: { "Access-Control-Allow-Origin": "*" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Expect a certain number of video tiles to be present and visible.
|
||||
*/
|
||||
@@ -133,5 +151,6 @@ export const SpaHelpers = {
|
||||
createCall,
|
||||
getCallInviteLink,
|
||||
joinCallFromInviteLink,
|
||||
disableLeaveDelegation,
|
||||
expectVideoTilesCount,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type JSHandle,
|
||||
type Page,
|
||||
type FrameLocator,
|
||||
type Locator,
|
||||
} from "@playwright/test";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
@@ -203,6 +204,16 @@ export class TestHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
public static async expandAllSections(page: Page): Promise<void> {
|
||||
try {
|
||||
await page
|
||||
.getByRole("button", { name: "Expand all sections" })
|
||||
.click({ timeout: 2000 });
|
||||
} catch {
|
||||
// Already expanded or button not present
|
||||
}
|
||||
}
|
||||
|
||||
public static async createRoom(
|
||||
name: string,
|
||||
page: Page,
|
||||
@@ -253,9 +264,9 @@ export class TestHelpers {
|
||||
roomName: string,
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
await page.getByRole("option", { name: roomName }).click({
|
||||
timeout: 10000,
|
||||
});
|
||||
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
|
||||
await TestHelpers.expandAllSections(page);
|
||||
await TestHelpers.roomListItem(page, roomName).click({ timeout: 10000 });
|
||||
await page.getByRole("button", { name: "Accept" }).click({
|
||||
timeout: 5000,
|
||||
});
|
||||
@@ -350,6 +361,17 @@ export class TestHelpers {
|
||||
.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates a room in the room list by its name.
|
||||
*
|
||||
* Matches on the aria-label prefix because the item's role differs between
|
||||
* the flat room list (`option`) and the sectioned room list (`button`), and
|
||||
* the label may carry a suffix such as " invitation.".
|
||||
*/
|
||||
public static roomListItem(page: Page, roomName: string): Locator {
|
||||
return page.locator(`[aria-label^="Open room ${roomName}"]`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches to a room in the room list by its name.
|
||||
* @param page - The EW page
|
||||
@@ -359,7 +381,8 @@ export class TestHelpers {
|
||||
page: Page,
|
||||
roomName: string,
|
||||
): Promise<void> {
|
||||
await page.getByRole("option", { name: `Open room ${roomName}` }).click();
|
||||
await TestHelpers.expandAllSections(page);
|
||||
await TestHelpers.roomListItem(page, roomName).click();
|
||||
}
|
||||
|
||||
public static async dismissInviteUnknownUserModal(page: Page): Promise<void> {
|
||||
|
||||
Generated
+46
-57
@@ -39,13 +39,13 @@ importers:
|
||||
version: 11.7.12
|
||||
'@livekit/components-core':
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
version: 0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
'@livekit/components-react':
|
||||
specifier: ^2.0.0
|
||||
version: 2.9.24(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)
|
||||
version: 2.9.24(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)
|
||||
'@livekit/track-processors':
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))
|
||||
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))
|
||||
'@mediapipe/tasks-vision':
|
||||
specifier: ^0.10.18
|
||||
version: 0.10.35
|
||||
@@ -183,7 +183,7 @@ importers:
|
||||
version: 5.88.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)(typescript@5.9.3)
|
||||
livekit-client:
|
||||
specifier: ^2.18.1
|
||||
version: 2.22.2(@types/dom-mediacapture-record@1.0.22)
|
||||
version: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||
lodash-es:
|
||||
specifier: ^4.17.21
|
||||
version: 4.18.1
|
||||
@@ -223,6 +223,9 @@ importers:
|
||||
postcss-preset-env:
|
||||
specifier: ^10.0.0
|
||||
version: 10.6.1(postcss@8.5.26)
|
||||
postcss-selector-parser:
|
||||
specifier: ^7.1.1
|
||||
version: 7.1.3
|
||||
posthog-js:
|
||||
specifier: 1.408.2
|
||||
version: 1.408.2
|
||||
@@ -4602,8 +4605,8 @@ packages:
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
livekit-client@2.22.2:
|
||||
resolution: {integrity: sha512-o3yLYoH2eHVR/DmIZzgC96FMvtR4+g/DjeqNwS5zp12EjqrqiRULxm6Oq/YsTE97WnA6fhy23+a2hGkbce5SPA==}
|
||||
livekit-client@2.22.3:
|
||||
resolution: {integrity: sha512-jw9zBKXY5Gtr5MZ7vEON3QhMNccuDvYHck1PFSyG1aaateQPqgKZFBMgZkFZaXHIf9RV4MDW5xpTK2b/+qbwOg==}
|
||||
peerDependencies:
|
||||
'@types/dom-mediacapture-record': ^1
|
||||
|
||||
@@ -5182,8 +5185,8 @@ packages:
|
||||
peerDependencies:
|
||||
postcss: ^8.4
|
||||
|
||||
postcss-selector-parser@7.1.1:
|
||||
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
|
||||
postcss-selector-parser@7.1.3:
|
||||
resolution: {integrity: sha512-ajnd7iZnqjJDkyHNfznl/ZVO0lWqvBmQXfKKENx9/p/bEiF/L3eHwdydNUg9RXZx6xfZWOCmXmBa5oeB+YrAPQ==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
postcss-value-parser@4.2.0:
|
||||
@@ -6137,18 +6140,6 @@ packages:
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.21.1:
|
||||
resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.21.3:
|
||||
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -6434,9 +6425,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.1)
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.1
|
||||
postcss-selector-parser: 7.1.3
|
||||
|
||||
'@csstools/postcss-color-function-display-p3-linear@1.0.1(postcss@8.5.26)':
|
||||
dependencies:
|
||||
@@ -6542,9 +6533,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.1)
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.1
|
||||
postcss-selector-parser: 7.1.3
|
||||
|
||||
'@csstools/postcss-light-dark-function@2.0.11(postcss@8.5.26)':
|
||||
dependencies:
|
||||
@@ -6646,7 +6637,7 @@ snapshots:
|
||||
'@csstools/postcss-scope-pseudo-class@4.0.1(postcss@8.5.26)':
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.1
|
||||
postcss-selector-parser: 7.1.3
|
||||
|
||||
'@csstools/postcss-sign-functions@1.1.4(postcss@8.5.26)':
|
||||
dependencies:
|
||||
@@ -6690,13 +6681,13 @@ snapshots:
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
|
||||
'@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.1)':
|
||||
'@csstools/selector-resolve-nested@3.1.0(postcss-selector-parser@7.1.3)':
|
||||
dependencies:
|
||||
postcss-selector-parser: 7.1.1
|
||||
postcss-selector-parser: 7.1.3
|
||||
|
||||
'@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.1)':
|
||||
'@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.3)':
|
||||
dependencies:
|
||||
postcss-selector-parser: 7.1.1
|
||||
postcss-selector-parser: 7.1.3
|
||||
|
||||
'@csstools/utilities@2.0.0(postcss@8.5.26)':
|
||||
dependencies:
|
||||
@@ -7064,7 +7055,7 @@ snapshots:
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/remapping@2.3.5':
|
||||
@@ -7086,23 +7077,23 @@ snapshots:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
'@livekit/components-core@0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
|
||||
'@livekit/components-core@0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.7.6
|
||||
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
|
||||
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||
loglevel: 1.9.1
|
||||
rxjs: 7.8.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@livekit/components-react@2.9.24(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)':
|
||||
'@livekit/components-react@2.9.24(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)':
|
||||
dependencies:
|
||||
'@livekit/components-core': 0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
'@livekit/components-core': 0.12.15(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
|
||||
clsx: 2.1.1
|
||||
events: 3.3.0
|
||||
jose: 6.2.9
|
||||
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
|
||||
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8(react@19.2.8)
|
||||
tslib: 2.8.1
|
||||
@@ -7114,11 +7105,11 @@ snapshots:
|
||||
dependencies:
|
||||
'@bufbuild/protobuf': 1.10.1
|
||||
|
||||
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))':
|
||||
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22))':
|
||||
dependencies:
|
||||
'@mediapipe/tasks-vision': 0.10.35
|
||||
'@types/dom-mediacapture-transform': 0.1.11
|
||||
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
|
||||
livekit-client: 2.22.3(@types/dom-mediacapture-record@1.0.22)
|
||||
|
||||
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0': {}
|
||||
|
||||
@@ -8762,7 +8753,7 @@ snapshots:
|
||||
sirv: 3.0.2
|
||||
tinyrainbow: 3.1.1
|
||||
vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-v8@4.1.11)(@vitest/ui@4.1.11)(jsdom@26.1.0(supports-color@7.2.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))
|
||||
ws: 8.21.1
|
||||
ws: 8.21.3
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- msw
|
||||
@@ -9253,13 +9244,13 @@ snapshots:
|
||||
css-blank-pseudo@7.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.1
|
||||
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.1)
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.3)
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.1
|
||||
postcss-selector-parser: 7.1.3
|
||||
postcss-value-parser: 4.2.0
|
||||
|
||||
css-prefers-color-scheme@10.0.0(postcss@8.5.26):
|
||||
@@ -10189,7 +10180,7 @@ snapshots:
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22):
|
||||
livekit-client@2.22.3(@types/dom-mediacapture-record@1.0.22):
|
||||
dependencies:
|
||||
'@livekit/mutex': 1.1.1
|
||||
'@livekit/protocol': 1.50.4
|
||||
@@ -10244,7 +10235,7 @@ snapshots:
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
magic-string@0.30.8:
|
||||
dependencies:
|
||||
@@ -10719,7 +10710,7 @@ snapshots:
|
||||
postcss-attribute-case-insensitive@7.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.1
|
||||
postcss-selector-parser: 7.1.3
|
||||
|
||||
postcss-clamp@4.1.0(postcss@8.5.26):
|
||||
dependencies:
|
||||
@@ -10770,12 +10761,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.1
|
||||
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.1
|
||||
postcss-selector-parser: 7.1.3
|
||||
|
||||
postcss-double-position-gradients@6.0.4(postcss@8.5.26):
|
||||
dependencies:
|
||||
@@ -10787,12 +10778,12 @@ snapshots:
|
||||
postcss-focus-visible@10.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.1
|
||||
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.1
|
||||
postcss-selector-parser: 7.1.3
|
||||
|
||||
postcss-font-variant@5.0.0(postcss@8.5.26):
|
||||
dependencies:
|
||||
@@ -10824,10 +10815,10 @@ snapshots:
|
||||
|
||||
postcss-nesting@13.0.2(postcss@8.5.26):
|
||||
dependencies:
|
||||
'@csstools/selector-resolve-nested': 3.1.0(postcss-selector-parser@7.1.1)
|
||||
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1)
|
||||
'@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.1
|
||||
postcss-selector-parser: 7.1.3
|
||||
|
||||
postcss-opacity-percentage@3.0.0(postcss@8.5.26):
|
||||
dependencies:
|
||||
@@ -10925,7 +10916,7 @@ snapshots:
|
||||
postcss-pseudo-class-any-link@10.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.1
|
||||
postcss-selector-parser: 7.1.3
|
||||
|
||||
postcss-replace-overflow-wrap@4.0.0(postcss@8.5.26):
|
||||
dependencies:
|
||||
@@ -10934,9 +10925,9 @@ snapshots:
|
||||
postcss-selector-not@8.0.1(postcss@8.5.26):
|
||||
dependencies:
|
||||
postcss: 8.5.26
|
||||
postcss-selector-parser: 7.1.1
|
||||
postcss-selector-parser: 7.1.3
|
||||
|
||||
postcss-selector-parser@7.1.1:
|
||||
postcss-selector-parser@7.1.3:
|
||||
dependencies:
|
||||
cssesc: 3.0.0
|
||||
util-deprecate: 1.0.2
|
||||
@@ -11876,8 +11867,6 @@ snapshots:
|
||||
|
||||
ws@8.21.0: {}
|
||||
|
||||
ws@8.21.1: {}
|
||||
|
||||
ws@8.21.3: {}
|
||||
|
||||
wsl-utils@0.1.0:
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
supportedArchitectures:
|
||||
os: [current, linux]
|
||||
cpu: [current, arm64]
|
||||
libc: [current, glibc]
|
||||
minimumReleaseAgeExclude:
|
||||
- "@vector-im/compound-design-tokens"
|
||||
- "@vector-im/compound-web"
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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 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
|
||||
* all. That is the failure this guards against.
|
||||
*
|
||||
* It reads the list from the build config itself, so there is one copy of it,
|
||||
* and compares it against every import of those packages in the source.
|
||||
*
|
||||
* The comparison is deliberately over-approximate: it looks at all of `src`
|
||||
* rather than only the modules the component actually pulls in, so it will
|
||||
* sometimes ask for a subpath that only the standalone app imports. Listing
|
||||
* one the component never imports costs nothing — the bundler ignores it —
|
||||
* whereas missing one costs a duplicate package.
|
||||
*/
|
||||
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { loadConfigFromFile } from "vite";
|
||||
|
||||
const CONFIG = "vite-component.config.ts";
|
||||
const SOURCES = ["src", "component"];
|
||||
|
||||
/** The packages whose duplication would break a host, rather than merely enlarge it. */
|
||||
const MUST_BE_EXTERNAL = [
|
||||
"react",
|
||||
"react-dom",
|
||||
"matrix-js-sdk",
|
||||
"livekit-client",
|
||||
];
|
||||
|
||||
const isTestFile = (name) =>
|
||||
name.includes(".test.") || name.includes(".stories.");
|
||||
|
||||
/** Every source file under the given directories, recursively. */
|
||||
async function* sourceFiles(dir) {
|
||||
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* sourceFiles(path);
|
||||
else if (/\.(ts|tsx)$/.test(entry.name) && !isTestFile(entry.name))
|
||||
yield path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The module specifiers a source file imports. Covers `from "…"` (which is
|
||||
* both static imports and re-exports), bare `import "…"` for side effects, and
|
||||
* dynamic `import("…")`.
|
||||
*/
|
||||
function imports(source) {
|
||||
const specifiers = [];
|
||||
for (const pattern of [
|
||||
/\bfrom\s*["']([^"']+)["']/g,
|
||||
/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g,
|
||||
/^\s*import\s+["']([^"']+)["']/gm,
|
||||
])
|
||||
for (const [, specifier] of source.matchAll(pattern))
|
||||
specifiers.push(specifier);
|
||||
return specifiers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a specifier is an import of one of the packages we care about.
|
||||
*
|
||||
* Imports carrying a resource query — `?worker`, `?inline` and friends — are
|
||||
* not, whatever package they name. Those ask the bundler for a script to run
|
||||
* in a context of its own, which has to be self-contained and shares no state
|
||||
* with the host's copy of anything. Worker sub-builds do not inherit this
|
||||
* option anyway.
|
||||
*/
|
||||
const mustBeExternal = (specifier) =>
|
||||
!specifier.includes("?") &&
|
||||
MUST_BE_EXTERNAL.some(
|
||||
(pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`),
|
||||
);
|
||||
|
||||
const loaded = await loadConfigFromFile(
|
||||
{ command: "build", mode: "production" },
|
||||
CONFIG,
|
||||
);
|
||||
if (loaded === null) {
|
||||
console.error(`Could not load ${CONFIG}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const declared = new Set(loaded.config.build?.rollupOptions?.external ?? []);
|
||||
if (declared.size === 0) {
|
||||
console.error(
|
||||
`${CONFIG} declares nothing external. Either the option moved, or the ` +
|
||||
`list is empty; either way this check is not looking at what it thinks.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Where each missing specifier is imported, so the message can point at it
|
||||
const missing = new Map();
|
||||
const seen = new Set();
|
||||
for (const dir of SOURCES)
|
||||
for await (const file of sourceFiles(dir)) {
|
||||
const source = await readFile(file, "utf8");
|
||||
for (const specifier of imports(source)) {
|
||||
if (!mustBeExternal(specifier)) continue;
|
||||
seen.add(specifier);
|
||||
if (declared.has(specifier)) continue;
|
||||
const files = missing.get(specifier) ?? [];
|
||||
files.push(file);
|
||||
missing.set(specifier, files);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.size > 0) {
|
||||
console.error(
|
||||
`${CONFIG} does not declare these imports external, so the component ` +
|
||||
`build would bundle its own copy of them:\n`,
|
||||
);
|
||||
for (const [specifier, files] of [...missing].sort())
|
||||
console.error(` ${specifier}\n imported by ${files.join(", ")}`);
|
||||
console.error(`\nAdd each one to the \`external\` list in ${CONFIG}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Deliberately no complaint about declarations nothing imports. Some of them
|
||||
// cannot be seen from the source at all — `react/jsx-runtime` is injected by
|
||||
// the JSX transform — and an extra declaration is inert, so there is nothing
|
||||
// to warn about.
|
||||
console.log(
|
||||
`${declared.size} external declarations cover all ${seen.size} imports of ${MUST_BE_EXTERNAL.join(", ")}.`,
|
||||
);
|
||||
+35
-23
@@ -46,7 +46,10 @@ import {
|
||||
// Can this be done in the tsconfig.json
|
||||
import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/room/types";
|
||||
import { type Behavior, constant } from "../src/state/Behavior";
|
||||
import { createCallViewModel$ } from "../src/state/CallViewModel/CallViewModel";
|
||||
import {
|
||||
callViewModelOptionsFromParams,
|
||||
createCallViewModel$,
|
||||
} from "../src/state/CallViewModel/CallViewModel";
|
||||
import { ObservableScope } from "../src/state/ObservableScope";
|
||||
import { getUrlParams } from "../src/UrlParams";
|
||||
import { MuteStates } from "../src/state/MuteStates";
|
||||
@@ -54,12 +57,10 @@ import { MediaDevices } from "../src/state/MediaDevices";
|
||||
import { E2eeType } from "../src/e2ee/e2eeType";
|
||||
import { currentAndPrev, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
|
||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
ElementWidgetActions,
|
||||
widget as _widget,
|
||||
initializeWidget,
|
||||
} from "../src/widget";
|
||||
import { initializeWidget } from "../src/widget";
|
||||
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
|
||||
import { createWidgetHostBridge } from "../src/HostBridge";
|
||||
import { observeElementSize$ } from "../src/utils/elementSize";
|
||||
|
||||
interface MatrixRTCSdk {
|
||||
/**
|
||||
@@ -109,14 +110,15 @@ export async function createMatrixRTCSdk(
|
||||
const scope = new ObservableScope();
|
||||
|
||||
// widget client
|
||||
initializeWidget(application, true);
|
||||
const widget = _widget;
|
||||
const widget = initializeWidget(application, true);
|
||||
if (!widget) throw Error("No widget. This webapp can only start as a widget");
|
||||
const client = await widget.client;
|
||||
const hostBridge = createWidgetHostBridge(widget);
|
||||
logger.info("client created");
|
||||
|
||||
// url params
|
||||
const { roomId } = getUrlParams();
|
||||
const urlParams = getUrlParams();
|
||||
const { roomId, controlledAudioDevices, callIntent } = urlParams;
|
||||
if (roomId === null) throw Error("could not get roomId from url params");
|
||||
const room = client.getRoom(roomId);
|
||||
if (room === null) throw Error("could not get room from client");
|
||||
@@ -128,11 +130,16 @@ export async function createMatrixRTCSdk(
|
||||
const rtcSession = rtcSessionManager.getRoomSession(room);
|
||||
|
||||
// media devices
|
||||
const mediaDevices = new MediaDevices(scope);
|
||||
const muteStates = new MuteStates(scope, mediaDevices, {
|
||||
audioEnabled: false,
|
||||
videoEnabled: false,
|
||||
const mediaDevices = new MediaDevices(scope, {
|
||||
controlledAudioDevices,
|
||||
callIntent,
|
||||
});
|
||||
const muteStates = new MuteStates(
|
||||
scope,
|
||||
mediaDevices,
|
||||
{ audioEnabled: false, videoEnabled: false },
|
||||
hostBridge,
|
||||
);
|
||||
|
||||
// call view model
|
||||
const callViewModel = createCallViewModel$(
|
||||
@@ -141,7 +148,13 @@ export async function createMatrixRTCSdk(
|
||||
room,
|
||||
mediaDevices,
|
||||
muteStates,
|
||||
{ encryptionSystem: { kind: E2eeType.PER_PARTICIPANT } },
|
||||
{
|
||||
...callViewModelOptionsFromParams(urlParams),
|
||||
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
|
||||
hostBridge,
|
||||
// The SDK owns its page, so the body is the space it has
|
||||
windowSize$: scope.behavior(observeElementSize$(document.body)),
|
||||
},
|
||||
of({}),
|
||||
of({}),
|
||||
constant({ supported: false, processor: undefined }),
|
||||
@@ -282,18 +295,17 @@ export async function createMatrixRTCSdk(
|
||||
});
|
||||
await leaveResolver.promise;
|
||||
logger.info("send Unstick");
|
||||
await widget.api
|
||||
await hostBridge
|
||||
.setAlwaysOnScreen(false)
|
||||
.catch((e) =>
|
||||
logger.error(
|
||||
"Failed to set call widget `alwaysOnScreen` to false",
|
||||
e,
|
||||
),
|
||||
.catch((e: unknown) =>
|
||||
logger.error("Failed to set `alwaysOnScreen` to false", e),
|
||||
);
|
||||
logger.info("send Close");
|
||||
await widget.api.transport
|
||||
.send(ElementWidgetActions.Close, {})
|
||||
.catch((e) => logger.error("Failed to send close action", e));
|
||||
await hostBridge
|
||||
.close?.()
|
||||
.catch((e: unknown) =>
|
||||
logger.error("Failed to ask the host to close", e),
|
||||
);
|
||||
};
|
||||
|
||||
// schedule close first and then leave (scope.end)
|
||||
|
||||
+120
-47
@@ -9,14 +9,22 @@ import {
|
||||
type FC,
|
||||
type JSX,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom";
|
||||
import {
|
||||
BrowserRouter,
|
||||
Route,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
Routes,
|
||||
} from "react-router-dom";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import { HomePage } from "./home/HomePage";
|
||||
import { LoginPage } from "./auth/LoginPage";
|
||||
@@ -25,13 +33,27 @@ import { RoomPage } from "./room/RoomPage";
|
||||
import { ClientProvider } from "./ClientContext";
|
||||
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
||||
import { Initializer } from "./initializer";
|
||||
import { widget } from "./widget";
|
||||
import { type WidgetHelpers } from "./widget";
|
||||
import { useTheme } from "./useTheme";
|
||||
import { ProcessorProvider } from "./livekit/TrackProcessorContext";
|
||||
import { type AppViewModel } from "./state/AppViewModel";
|
||||
import { MediaDevicesContext } from "./MediaDevicesContext";
|
||||
import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams";
|
||||
import {
|
||||
HeaderStyle,
|
||||
UrlParamsProvider,
|
||||
useUrlParams,
|
||||
useUrlParamsFromLocation,
|
||||
} from "./UrlParams";
|
||||
import { AppBar } from "./AppBar";
|
||||
import { i18n } from "./utils/i18n";
|
||||
import { useRootElement } from "./RootElementContext";
|
||||
import {
|
||||
createWidgetHostBridge,
|
||||
HostBridgeProvider,
|
||||
nullHostBridge,
|
||||
} from "./HostBridge";
|
||||
import { useInitial } from "./useInitial";
|
||||
import { LeaveToHomeProvider } from "./LeaveToHomeContext";
|
||||
|
||||
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
|
||||
|
||||
@@ -39,15 +61,38 @@ interface SimpleProviderProps {
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Supplies the URL-derived params to the rest of the app. Only the standalone
|
||||
* and widget builds own the URL, so this lives here in the app shell rather
|
||||
* than alongside the context itself.
|
||||
*/
|
||||
const LocationUrlParamsProvider: FC<SimpleProviderProps> = ({ children }) => {
|
||||
const urlParams = useUrlParamsFromLocation();
|
||||
return <UrlParamsProvider value={urlParams}>{children}</UrlParamsProvider>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Supplies the way home. Only the app has one — its home page, with the list
|
||||
* of recent calls — so this, too, lives in the app shell.
|
||||
*/
|
||||
const HomeProvider: FC<SimpleProviderProps> = ({ children }) => {
|
||||
const navigate = useNavigate();
|
||||
const leaveToHome = useCallback(() => {
|
||||
navigate("/")?.catch((e) => logger.error("Failed to navigate home", e));
|
||||
}, [navigate]);
|
||||
return (
|
||||
<LeaveToHomeProvider value={leaveToHome}>{children}</LeaveToHomeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const BackgroundProvider: FC<SimpleProviderProps> = ({ children }) => {
|
||||
const { pathname } = useLocation();
|
||||
const { background } = useUrlParams();
|
||||
const rootElement = useRootElement();
|
||||
|
||||
useEffect(() => {
|
||||
document
|
||||
.getElementsByTagName("body")[0]
|
||||
.setAttribute("data-background", background);
|
||||
}, [pathname, background]);
|
||||
rootElement.setAttribute("data-background", background);
|
||||
}, [pathname, background, rootElement]);
|
||||
|
||||
return children;
|
||||
};
|
||||
@@ -57,61 +102,89 @@ const ThemeProvider: FC<SimpleProviderProps> = ({ children }) => {
|
||||
return children;
|
||||
};
|
||||
|
||||
/** Wraps the app in an {@link AppBar}, if the params ask for one. */
|
||||
const MaybeAppBar: FC<SimpleProviderProps> = ({ children }) => {
|
||||
const { header } = useUrlParams();
|
||||
return header === HeaderStyle.AppBar ? <AppBar>{children}</AppBar> : children;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
vm: AppViewModel;
|
||||
/** A point of access to the widget API, if running as a widget. */
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
export const App: FC<Props> = ({ vm }) => {
|
||||
export const App: FC<Props> = ({ vm, widget }) => {
|
||||
// The standalone build has no host; the widget build's host is the client it
|
||||
// is a widget of.
|
||||
const hostBridge = useInitial(() =>
|
||||
widget === null ? nullHostBridge : createWidgetHostBridge(widget),
|
||||
);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
Initializer.init()
|
||||
?.then(async () => {
|
||||
if (loaded) return;
|
||||
setLoaded(true);
|
||||
await widget?.api.sendContentLoaded();
|
||||
await hostBridge.contentLoaded();
|
||||
})
|
||||
.catch(logger.error);
|
||||
});
|
||||
|
||||
// Since we are outside the router component, we cannot use useUrlParams here
|
||||
const { header } = useMemo(getUrlParams, []);
|
||||
|
||||
const content = loaded ? (
|
||||
<ClientProvider>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={(error) => <ErrorPage error={error} widget={widget} />}
|
||||
>
|
||||
<Routes>
|
||||
<SentryRoute path="/" element={<HomePage />} />
|
||||
<SentryRoute path="/login" element={<LoginPage />} />
|
||||
<SentryRoute path="/register" element={<RegisterPage />} />
|
||||
<SentryRoute path="*" element={<RoomPage />} />
|
||||
</Routes>
|
||||
</Sentry.ErrorBoundary>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
) : (
|
||||
<LoadingPage />
|
||||
// As a widget, the client comes from the host over the widget API. Standalone,
|
||||
// Element Call finds one itself, so there is nothing to wait for here.
|
||||
const [widgetClient, setWidgetClient] = useState<MatrixClient | undefined>(
|
||||
undefined,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (widget === null) return;
|
||||
widget.client
|
||||
.then(setWidgetClient)
|
||||
.catch((e) => logger.error("Failed to obtain the host's client", e));
|
||||
}, [widget]);
|
||||
const clientReady = widget === null || widgetClient !== undefined;
|
||||
|
||||
const content =
|
||||
loaded && clientReady ? (
|
||||
<ClientProvider client={widgetClient}>
|
||||
<MediaDevicesContext value={vm.mediaDevices}>
|
||||
<ProcessorProvider>
|
||||
<Sentry.ErrorBoundary
|
||||
fallback={(error) => <ErrorPage error={error} />}
|
||||
>
|
||||
<Routes>
|
||||
<SentryRoute path="/" element={<HomePage />} />
|
||||
<SentryRoute path="/login" element={<LoginPage />} />
|
||||
<SentryRoute path="/register" element={<RegisterPage />} />
|
||||
<SentryRoute path="*" element={<RoomPage />} />
|
||||
</Routes>
|
||||
</Sentry.ErrorBoundary>
|
||||
</ProcessorProvider>
|
||||
</MediaDevicesContext>
|
||||
</ClientProvider>
|
||||
) : (
|
||||
<LoadingPage />
|
||||
);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<BackgroundProvider>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>
|
||||
<Suspense fallback={null}>
|
||||
{header === HeaderStyle.AppBar ? (
|
||||
<AppBar>{content}</AppBar>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</BackgroundProvider>
|
||||
</BrowserRouter>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<HostBridgeProvider value={hostBridge}>
|
||||
<BrowserRouter>
|
||||
<LocationUrlParamsProvider>
|
||||
<HomeProvider>
|
||||
<BackgroundProvider>
|
||||
<ThemeProvider>
|
||||
<TooltipProvider>
|
||||
<Suspense fallback={null}>
|
||||
<MaybeAppBar>{content}</MaybeAppBar>
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</BackgroundProvider>
|
||||
</HomeProvider>
|
||||
</LocationUrlParamsProvider>
|
||||
</BrowserRouter>
|
||||
</HostBridgeProvider>
|
||||
</I18nextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
}
|
||||
|
||||
/* Hide everything but the subtitle in small windows */
|
||||
@media (max-height: 450px) {
|
||||
@container element-call (max-height: 450px) {
|
||||
.bar {
|
||||
display: none;
|
||||
}
|
||||
@@ -131,7 +131,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
body[data-platform="ios"] {
|
||||
[data-element-call-root][data-platform="ios"] {
|
||||
.bar > header {
|
||||
grid-template-rows: minmax(var(--cpd-space-11x), auto) var(--cpd-space-4x);
|
||||
grid-template-areas: "primaryButton title secondaryButton";
|
||||
@@ -166,7 +166,7 @@ body[data-platform="ios"] {
|
||||
}
|
||||
|
||||
/* Hide everything but the subtitle in small windows */
|
||||
@media (max-height: 450px) {
|
||||
@container element-call (max-height: 450px) {
|
||||
.bar:has(.subtitle) > header {
|
||||
grid-template-rows: var(--cpd-space-4x) minmax(var(--cpd-space-5x), auto);
|
||||
grid-template-areas: "." "subtitle";
|
||||
|
||||
+14
-49
@@ -9,18 +9,22 @@ import { afterEach, expect, test, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC, type PropsWithChildren } from "react";
|
||||
import { type WidgetApi } from "matrix-widget-api";
|
||||
|
||||
import { ClientContextProvider } from "./ClientContext";
|
||||
import { Avatar, getAvatarFromWidgetAPI } from "./Avatar";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
|
||||
import { widget } from "./widget";
|
||||
import {
|
||||
type HostBridge,
|
||||
HostBridgeProvider,
|
||||
nullHostBridge,
|
||||
} from "./HostBridge";
|
||||
|
||||
const TestComponent: FC<
|
||||
PropsWithChildren<{
|
||||
client: MatrixClient;
|
||||
hostBridge?: HostBridge;
|
||||
}>
|
||||
> = ({ client, children }) => {
|
||||
> = ({ client, hostBridge = nullHostBridge, children }) => {
|
||||
return (
|
||||
<ClientContextProvider
|
||||
value={{
|
||||
@@ -38,17 +42,11 @@ const TestComponent: FC<
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<HostBridgeProvider value={hostBridge}>{children}</HostBridgeProvider>
|
||||
</ClientContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
vi.mock("./widget", () => ({
|
||||
widget: {
|
||||
api: null, // Ideally we'd only mock this in the as a widget test so the whole module is otherwise null, but just nulling `api` by default works well enough
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -135,7 +133,7 @@ test("should attempt to fetch authenticated media from the server", async () =>
|
||||
});
|
||||
});
|
||||
|
||||
test("should attempt to use widget API if running as a widget", async () => {
|
||||
test("should download media through the host when it offers to", async () => {
|
||||
const expectedMXCUrl = "mxc://example.org/alice-avatar";
|
||||
const expectedObjectURL = "my-object-url";
|
||||
const theBlob = new Blob([]);
|
||||
@@ -151,8 +149,8 @@ test("should attempt to use widget API if running as a widget", async () => {
|
||||
getAccessToken: () => undefined,
|
||||
} as unknown as MatrixClient);
|
||||
|
||||
widget!.api = { downloadFile: vi.fn() } as unknown as WidgetApi;
|
||||
vi.spyOn(widget!.api, "downloadFile").mockResolvedValue({ file: theBlob });
|
||||
const downloadMedia = vi.fn().mockResolvedValue(theBlob);
|
||||
const hostBridge: HostBridge = { ...nullHostBridge, downloadMedia };
|
||||
const member = mockMatrixRoomMember(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{
|
||||
@@ -161,7 +159,7 @@ test("should attempt to use widget API if running as a widget", async () => {
|
||||
);
|
||||
const displayName = "Alice";
|
||||
render(
|
||||
<TestComponent client={client}>
|
||||
<TestComponent client={client} hostBridge={hostBridge}>
|
||||
<Avatar
|
||||
id={member.userId}
|
||||
name={displayName}
|
||||
@@ -176,38 +174,5 @@ test("should attempt to use widget API if running as a widget", async () => {
|
||||
document.querySelector(`img[src='${expectedObjectURL}']`),
|
||||
);
|
||||
|
||||
expect(widget!.api.downloadFile).toBeCalledWith(expectedMXCUrl);
|
||||
});
|
||||
|
||||
test("Supports download files as base64", async () => {
|
||||
const expectedMXCUrl = "mxc://example.org/alice-avatar";
|
||||
const expectedBase64 =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAADIElEQVR4nAAQA+/8ApxhEfFNuwna" +
|
||||
"+DO1pFMx5YDg6gb8p1WFkbFSox9H6r5c8jp1gxlHXrDfA/oQFi4A0gTXH9YBNgwRm12xO68QP6lv" +
|
||||
"ZLKH9qW1VM6kz6zA3T1Ui8J+Xbnh2BZ7oXDe/2gajzoA6j1JGotpz99xO+T2NR634Nhx3zhuera/" +
|
||||
"UdrpMLdEpwWXLnSqZRasGsrl93FjdTwRBMaqsx6vJksnPOmV9ttbXFIOb0XDGPbVythSC2n7P/bS" +
|
||||
"Zv0U0QqbBLk/5Wu1werYzAHiz11Bj8bEylQ92Pxvo+PwF6/KbGnIHTvGZkFzDkMnqz3g7Pw3NOSP" +
|
||||
"oV+qfyJuSI0AeZmrPejFQ8kzBSDWO8D7lr4+6ePRBRmZtKCf+fNjSCOyb5jqwhBnD2cycbJtQQbR" +
|
||||
"A4qdPG2ONfTPeQgi96+zT7grBI0JwvgFBceJdLJd4BX1VQIyY+j7OYueNWqEpf8iYgMj78I95eRt" +
|
||||
"nfPLwlxhVns84iL4Yvw8jDrB9vQi8ktpsdJOMiDwKrBGD3q56COD2oIA96CCBgiro4tkvkumZSAc" +
|
||||
"ZKXRLsziUFGytWJLaPjwnzXv2hicPy6k9AXsF3QkysOZAkB3m9XPpixhq9b0OKqV/zZx3L79o6wZ" +
|
||||
"Dr40J7sj7f+ARd545CP01r5omHt94tbnjgA46HsM2OhP+qQ882LN+Bhscq2WSHGSHT4J9MQcsWZP" +
|
||||
"2+N2LdPy61MN4/1++BJHmDcDLQBUEwLvjZp1fRfzxV7yirwIiOA7Vr8z+1yvS/pSkfUzkjswybOd" +
|
||||
"M5i0I8Q69MTXAKxqtR0/tyGkfCmHfupGASp/SAT9J8f3aQV+gDbpva592v4w8Cv5EMm7CzZPwThF" +
|
||||
"kgTChNPts7F03ccxpblfIz0EiAON1DKk71rX07BvDlLHY1ItPuqZ7hjy19jrAgl+QqEE1btHVA5R" +
|
||||
"uAnRXpEWc6rjARlJY5G1wbMk12rrqpr8rhR3YpFgLgOx4BtQ0D/hGe7KANSGBMQojmObId0asCmd" +
|
||||
"XzmnQI9P8QnwsO9vtqZlgIoU4g+f2/G8Q3/nVMX7dujniwEAAP//KmiQs7P8MeIAAAAASUVORK5C" +
|
||||
"YII=";
|
||||
const mockWidgetAPI = {
|
||||
downloadFile: vi.fn().mockImplementation(async (contentUri) => {
|
||||
if (contentUri !== expectedMXCUrl) {
|
||||
return Promise.reject(new Error("Unexpected content URI"));
|
||||
}
|
||||
return { file: expectedBase64 };
|
||||
}),
|
||||
} as unknown as WidgetApi;
|
||||
|
||||
const blob = await getAvatarFromWidgetAPI(mockWidgetAPI, expectedMXCUrl);
|
||||
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
expect(downloadMedia).toBeCalledWith(expectedMXCUrl);
|
||||
});
|
||||
|
||||
+7
-27
@@ -14,10 +14,9 @@ import {
|
||||
} from "react";
|
||||
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type WidgetApi } from "matrix-widget-api";
|
||||
|
||||
import { useClientState } from "./ClientContext";
|
||||
import { widget } from "./widget";
|
||||
import { useHostBridge } from "./HostBridge";
|
||||
|
||||
export enum Size {
|
||||
XS = "xs",
|
||||
@@ -76,6 +75,7 @@ export const Avatar: FC<Props> = ({
|
||||
...props
|
||||
}) => {
|
||||
const clientState = useClientState();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
const sizePx = useMemo(
|
||||
() =>
|
||||
@@ -87,7 +87,8 @@ export const Avatar: FC<Props> = ({
|
||||
|
||||
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
||||
|
||||
// In theory, a change in `clientState` or `sizePx` could run extra getAvatarFromWidgetAPI calls, but in practice they should be stable long before this code runs.
|
||||
// In theory, a change in `clientState` or `sizePx` could run extra media
|
||||
// downloads, but in practice they should be stable long before this code runs.
|
||||
useEffect(() => {
|
||||
if (!src) {
|
||||
setAvatarUrl(undefined);
|
||||
@@ -96,8 +97,8 @@ export const Avatar: FC<Props> = ({
|
||||
|
||||
let blob: Promise<Blob>;
|
||||
|
||||
if (widget?.api) {
|
||||
blob = getAvatarFromWidgetAPI(widget.api, src);
|
||||
if (hostBridge.downloadMedia) {
|
||||
blob = hostBridge.downloadMedia(src);
|
||||
} else if (
|
||||
clientState?.state === "valid" &&
|
||||
clientState.authenticated?.client &&
|
||||
@@ -132,7 +133,7 @@ export const Avatar: FC<Props> = ({
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
};
|
||||
}, [clientState, src, sizePx]);
|
||||
}, [clientState, hostBridge, src, sizePx]);
|
||||
|
||||
return (
|
||||
<CompoundAvatar
|
||||
@@ -172,24 +173,3 @@ async function getAvatarFromServer(
|
||||
|
||||
return blob;
|
||||
}
|
||||
|
||||
// export for testing
|
||||
export async function getAvatarFromWidgetAPI(
|
||||
api: WidgetApi,
|
||||
src: string,
|
||||
): Promise<Blob> {
|
||||
const response = await api.downloadFile(src);
|
||||
const file = response.file;
|
||||
|
||||
// element-web sends a Blob, and the MSC4039 is considering changing the spec to strictly Blob, so only handling that
|
||||
if (file instanceof Blob) {
|
||||
return file;
|
||||
} else if (typeof file === "string") {
|
||||
// it is a base64 string
|
||||
const bytes = Uint8Array.from(atob(file), (c) => c.charCodeAt(0));
|
||||
return new Blob([bytes]);
|
||||
}
|
||||
throw new Error(
|
||||
"Downloaded file format is not supported: " + typeof file + "",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
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, vi } from "vitest";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type FC } from "react";
|
||||
|
||||
import { ClientProvider, useClientState } from "./ClientContext";
|
||||
|
||||
const mockClient = (userId = "@alice:example.org"): MatrixClient =>
|
||||
({
|
||||
on: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
getUserId: () => userId,
|
||||
getDeviceId: () => "AAAA",
|
||||
stopClient: vi.fn(),
|
||||
}) as Partial<MatrixClient> as MatrixClient;
|
||||
|
||||
/** Reports what the context says, so a test can assert on it. */
|
||||
const ShowClientState: FC = () => {
|
||||
const state = useClientState();
|
||||
if (state === undefined) return <span>loading</span>;
|
||||
if (state.state === "error") return <span>error</span>;
|
||||
return (
|
||||
<span>{state.authenticated?.client.getUserId() ?? "unauthenticated"}</span>
|
||||
);
|
||||
};
|
||||
|
||||
test("uses a client supplied by the host without waiting", () => {
|
||||
const client = mockClient();
|
||||
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={client}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
// Available on the very first render: a supplied client needs no session
|
||||
// restoring, so there is no loading state to pass through.
|
||||
expect(container.textContent).toBe("@alice:example.org");
|
||||
});
|
||||
|
||||
test("does not claim exclusive use of storage when given a client", () => {
|
||||
// The channel is created when the module loads, so spy on the prototype
|
||||
// rather than trying to replace the global.
|
||||
const postMessage = vi.spyOn(BroadcastChannel.prototype, "postMessage");
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={mockClient()}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
// The broadcast shuts down other instances to protect Element Call's own
|
||||
// stores. A host's client brings its own, so there is nothing to protect.
|
||||
expect(postMessage).not.toHaveBeenCalled();
|
||||
|
||||
postMessage.mockRestore();
|
||||
});
|
||||
|
||||
test("follows the client when the host swaps it", () => {
|
||||
const first = mockClient();
|
||||
const second = mockClient("@bob:example.org");
|
||||
|
||||
const { container, rerender } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={first}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
expect(container.textContent).toBe("@alice:example.org");
|
||||
|
||||
// A host that re-authenticates hands us a new client on a mounted component
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<ClientProvider client={second}>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
expect(container.textContent).toBe("@bob:example.org");
|
||||
});
|
||||
|
||||
test("finds a client of its own when the host supplies none", async () => {
|
||||
const client = mockClient();
|
||||
vi.doMock("./utils/spa", () => ({
|
||||
initSPA: vi.fn().mockResolvedValue({ client, passwordlessUser: true }),
|
||||
}));
|
||||
|
||||
const { container } = render(
|
||||
<BrowserRouter>
|
||||
<ClientProvider>
|
||||
<ShowClientState />
|
||||
</ClientProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
// Nothing to show until a session has been restored or created
|
||||
expect(container.textContent).toBe("loading");
|
||||
await waitFor(() => expect(container.textContent).toBe("@alice:example.org"));
|
||||
|
||||
vi.doUnmock("./utils/spa");
|
||||
});
|
||||
+55
-53
@@ -16,14 +16,13 @@ import {
|
||||
useMemo,
|
||||
type JSX,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import type { WidgetApi } from "matrix-widget-api";
|
||||
import { ErrorPage } from "./FullScreenView";
|
||||
import { widget } from "./widget";
|
||||
import { useHostBridge } from "./HostBridge";
|
||||
import { useLeaveToHome } from "./LeaveToHomeContext";
|
||||
import {
|
||||
PosthogAnalytics,
|
||||
RegistrationType,
|
||||
@@ -134,18 +133,48 @@ const loadChannel =
|
||||
|
||||
interface Props {
|
||||
children: JSX.Element;
|
||||
/**
|
||||
* The client Element Call should use.
|
||||
*
|
||||
* An application hosting Element Call as a component already has a client,
|
||||
* and owns the user's session; supplying it here means Element Call neither
|
||||
* authenticates anyone nor manages their session. Left out, Element Call finds a client
|
||||
* itself — from the widget API, or by restoring or creating a session of its
|
||||
* own.
|
||||
*/
|
||||
client?: MatrixClient;
|
||||
}
|
||||
|
||||
export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
const navigate = useNavigate();
|
||||
export const ClientProvider: FC<Props> = ({ children, client }) => {
|
||||
const leaveToHome = useLeaveToHome();
|
||||
const hostBridge = useHostBridge();
|
||||
|
||||
// null = signed out, undefined = loading
|
||||
const [initClientState, setInitClientState] = useState<
|
||||
InitResult | null | undefined
|
||||
>(undefined);
|
||||
>(
|
||||
client === undefined
|
||||
? undefined
|
||||
: // A supplied client belongs to the host, so there is no session of ours
|
||||
// to restore and nothing to wait for.
|
||||
{ client, passwordlessUser: false },
|
||||
);
|
||||
|
||||
const initializing = useRef(false);
|
||||
useEffect(() => {
|
||||
if (client !== undefined) {
|
||||
// Nothing to load, but a host may hand us a different client later — on
|
||||
// re-authenticating, say — so follow whichever one it has given us.
|
||||
setInitClientState((current) =>
|
||||
current?.client === client
|
||||
? current
|
||||
: { client, passwordlessUser: false },
|
||||
);
|
||||
// Analytics still need to follow the user's choices.
|
||||
if (PosthogAnalytics.instance.isEnabled())
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
return;
|
||||
}
|
||||
// In case the component is mounted, unmounted, and remounted quickly (as
|
||||
// React does in strict mode), we need to make sure not to doubly initialize
|
||||
// the client.
|
||||
@@ -160,7 +189,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
})
|
||||
.catch((err) => logger.error(err))
|
||||
.finally(() => (initializing.current = false));
|
||||
}, []);
|
||||
}, [client]);
|
||||
|
||||
const changePassword = useCallback(
|
||||
async (password: string) => {
|
||||
@@ -201,7 +230,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
|
||||
saveSession(session);
|
||||
setInitClientState({
|
||||
widgetApi: null,
|
||||
client,
|
||||
passwordlessUser: session.passwordlessUser,
|
||||
});
|
||||
@@ -221,18 +249,20 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
await client.clearStores();
|
||||
clearSession();
|
||||
setInitClientState(null);
|
||||
await navigate("/");
|
||||
leaveToHome?.();
|
||||
PosthogAnalytics.instance.logout();
|
||||
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest);
|
||||
}, [navigate, initClientState?.client]);
|
||||
}, [leaveToHome, initClientState?.client]);
|
||||
|
||||
// To protect against multiple sessions writing to the same storage
|
||||
// simultaneously, we send a broadcast message that shuts down all other
|
||||
// running instances of the app. This isn't necessary if the app is running in
|
||||
// a widget though, since then it'll be mostly stateless.
|
||||
// running instances of the app. Element Call only has storage of its own to
|
||||
// protect when it created the session itself; given a client — by a host, or
|
||||
// over the widget API — it is mostly stateless.
|
||||
const ownsSession = client === undefined;
|
||||
useEffect(() => {
|
||||
if (!widget) loadChannel?.postMessage({});
|
||||
}, []);
|
||||
if (ownsSession) loadChannel?.postMessage({});
|
||||
}, [ownsSession]);
|
||||
|
||||
const [alreadyOpenedErr, setAlreadyOpenedErr] = useState<Error | undefined>(
|
||||
undefined,
|
||||
@@ -307,64 +337,36 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
initClientState.client.on(ClientEvent.Sync, onSync);
|
||||
}
|
||||
|
||||
if (initClientState.widgetApi) {
|
||||
const reactSend = initClientState.widgetApi.hasCapability(
|
||||
"org.matrix.msc2762.send.event:m.reaction",
|
||||
);
|
||||
const redactSend = initClientState.widgetApi.hasCapability(
|
||||
"org.matrix.msc2762.send.event:m.room.redaction",
|
||||
);
|
||||
const reactRcv = initClientState.widgetApi.hasCapability(
|
||||
"org.matrix.msc2762.receive.event:m.reaction",
|
||||
);
|
||||
const redactRcv = initClientState.widgetApi.hasCapability(
|
||||
"org.matrix.msc2762.receive.event:m.room.redaction",
|
||||
);
|
||||
|
||||
if (!reactSend || !reactRcv || !redactSend || !redactRcv) {
|
||||
logger.warn("Widget does not support reactions");
|
||||
setSupportsReactions(false);
|
||||
} else {
|
||||
setSupportsReactions(true);
|
||||
}
|
||||
} else {
|
||||
setSupportsReactions(true);
|
||||
}
|
||||
if (!hostBridge.supportsReactions)
|
||||
logger.warn("The host does not permit reactions");
|
||||
setSupportsReactions(hostBridge.supportsReactions);
|
||||
|
||||
return (): void => {
|
||||
if (initClientState.client) {
|
||||
initClientState.client.removeListener(ClientEvent.Sync, onSync);
|
||||
}
|
||||
};
|
||||
}, [initClientState, onSync]);
|
||||
}, [initClientState, onSync, hostBridge]);
|
||||
|
||||
if (alreadyOpenedErr) {
|
||||
return <ErrorPage widget={widget} error={alreadyOpenedErr} />;
|
||||
return <ErrorPage error={alreadyOpenedErr} />;
|
||||
}
|
||||
|
||||
return <ClientContext value={state}>{children}</ClientContext>;
|
||||
};
|
||||
|
||||
export type InitResult = {
|
||||
widgetApi: WidgetApi | null;
|
||||
client: MatrixClient;
|
||||
passwordlessUser: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Restores or creates a session of Element Call's own. Only reached when no
|
||||
* client was supplied for it to use.
|
||||
*/
|
||||
async function loadClient(): Promise<InitResult | null> {
|
||||
if (widget) {
|
||||
// We're inside a widget, so let's engage *matryoshka mode*
|
||||
logger.log("Using a matryoshka client");
|
||||
const client = await widget.client;
|
||||
return {
|
||||
widgetApi: widget.api,
|
||||
client,
|
||||
passwordlessUser: false,
|
||||
};
|
||||
} else {
|
||||
const { initSPA } = await import("./utils/spa");
|
||||
return initSPA(loadSession, clearSession);
|
||||
}
|
||||
const { initSPA } = await import("./utils/spa");
|
||||
return initSPA(loadSession, clearSession);
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
|
||||
+28
-35
@@ -20,8 +20,8 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { RageshakeButton } from "./settings/RageshakeButton";
|
||||
import styles from "./ErrorView.module.css";
|
||||
import { useUrlParams } from "./UrlParams";
|
||||
import { LinkButton } from "./button";
|
||||
import { ElementWidgetActions, type WidgetHelpers } from "./widget.ts";
|
||||
import { useLeaveToHome } from "./LeaveToHomeContext";
|
||||
import { useHostBridge } from "./HostBridge.ts";
|
||||
|
||||
interface Props {
|
||||
Icon: ComponentType<SVGAttributes<SVGElement>>;
|
||||
@@ -38,7 +38,6 @@ interface Props {
|
||||
*/
|
||||
fatal?: boolean;
|
||||
children: ReactNode;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
export const ErrorView: FC<Props> = ({
|
||||
@@ -47,53 +46,47 @@ export const ErrorView: FC<Props> = ({
|
||||
rageshake,
|
||||
fatal,
|
||||
children,
|
||||
widget,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { confineToRoom } = useUrlParams();
|
||||
const hostBridge = useHostBridge();
|
||||
const leaveToHome = useLeaveToHome();
|
||||
|
||||
const onReload = useCallback(() => {
|
||||
window.location.href = "/";
|
||||
}, []);
|
||||
|
||||
const CloseWidgetButton: FC<{ widget: WidgetHelpers }> = ({
|
||||
widget,
|
||||
const CloseButton: FC<{ close: () => Promise<void> }> = ({
|
||||
close,
|
||||
}): ReactElement => {
|
||||
// in widget mode we don't want to show the return home button but a close button
|
||||
const closeWidget = (): void => {
|
||||
widget.api.transport
|
||||
.send(ElementWidgetActions.Close, {})
|
||||
.catch((e) => {
|
||||
// What to do here?
|
||||
logger.error("Failed to send close action", e);
|
||||
})
|
||||
.finally(() => {
|
||||
widget.api.transport.stop();
|
||||
});
|
||||
// When the host can dismiss us, offer that instead of a link home
|
||||
const onClose = (): void => {
|
||||
close().catch((e) => {
|
||||
// What to do here?
|
||||
logger.error("Failed to ask the host to close Element Call", e);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Button kind="primary" onClick={closeWidget}>
|
||||
<Button kind="primary" onClick={onClose}>
|
||||
{t("action.close")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
// Whether the error is considered fatal or pathname is `/` then reload the all app.
|
||||
// If not then navigate to home page.
|
||||
const ReturnToHomeButton = (): ReactElement => {
|
||||
if (fatal || location.pathname === "/") {
|
||||
return (
|
||||
<Button kind="tertiary" className={styles.homeLink} onClick={onReload}>
|
||||
{t("return_home_button")}
|
||||
</Button>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<LinkButton kind="tertiary" className={styles.homeLink} to="/">
|
||||
{t("return_home_button")}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
// If not then navigate to home page. Neither applies when there is no home
|
||||
// to go to.
|
||||
const ReturnToHomeButton = (): ReactElement | null => {
|
||||
if (leaveToHome === null) return null;
|
||||
return (
|
||||
<Button
|
||||
kind="tertiary"
|
||||
className={styles.homeLink}
|
||||
onClick={fatal || location.pathname === "/" ? onReload : leaveToHome}
|
||||
>
|
||||
{t("return_home_button")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -108,8 +101,8 @@ export const ErrorView: FC<Props> = ({
|
||||
{rageshake && (
|
||||
<RageshakeButton description={`***Error View***: ${title}`} />
|
||||
)}
|
||||
{widget ? (
|
||||
<CloseWidgetButton widget={widget} />
|
||||
{hostBridge.close ? (
|
||||
<CloseButton close={hostBridge.close} />
|
||||
) : (
|
||||
!confineToRoom && <ReturnToHomeButton />
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,6 @@ import styles from "./FullScreenView.module.css";
|
||||
import { useUrlParams } from "./UrlParams";
|
||||
import { RichError } from "./RichError";
|
||||
import { ErrorView } from "./ErrorView";
|
||||
import { type WidgetHelpers } from "./widget.ts";
|
||||
|
||||
interface FullScreenViewProps {
|
||||
className?: string;
|
||||
@@ -48,12 +47,11 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
|
||||
|
||||
interface ErrorPageProps {
|
||||
error: unknown;
|
||||
widget: WidgetHelpers | null;
|
||||
}
|
||||
|
||||
// Due to this component being used as the crash fallback for Sentry, which has
|
||||
// weird type requirements, we can't just give this a type of FC<ErrorPageProps>
|
||||
export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => {
|
||||
export const ErrorPage = ({ error }: ErrorPageProps): ReactElement => {
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
logger.error(error);
|
||||
@@ -66,7 +64,6 @@ export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => {
|
||||
error.richMessage
|
||||
) : (
|
||||
<ErrorView
|
||||
widget={widget}
|
||||
Icon={ErrorSolidIcon}
|
||||
title={t("error.generic")}
|
||||
rageshake
|
||||
|
||||
@@ -30,6 +30,11 @@ Please see LICENSE in the repository root for full details.
|
||||
display: none;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
/* A button when it leads home, so undo the browser's button styling */
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.leftNav.hideMobile {
|
||||
@@ -107,7 +112,7 @@ Please see LICENSE in the repository root for full details.
|
||||
gap: var(--cpd-space-1-5x);
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
@container element-call (min-width: 800px) {
|
||||
.headerLogo,
|
||||
.leftNav.hideMobile,
|
||||
.rightNav.hideMobile {
|
||||
|
||||
+26
-7
@@ -6,8 +6,13 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import { type Ref, type FC, type HTMLAttributes, type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
type Ref,
|
||||
type FC,
|
||||
type HTMLAttributes,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Heading, Text } from "@vector-im/compound-web";
|
||||
import { UserProfileIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
@@ -16,7 +21,8 @@ import styles from "./Header.module.css";
|
||||
import Logo from "./icons/Logo.svg?react";
|
||||
import { Avatar, Size } from "./Avatar";
|
||||
import { EncryptionLock } from "./room/EncryptionLock";
|
||||
import { useMediaQuery } from "./useMediaQuery";
|
||||
import { useRootSizeMatches } from "./useRootSize";
|
||||
import { useLeaveToHome } from "./LeaveToHomeContext";
|
||||
import { DisconnectedBanner } from "./DisconnectedBanner";
|
||||
|
||||
interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
||||
@@ -112,17 +118,30 @@ interface HeaderLogoProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The logo, which is also the way home — when there is a home to go to. As a
|
||||
* component there is not, and it is just the logo.
|
||||
*/
|
||||
export const HeaderLogo: FC<HeaderLogoProps> = ({ className }) => {
|
||||
const { t } = useTranslation();
|
||||
const leaveToHome = useLeaveToHome();
|
||||
const onClick = useCallback(() => leaveToHome?.(), [leaveToHome]);
|
||||
|
||||
if (leaveToHome === null)
|
||||
return (
|
||||
<div className={classNames(styles.headerLogo, className)}>
|
||||
<Logo />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Link
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(styles.headerLogo, className)}
|
||||
to="/"
|
||||
onClick={onClick}
|
||||
aria-label={t("header_label")}
|
||||
>
|
||||
<Logo />
|
||||
</Link>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -142,7 +161,7 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
|
||||
participantCount,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const size = useMediaQuery("(max-width: 550px)") ? "sm" : "lg";
|
||||
const size = useRootSizeMatches(({ width }) => width <= 550) ? "sm" : "lg";
|
||||
|
||||
return (
|
||||
<div className={styles.roomHeaderInfo} data-size={size}>
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
/*
|
||||
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 { describe, expect, test, vi } from "vitest";
|
||||
import { type WidgetApi, WidgetApiToWidgetAction } from "matrix-widget-api";
|
||||
import EventEmitter from "events";
|
||||
|
||||
import { type Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
createWidgetHostBridge,
|
||||
type HostBridge,
|
||||
nullHostBridge,
|
||||
} from "./HostBridge";
|
||||
import { ElementWidgetActions, type WidgetHelpers } from "./widget";
|
||||
|
||||
function mockWidget(api: Partial<WidgetApi>): WidgetHelpers {
|
||||
return {
|
||||
api: api as WidgetApi,
|
||||
lazyActions: new EventEmitter(),
|
||||
client: Promise.resolve(),
|
||||
} as unknown as WidgetHelpers;
|
||||
}
|
||||
|
||||
/** A widget whose transport records what Element Call sends it. */
|
||||
function mockTransport(): {
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
reply: ReturnType<typeof vi.fn>;
|
||||
stop: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
return {
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
reply: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createWidgetHostBridge", () => {
|
||||
describe("telling the host what Element Call is doing", () => {
|
||||
test("asks to be kept on screen, and to stop being", async () => {
|
||||
const setAlwaysOnScreen = vi.fn().mockResolvedValue(true);
|
||||
const bridge = createWidgetHostBridge(mockWidget({ setAlwaysOnScreen }));
|
||||
|
||||
await bridge.setAlwaysOnScreen(true);
|
||||
await bridge.setAlwaysOnScreen(false);
|
||||
|
||||
expect(setAlwaysOnScreen).toHaveBeenNthCalledWith(1, true);
|
||||
expect(setAlwaysOnScreen).toHaveBeenNthCalledWith(2, false);
|
||||
});
|
||||
|
||||
test("reports that it has loaded", async () => {
|
||||
const sendContentLoaded = vi.fn().mockResolvedValue(undefined);
|
||||
const bridge = createWidgetHostBridge(mockWidget({ sendContentLoaded }));
|
||||
|
||||
await bridge.contentLoaded();
|
||||
|
||||
expect(sendContentLoaded).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test.each([
|
||||
["notifyJoined", ElementWidgetActions.JoinCall, {}],
|
||||
["notifyHungUp", ElementWidgetActions.HangupCall, {}],
|
||||
] as const)("sends %s as %s", async (method, action, payload) => {
|
||||
const transport = mockTransport();
|
||||
const bridge = createWidgetHostBridge(mockWidget({ transport } as never));
|
||||
|
||||
await bridge[method]();
|
||||
|
||||
expect(transport.send).toHaveBeenCalledWith(action, payload);
|
||||
});
|
||||
|
||||
test("sends the mute state the host needs to mirror", async () => {
|
||||
const transport = mockTransport();
|
||||
const bridge = createWidgetHostBridge(mockWidget({ transport } as never));
|
||||
|
||||
await bridge.notifyDeviceMute({
|
||||
audio_enabled: true,
|
||||
video_enabled: false,
|
||||
});
|
||||
|
||||
expect(transport.send).toHaveBeenCalledWith(
|
||||
ElementWidgetActions.DeviceMute,
|
||||
{ audio_enabled: true, video_enabled: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("relaying what the host asks for", () => {
|
||||
/** Emits a widget action the way widget.ts does, and returns the event. */
|
||||
function askHost(
|
||||
widget: WidgetHelpers,
|
||||
action: string,
|
||||
data: unknown,
|
||||
): CustomEvent {
|
||||
const ev = new CustomEvent(action, { detail: { action, data } });
|
||||
widget.lazyActions.emit(action, ev);
|
||||
return ev;
|
||||
}
|
||||
|
||||
// Selectors rather than keys, so each stream keeps its own request type
|
||||
const inboundStreams: [
|
||||
name: string,
|
||||
select: (bridge: HostBridge) => Observable<{ data: unknown }>,
|
||||
action: string,
|
||||
][] = [
|
||||
[
|
||||
"themeChange$",
|
||||
(bridge) => bridge.themeChange$,
|
||||
WidgetApiToWidgetAction.ThemeChange,
|
||||
],
|
||||
["join$", (bridge) => bridge.join$, ElementWidgetActions.JoinCall],
|
||||
["hangUp$", (bridge) => bridge.hangUp$, ElementWidgetActions.HangupCall],
|
||||
[
|
||||
"deviceMute$",
|
||||
(bridge) => bridge.deviceMute$,
|
||||
ElementWidgetActions.DeviceMute,
|
||||
],
|
||||
];
|
||||
|
||||
test.each(inboundStreams)(
|
||||
"surfaces %s with the host's data",
|
||||
(_name, select, action) => {
|
||||
const widget = mockWidget({ transport: mockTransport() } as never);
|
||||
const bridge = createWidgetHostBridge(widget);
|
||||
const seen: unknown[] = [];
|
||||
select(bridge).subscribe((request) => seen.push(request.data));
|
||||
|
||||
askHost(widget, action, { some: "payload" });
|
||||
|
||||
expect(seen).toEqual([{ some: "payload" }]);
|
||||
},
|
||||
);
|
||||
|
||||
test("replies to the host against the request it made", () => {
|
||||
const transport = mockTransport();
|
||||
const widget = mockWidget({ transport } as never);
|
||||
const bridge = createWidgetHostBridge(widget);
|
||||
bridge.deviceMute$.subscribe((request) =>
|
||||
request.reply({ audio_enabled: false, video_enabled: true }),
|
||||
);
|
||||
|
||||
const ev = askHost(widget, ElementWidgetActions.DeviceMute, {
|
||||
audio_enabled: false,
|
||||
});
|
||||
|
||||
expect(transport.reply).toHaveBeenCalledWith(ev.detail, {
|
||||
audio_enabled: false,
|
||||
video_enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("still replies when there is nothing to say", () => {
|
||||
const transport = mockTransport();
|
||||
const widget = mockWidget({ transport } as never);
|
||||
const bridge = createWidgetHostBridge(widget);
|
||||
bridge.hangUp$.subscribe((request) => request.reply());
|
||||
|
||||
const ev = askHost(widget, ElementWidgetActions.HangupCall, {});
|
||||
|
||||
// The widget API requires an answer, so an empty reply becomes {}
|
||||
expect(transport.reply).toHaveBeenCalledWith(ev.detail, {});
|
||||
});
|
||||
|
||||
test("stops listening once unsubscribed", () => {
|
||||
const widget = mockWidget({ transport: mockTransport() } as never);
|
||||
const bridge = createWidgetHostBridge(widget);
|
||||
const seen: unknown[] = [];
|
||||
const subscription = bridge.hangUp$.subscribe((r) => seen.push(r.data));
|
||||
|
||||
subscription.unsubscribe();
|
||||
askHost(widget, ElementWidgetActions.HangupCall, {});
|
||||
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadMedia", () => {
|
||||
const mxcUri = "mxc://example.org/alice-avatar";
|
||||
|
||||
test("passes a Blob through unchanged", async () => {
|
||||
const file = new Blob([]);
|
||||
const bridge = createWidgetHostBridge(
|
||||
mockWidget({ downloadFile: vi.fn().mockResolvedValue({ file }) }),
|
||||
);
|
||||
|
||||
await expect(bridge.downloadMedia!(mxcUri)).resolves.toBe(file);
|
||||
});
|
||||
|
||||
test("decodes a base64 string into a Blob", async () => {
|
||||
const bridge = createWidgetHostBridge(
|
||||
mockWidget({
|
||||
// "hello" in base64
|
||||
downloadFile: vi.fn().mockResolvedValue({ file: "aGVsbG8=" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const blob = await bridge.downloadMedia!(mxcUri);
|
||||
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
// The five decoded bytes, rather than the eight characters of base64 —
|
||||
// which is what we'd get if the string were stored verbatim.
|
||||
expect(blob.size).toBe(5);
|
||||
});
|
||||
|
||||
test("rejects a file format it does not understand", async () => {
|
||||
const bridge = createWidgetHostBridge(
|
||||
mockWidget({ downloadFile: vi.fn().mockResolvedValue({ file: 42 }) }),
|
||||
);
|
||||
|
||||
await expect(bridge.downloadMedia!(mxcUri)).rejects.toThrow(
|
||||
"Downloaded file format is not supported",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("close", () => {
|
||||
test("asks the host to close, then stops the transport", async () => {
|
||||
const transport = {
|
||||
send: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const bridge = createWidgetHostBridge(mockWidget({ transport } as never));
|
||||
|
||||
await bridge.close!();
|
||||
|
||||
expect(transport.send).toHaveBeenCalledWith(
|
||||
ElementWidgetActions.Close,
|
||||
{},
|
||||
);
|
||||
expect(transport.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("stops the transport even when the host refuses to close", async () => {
|
||||
const transport = {
|
||||
send: vi.fn().mockRejectedValue(new Error("no")),
|
||||
stop: vi.fn(),
|
||||
};
|
||||
const bridge = createWidgetHostBridge(mockWidget({ transport } as never));
|
||||
|
||||
// Leaving the messaging live would leave the close affordance dead
|
||||
await expect(bridge.close!()).rejects.toThrow("no");
|
||||
expect(transport.stop).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
test("does not offer profile changes, since the host signed the user in", () => {
|
||||
const bridge = createWidgetHostBridge(mockWidget({}));
|
||||
expect(bridge.supportsProfileChanges).toBe(false);
|
||||
});
|
||||
|
||||
test("allows joining unmuted on the intent, since the host asked for the call", () => {
|
||||
const bridge = createWidgetHostBridge(mockWidget({}));
|
||||
expect(bridge.allowJoinUnmutedViaIntent).toBe(true);
|
||||
});
|
||||
|
||||
describe("supportsReactions", () => {
|
||||
const capabilities = [
|
||||
"org.matrix.msc2762.send.event:m.reaction",
|
||||
"org.matrix.msc2762.send.event:m.room.redaction",
|
||||
"org.matrix.msc2762.receive.event:m.reaction",
|
||||
"org.matrix.msc2762.receive.event:m.room.redaction",
|
||||
];
|
||||
|
||||
test("is true when the host grants every reaction capability", () => {
|
||||
const bridge = createWidgetHostBridge(
|
||||
mockWidget({ hasCapability: () => true }),
|
||||
);
|
||||
|
||||
expect(bridge.supportsReactions).toBe(true);
|
||||
});
|
||||
|
||||
test.each(capabilities)("is false without %s", (missing) => {
|
||||
const bridge = createWidgetHostBridge(
|
||||
mockWidget({ hasCapability: (c) => c !== missing }),
|
||||
);
|
||||
|
||||
expect(bridge.supportsReactions).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("nullHostBridge", () => {
|
||||
test("offers no way to close, so the interface falls back to navigation", () => {
|
||||
expect(nullHostBridge.close).toBeUndefined();
|
||||
});
|
||||
|
||||
test("supports profile changes, since Element Call signed the user in itself", () => {
|
||||
expect(nullHostBridge.supportsProfileChanges).toBe(true);
|
||||
});
|
||||
|
||||
test("offers no media download, so Element Call uses its own client", () => {
|
||||
expect(nullHostBridge.downloadMedia).toBeUndefined();
|
||||
});
|
||||
|
||||
test("supports reactions, since nothing is mediating its homeserver access", () => {
|
||||
expect(nullHostBridge.supportsReactions).toBe(true);
|
||||
});
|
||||
|
||||
test("does not allow joining unmuted on the intent, since nobody vouched for it", () => {
|
||||
expect(nullHostBridge.allowJoinUnmutedViaIntent).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
/*
|
||||
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 { createContext, use } from "react";
|
||||
import { fromEvent, map, NEVER, type Observable } from "rxjs";
|
||||
import {
|
||||
type IWidgetApiRequest,
|
||||
type IWidgetApiRequestData,
|
||||
WidgetApiToWidgetAction,
|
||||
} from "matrix-widget-api";
|
||||
|
||||
import {
|
||||
ElementWidgetActions,
|
||||
type JoinCallData,
|
||||
type WidgetHelpers,
|
||||
} from "./widget";
|
||||
|
||||
// Note: these are type aliases rather than interfaces so that they satisfy the
|
||||
// widget API's index-signature payload types.
|
||||
|
||||
/** The mute state Element Call and its host exchange. */
|
||||
export type DeviceMuteState = {
|
||||
audio_enabled: boolean;
|
||||
video_enabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A mute state change requested by the host. An absent field means "leave this
|
||||
* one as it is".
|
||||
*/
|
||||
export type DeviceMuteRequest = {
|
||||
audio_enabled?: boolean;
|
||||
video_enabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Something the host has asked of Element Call, which it is expected to
|
||||
* acknowledge.
|
||||
*/
|
||||
export interface HostRequest<Data, Reply = void> {
|
||||
data: Data;
|
||||
/** Acknowledges the request. Should be called exactly once. */
|
||||
reply(reply: Reply): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Element Call's view of the application hosting it.
|
||||
*
|
||||
* Element Call can run as its own page, as a widget inside a Matrix client, or
|
||||
* as a component inside one. Only the last two give it a host, and each of
|
||||
* them reaches it by a different route — so everything Element Call needs from
|
||||
* whatever is hosting it goes through this interface, rather than being
|
||||
* expressed in terms of the widget API.
|
||||
*
|
||||
* This covers the interactions Element Call has with its host while running.
|
||||
* The Matrix client it talks to is supplied separately, at startup.
|
||||
*/
|
||||
export interface HostBridge {
|
||||
// What Element Call tells the host.
|
||||
|
||||
/**
|
||||
* Asks the host to keep Element Call on screen (or stop doing so), so that a
|
||||
* call in progress is not torn down when the user navigates elsewhere.
|
||||
*/
|
||||
setAlwaysOnScreen(alwaysOnScreen: boolean): Promise<void>;
|
||||
/** Tells the host that Element Call has finished loading. */
|
||||
contentLoaded(): Promise<void>;
|
||||
/** Tells the host that the user has joined the call. */
|
||||
notifyJoined(): Promise<void>;
|
||||
/** Tells the host that the user has hung up. */
|
||||
notifyHungUp(): Promise<void>;
|
||||
/** Tells the host the user's current audio and video mute state. */
|
||||
notifyDeviceMute(state: DeviceMuteState): Promise<void>;
|
||||
/**
|
||||
* Asks the host to close Element Call, and stops communicating with it. No
|
||||
* further calls should be made on this bridge afterwards.
|
||||
*
|
||||
* Absent when the host has no way to dismiss Element Call — standalone, the
|
||||
* user navigates away instead — so its presence is what tells the interface
|
||||
* whether to offer a close affordance.
|
||||
*/
|
||||
close?(): Promise<void>;
|
||||
|
||||
// What the host asks of Element Call.
|
||||
|
||||
/** The host has changed the theme Element Call should use. */
|
||||
themeChange$: Observable<HostRequest<{ name?: string }>>;
|
||||
/** The host wants a preloaded Element Call to join the call now. */
|
||||
join$: Observable<HostRequest<JoinCallData>>;
|
||||
/** The host wants Element Call to leave the call. */
|
||||
hangUp$: Observable<HostRequest<Record<string, never>>>;
|
||||
/** The host wants to change, or read back, the device mute state. */
|
||||
deviceMute$: Observable<HostRequest<DeviceMuteRequest, DeviceMuteState>>;
|
||||
|
||||
// What the host is, and is capable of.
|
||||
|
||||
/**
|
||||
* Whether Element Call may offer to change the user's profile — their
|
||||
* display name and avatar. Only when the account is Element Call's own,
|
||||
* which is to say standalone: a widget's host and an application hosting
|
||||
* the component both signed the user in themselves, so the profile is theirs
|
||||
* to manage and Element Call must not offer to edit it.
|
||||
*/
|
||||
readonly supportsProfileChanges: boolean;
|
||||
/** Whether the host permits Element Call to send and receive reactions. */
|
||||
readonly supportsReactions: boolean;
|
||||
/**
|
||||
* Whether the user may be put into a call unmuted on the strength of the
|
||||
* intent alone, when the lobby is skipped and so they get no chance to check
|
||||
* their devices first. A host that asked for the call on the user's behalf
|
||||
* has that much of their trust; standalone Element Call does not, and starts
|
||||
* them muted instead.
|
||||
*/
|
||||
readonly allowJoinUnmutedViaIntent: boolean;
|
||||
/**
|
||||
* Fetches media on Element Call's behalf, for hosts that do not give it
|
||||
* direct access to the homeserver. Absent when Element Call should fetch
|
||||
* media itself using its own client.
|
||||
*/
|
||||
downloadMedia?(mxcUri: string): Promise<Blob>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bridge to nowhere, for when Element Call has no host — that is, when it is
|
||||
* running as its own page and talks to the homeserver directly.
|
||||
*/
|
||||
export const nullHostBridge: HostBridge = {
|
||||
setAlwaysOnScreen: async () => {},
|
||||
contentLoaded: async () => {},
|
||||
notifyJoined: async () => {},
|
||||
notifyHungUp: async () => {},
|
||||
notifyDeviceMute: async () => {},
|
||||
themeChange$: NEVER,
|
||||
join$: NEVER,
|
||||
hangUp$: NEVER,
|
||||
deviceMute$: NEVER,
|
||||
// Standalone, the account is Element Call's own: it signed the user in, so
|
||||
// it may offer to change the profile.
|
||||
supportsProfileChanges: true,
|
||||
// Standalone Element Call reaches the homeserver itself, so nothing is
|
||||
// withholding these from it.
|
||||
supportsReactions: true,
|
||||
// Standalone, nobody vouched for the intent: it came from a URL, which is
|
||||
// not enough to switch the user's camera and microphone on unasked.
|
||||
allowJoinUnmutedViaIntent: false,
|
||||
};
|
||||
|
||||
/** Bridges to a host that Element Call is a widget of. */
|
||||
export function createWidgetHostBridge(widget: WidgetHelpers): HostBridge {
|
||||
const requests = <Data, Reply>(
|
||||
action: string,
|
||||
): Observable<HostRequest<Data, Reply>> =>
|
||||
(
|
||||
fromEvent(widget.lazyActions, action) as Observable<
|
||||
CustomEvent<IWidgetApiRequest>
|
||||
>
|
||||
).pipe(
|
||||
map((ev) => ({
|
||||
data: ev.detail.data as Data,
|
||||
// The widget API requires a reply for every request, and carries the
|
||||
// payload as a plain object, so an empty reply becomes {}.
|
||||
reply: (reply: Reply): void =>
|
||||
widget.api.transport.reply(ev.detail, reply ?? {}),
|
||||
})),
|
||||
);
|
||||
|
||||
const send = async (
|
||||
action: ElementWidgetActions,
|
||||
data: IWidgetApiRequestData = {},
|
||||
): Promise<void> => {
|
||||
await widget.api.transport.send(action, data);
|
||||
};
|
||||
|
||||
return {
|
||||
setAlwaysOnScreen: async (alwaysOnScreen) => {
|
||||
await widget.api.setAlwaysOnScreen(alwaysOnScreen);
|
||||
},
|
||||
contentLoaded: async () => widget.api.sendContentLoaded(),
|
||||
notifyJoined: async () => send(ElementWidgetActions.JoinCall),
|
||||
notifyHungUp: async () => send(ElementWidgetActions.HangupCall),
|
||||
notifyDeviceMute: async (state) =>
|
||||
send(ElementWidgetActions.DeviceMute, state),
|
||||
close: async () => {
|
||||
try {
|
||||
await send(ElementWidgetActions.Close);
|
||||
} finally {
|
||||
// Stop regardless of whether the host acknowledged the request. A host
|
||||
// that rejects or never answers would otherwise leave the messaging
|
||||
// live, and the close affordance doing nothing at all.
|
||||
widget.api.transport.stop();
|
||||
}
|
||||
},
|
||||
themeChange$: requests(WidgetApiToWidgetAction.ThemeChange),
|
||||
join$: requests(ElementWidgetActions.JoinCall),
|
||||
hangUp$: requests(ElementWidgetActions.HangupCall),
|
||||
deviceMute$: requests(ElementWidgetActions.DeviceMute),
|
||||
// The client we are a widget of signed the user in, so the profile is its
|
||||
// to manage
|
||||
supportsProfileChanges: false,
|
||||
// The client we are a widget of asked for this call on the user's behalf,
|
||||
// so its intent may be trusted to say whether they start unmuted
|
||||
allowJoinUnmutedViaIntent: true,
|
||||
// Element Call needs the host's permission to send reactions on its behalf.
|
||||
// Read on access rather than up front: the widget API negotiates its
|
||||
// capabilities asynchronously, and the bridge is built before that settles.
|
||||
get supportsReactions(): boolean {
|
||||
return (
|
||||
widget.api.hasCapability("org.matrix.msc2762.send.event:m.reaction") &&
|
||||
widget.api.hasCapability(
|
||||
"org.matrix.msc2762.send.event:m.room.redaction",
|
||||
) &&
|
||||
widget.api.hasCapability(
|
||||
"org.matrix.msc2762.receive.event:m.reaction",
|
||||
) &&
|
||||
widget.api.hasCapability(
|
||||
"org.matrix.msc2762.receive.event:m.room.redaction",
|
||||
)
|
||||
);
|
||||
},
|
||||
downloadMedia: async (mxcUri) => {
|
||||
const { file } = await widget.api.downloadFile(mxcUri);
|
||||
if (file instanceof Blob) return file;
|
||||
if (typeof file === "string")
|
||||
// it is a base64 string
|
||||
return new Blob([Uint8Array.from(atob(file), (c) => c.charCodeAt(0))]);
|
||||
throw new Error(
|
||||
`Downloaded file format is not supported: ${typeof file}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const HostBridgeContext = createContext<HostBridge | null>(null);
|
||||
|
||||
export const HostBridgeProvider = HostBridgeContext.Provider;
|
||||
|
||||
/**
|
||||
* The application hosting Element Call.
|
||||
*
|
||||
* Defaults to {@link nullHostBridge}, so that tests and stories, which have no
|
||||
* host, need no provider.
|
||||
*/
|
||||
export const useHostBridge = (): HostBridge =>
|
||||
use(HostBridgeContext) ?? nullHostBridge;
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
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 { createContext, use } from "react";
|
||||
|
||||
/**
|
||||
* How the user leaves the call for wherever they came from: the standalone
|
||||
* app's home page, with its list of recent calls.
|
||||
*
|
||||
* The call itself has no idea where that is, or whether there is such a place
|
||||
* at all. Standalone there is, and the shell navigates to it; as a component
|
||||
* there is not — the host decides what happens after a call — so nothing is
|
||||
* supplied, and the call offers no way out of its own. This is what lets the
|
||||
* call be rendered without a router.
|
||||
*/
|
||||
const LeaveToHomeContext = createContext<(() => void) | null>(null);
|
||||
|
||||
export const LeaveToHomeProvider = LeaveToHomeContext.Provider;
|
||||
|
||||
/**
|
||||
* The way out of the call, or null when there is nowhere to go and the call
|
||||
* should not offer one.
|
||||
*/
|
||||
export const useLeaveToHome = (): (() => void) | null =>
|
||||
use(LeaveToHomeContext);
|
||||
@@ -48,7 +48,7 @@ Please see LICENSE in the repository root for full details.
|
||||
--handle-inset-block-end: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
body[data-platform="ios"] .drawer {
|
||||
[data-element-call-root][data-platform="ios"] .drawer {
|
||||
--border-radius: 10px;
|
||||
--handle-block-size: 5px;
|
||||
--handle-inline-size: 36px;
|
||||
|
||||
+4
-2
@@ -24,6 +24,7 @@ import { Heading, Glass } from "@vector-im/compound-web";
|
||||
import styles from "./Modal.module.css";
|
||||
import overlayStyles from "./Overlay.module.css";
|
||||
import { useMediaQuery } from "./useMediaQuery";
|
||||
import { useRootElement } from "./RootElementContext";
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
@@ -78,6 +79,7 @@ export const Modal: FC<Props> = ({
|
||||
...rest
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const rootElement = useRootElement();
|
||||
// Empirically, Chrome on Android can end up not matching (hover: none), but
|
||||
// still matching (pointer: coarse) :/
|
||||
const touchscreen = useMediaQuery("(hover: none) or (pointer: coarse)");
|
||||
@@ -100,7 +102,7 @@ export const Modal: FC<Props> = ({
|
||||
onOpenChange={onOpenChange}
|
||||
dismissible={onDismiss !== undefined}
|
||||
>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Portal container={rootElement}>
|
||||
<Drawer.Overlay className={classNames(overlayStyles.bg)} />
|
||||
<Drawer.Content
|
||||
className={classNames(
|
||||
@@ -155,7 +157,7 @@ export const Modal: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPortal>
|
||||
<DialogPortal container={rootElement}>
|
||||
<DialogOverlay
|
||||
className={classNames(overlayStyles.bg, overlayStyles.animate)}
|
||||
/>
|
||||
|
||||
+2
-1
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import { type FC, useEffect, useState } from "react";
|
||||
import { toDataURL } from "qrcode";
|
||||
import classNames from "classnames";
|
||||
import { t } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import styles from "./QrCode.module.css";
|
||||
|
||||
@@ -18,6 +18,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const QrCode: FC<Props> = ({ data, className }) => {
|
||||
const { t } = useTranslation();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+1
-6
@@ -10,7 +10,6 @@ import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { ErrorView } from "./ErrorView";
|
||||
import { widget } from "./widget.ts";
|
||||
|
||||
/**
|
||||
* An error consisting of a terse message to be logged to the console and a
|
||||
@@ -32,11 +31,7 @@ const OpenElsewhere: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ErrorView
|
||||
widget={widget}
|
||||
Icon={PopOutIcon}
|
||||
title={t("error.open_elsewhere")}
|
||||
>
|
||||
<ErrorView Icon={PopOutIcon} title={t("error.open_elsewhere")}>
|
||||
<p>
|
||||
{t("error.open_elsewhere_description", {
|
||||
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
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 { createContext, use } from "react";
|
||||
|
||||
/**
|
||||
* The element that Element Call treats as the root of its own interface.
|
||||
*
|
||||
* Element Call decorates this element with the theme, layout and background
|
||||
* attributes its stylesheets key off, and portals its modals into it. When
|
||||
* Element Call owns the page this is simply the document body; as a component
|
||||
* it is the container the host mounted it into, so that Element Call does not
|
||||
* reach outside its own subtree.
|
||||
*
|
||||
* The stylesheets find this element by its `data-element-call-root` attribute,
|
||||
* which {@link useTheme} sets along with the platform and theme, so they no
|
||||
* longer depend on it being the body.
|
||||
*
|
||||
* What remains body-specific is the standalone page's own furniture: the
|
||||
* `body` rule in `index.css` still sets the page background and margin, and
|
||||
* `index.html` starts the body hidden with `no-theme` until the theme lands.
|
||||
* Neither applies when a host mounts Element Call into a container of its own.
|
||||
*/
|
||||
const RootElementContext = createContext<HTMLElement | null>(null);
|
||||
|
||||
/**
|
||||
* Supplies the element Element Call should confine itself to. The standalone
|
||||
* and widget builds need no provider, since for them that element is the body.
|
||||
*/
|
||||
export const RootElementProvider = RootElementContext.Provider;
|
||||
|
||||
/**
|
||||
* The element Element Call should decorate and portal into.
|
||||
*
|
||||
* Defaults to the document body, so that the standalone and widget builds work
|
||||
* without a provider.
|
||||
*/
|
||||
export const useRootElement = (): HTMLElement =>
|
||||
use(RootElementContext) ?? document.body;
|
||||
+7
-1
@@ -25,6 +25,7 @@ import { Text } from "@vector-im/compound-web";
|
||||
|
||||
import styles from "./Toast.module.css";
|
||||
import overlayStyles from "./Overlay.module.css";
|
||||
import { useRootElement } from "./RootElementContext";
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -64,6 +65,7 @@ export const Toast: FC<Props> = ({
|
||||
Icon,
|
||||
modal = true,
|
||||
}) => {
|
||||
const rootElement = useRootElement();
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) onDismiss();
|
||||
@@ -104,7 +106,11 @@ export const Toast: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<DialogRoot open={open} onOpenChange={onOpenChange} modal={modal}>
|
||||
{modal ? <DialogPortal>{content}</DialogPortal> : content}
|
||||
{modal ? (
|
||||
<DialogPortal container={rootElement}>{content}</DialogPortal>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</DialogRoot>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,10 +11,14 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import * as PlatformMod from "../src/Platform";
|
||||
import {
|
||||
BackgroundStyle,
|
||||
configurationForIntent,
|
||||
getRoomIdentifierFromUrl,
|
||||
computeUrlParams,
|
||||
HeaderStyle,
|
||||
getUrlParams,
|
||||
componentProperties,
|
||||
UserIntent,
|
||||
} from "../src/UrlParams";
|
||||
import { mockConfig } from "./utils/test";
|
||||
|
||||
@@ -335,6 +339,50 @@ describe("UrlParams", () => {
|
||||
callIntent: "audio",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts start_call_dm_voice", () => {
|
||||
expect(
|
||||
computeUrlParams(
|
||||
"?intent=start_call_dm_voice&widgetId=1234&parentUrl=parent.org",
|
||||
),
|
||||
).toMatchObject({
|
||||
...startNewCallDefaults("desktop"),
|
||||
// A DM rings the other side and waits for them, whichever platform
|
||||
sendNotificationType: "ring",
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: true,
|
||||
callIntent: "audio",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts join_existing_dm", () => {
|
||||
expect(
|
||||
computeUrlParams(
|
||||
"?intent=join_existing_dm&widgetId=1234&parentUrl=parent.org",
|
||||
),
|
||||
).toMatchObject({
|
||||
...joinExistingCallDefaults("desktop"),
|
||||
// Straight in: the other side is already waiting
|
||||
skipLobby: true,
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: false,
|
||||
callIntent: "video",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts join_existing_dm_voice", () => {
|
||||
expect(
|
||||
computeUrlParams(
|
||||
"?intent=join_existing_dm_voice&widgetId=1234&parentUrl=parent.org",
|
||||
),
|
||||
).toMatchObject({
|
||||
...joinExistingCallDefaults("desktop"),
|
||||
skipLobby: true,
|
||||
autoLeaveWhenOthersLeft: true,
|
||||
waitForCallPickup: false,
|
||||
callIntent: "audio",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("skipLobby", () => {
|
||||
@@ -424,4 +472,48 @@ describe("UrlParams", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// What Element Call runs with when a host embeds it as a component, which
|
||||
// has no URL of its own for any of this to come from
|
||||
describe("hosted defaults", () => {
|
||||
it("assume nothing about a session or a page", () => {
|
||||
expect(componentProperties).toMatchObject({
|
||||
// The host is not a widget host, and supplies the client itself, so
|
||||
// none of the widget or session plumbing applies
|
||||
isWidget: false,
|
||||
widgetId: null,
|
||||
parentUrl: null,
|
||||
userId: null,
|
||||
deviceId: null,
|
||||
baseUrl: null,
|
||||
homeserver: null,
|
||||
// The gradient is drawn by a `position: fixed` pseudo-element, which
|
||||
// would escape the container and cover the host's own interface
|
||||
background: BackgroundStyle.Solid,
|
||||
});
|
||||
});
|
||||
|
||||
it("keep a hosted call inside its room", () => {
|
||||
const hosted = configurationForIntent(UserIntent.JoinExistingCall);
|
||||
expect(hosted).toMatchObject({
|
||||
// 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,
|
||||
});
|
||||
// No Element Call branding inside someone else's application
|
||||
expect(hosted.header).not.toBe(HeaderStyle.Standard);
|
||||
});
|
||||
|
||||
it("fall back to the standalone app's when no intent is stated", () => {
|
||||
expect(configurationForIntent(UserIntent.Unknown)).toMatchObject({
|
||||
confineToRoom: false,
|
||||
header: HeaderStyle.Standard,
|
||||
perParticipantE2EE: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+173
-76
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { createContext, use, useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import {
|
||||
@@ -59,6 +59,17 @@ export interface UrlProperties {
|
||||
// Widget api related params
|
||||
widgetId: string | null;
|
||||
parentUrl: string | null;
|
||||
/**
|
||||
* Whether Element Call was started as a widget of a Matrix client, which is
|
||||
* to say whether it was given a widget ID and a parent to talk to.
|
||||
*
|
||||
* Only meaningful to the standalone and widget builds, which own the URL —
|
||||
* so use it for decisions that belong to the app shell, such as whether
|
||||
* Element Call is responsible for authenticating the user. Anything the call
|
||||
* interface itself needs to know about its host should come from the host
|
||||
* bridge instead.
|
||||
*/
|
||||
isWidget: boolean;
|
||||
/**
|
||||
* 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
|
||||
@@ -343,6 +354,136 @@ 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.
|
||||
*
|
||||
* It has no URL of its own to read these from, and it does not need most of
|
||||
* them: the widget plumbing does not apply, the Matrix client and the analytics
|
||||
* configuration come from the host by other routes, and what is left is either
|
||||
* the host's to state through the component's props or Element Call's own
|
||||
* default.
|
||||
*/
|
||||
export const componentProperties: UrlProperties = {
|
||||
widgetId: null,
|
||||
parentUrl: null,
|
||||
isWidget: false,
|
||||
roomId: null,
|
||||
userId: null,
|
||||
displayName: null,
|
||||
deviceId: null,
|
||||
baseUrl: null,
|
||||
lang: null,
|
||||
fonts: [],
|
||||
fontScale: null,
|
||||
posthogUserId: null,
|
||||
posthogApiHost: null,
|
||||
posthogApiKey: null,
|
||||
e2eEnabled: true,
|
||||
password: null,
|
||||
viaServers: null,
|
||||
homeserver: null,
|
||||
rageshakeSubmitUrl: null,
|
||||
sentryDsn: null,
|
||||
sentryEnvironment: null,
|
||||
theme: null,
|
||||
// Solid rather than the gradient the standalone app defaults to: the gradient
|
||||
// is drawn by a `position: fixed` pseudo-element, which would escape the
|
||||
// container Element Call was given and cover the host's own interface.
|
||||
background: BackgroundStyle.Solid,
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the app parameters for the current URL.
|
||||
* @param search The URL search string
|
||||
@@ -372,82 +513,12 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
const intent = !isWidget
|
||||
? UserIntent.Unknown
|
||||
: (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown);
|
||||
// Here we only use constants and `platform` to determine the intent preset.
|
||||
let intentPreset: 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:
|
||||
intentPreset.skipLobby = false;
|
||||
intentPreset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCall:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
intentPreset.skipLobby = false;
|
||||
intentPreset.callIntent = "video";
|
||||
break;
|
||||
case UserIntent.StartNewCallVoice:
|
||||
intentPreset.skipLobby = false;
|
||||
intentPreset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallVoice:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
intentPreset.skipLobby = false;
|
||||
intentPreset.callIntent = "audio";
|
||||
break;
|
||||
case UserIntent.StartNewCallDMVoice:
|
||||
intentPreset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.StartNewCallDM:
|
||||
intentPreset.skipLobby = true;
|
||||
intentPreset.sendNotificationType = "ring";
|
||||
intentPreset.autoLeaveWhenOthersLeft = true;
|
||||
intentPreset.waitForCallPickup = true;
|
||||
intentPreset.callIntent = intentPreset.callIntent ?? "video";
|
||||
break;
|
||||
case UserIntent.JoinExistingCallDMVoice:
|
||||
intentPreset.callIntent = "audio";
|
||||
// Fall through
|
||||
case UserIntent.JoinExistingCallDM:
|
||||
// On desktop this will be overridden based on which button was used to join the call
|
||||
intentPreset.skipLobby = true;
|
||||
intentPreset.autoLeaveWhenOthersLeft = true;
|
||||
intentPreset.callIntent = intentPreset.callIntent ?? "video";
|
||||
break;
|
||||
// Non widget usecase defaults
|
||||
default:
|
||||
intentPreset = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
const intentPreset = configurationForIntent(intent);
|
||||
|
||||
const properties: UrlProperties = {
|
||||
widgetId,
|
||||
parentUrl,
|
||||
isWidget,
|
||||
// NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl:
|
||||
// what would we do if it were invalid? If the widget API says that's what
|
||||
// the room ID is, then that's what it is.
|
||||
@@ -519,11 +590,37 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
||||
};
|
||||
};
|
||||
|
||||
const UrlParamsContext = createContext<UrlParams | null>(null);
|
||||
|
||||
/**
|
||||
* Hook to simplify use of getUrlParams.
|
||||
* @returns The app parameters for the current URL
|
||||
* Supplies the parameters Element Call should run with.
|
||||
*
|
||||
* The standalone and widget builds derive these from the URL, but the
|
||||
* component has no URL of its own to read them from, so its host provides them
|
||||
* directly instead.
|
||||
*
|
||||
* TODO: `UrlParams` is no longer an accurate name now that these need not come
|
||||
* from a URL. Renaming it touches every consumer, so it is left until the rest
|
||||
* of the de-globalisation work has settled.
|
||||
*/
|
||||
export const useUrlParams = (): UrlParams => {
|
||||
export const UrlParamsProvider = UrlParamsContext.Provider;
|
||||
|
||||
/**
|
||||
* The parameters Element Call is running with.
|
||||
*
|
||||
* Falls back to parsing `window.location` when no provider is present, so that
|
||||
* tests and stories keep working without one.
|
||||
*/
|
||||
export const useUrlParams = (): UrlParams =>
|
||||
use(UrlParamsContext) ?? getUrlParams();
|
||||
|
||||
/**
|
||||
* Derives {@link UrlParams} from the current router location.
|
||||
*
|
||||
* Only meaningful when Element Call owns the URL; the component is given its
|
||||
* params directly through {@link UrlParamsProvider}.
|
||||
*/
|
||||
export const useUrlParamsFromLocation = (): UrlParams => {
|
||||
const { search, hash } = useLocation();
|
||||
return useMemo(() => getUrlParams(search, hash), [search, hash]);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
afterAll,
|
||||
} from "vitest";
|
||||
import posthog, { type CaptureResult } from "posthog-js";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
|
||||
import {
|
||||
Anonymity,
|
||||
@@ -22,75 +23,122 @@ import {
|
||||
PosthogAnalytics,
|
||||
} from "./PosthogAnalytics";
|
||||
import { mockConfig } from "../utils/test";
|
||||
import { analyticsConfigFromEnvironment } from "../initializer";
|
||||
import { optInAnalytics } from "../settings/settings";
|
||||
|
||||
describe("PosthogAnalytics", () => {
|
||||
describe("embedded package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "embedded");
|
||||
});
|
||||
|
||||
describe("enablement", () => {
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
window.location.hash = "#";
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not create instance without config value or URL params", () => {
|
||||
it("stays off until it is configured", () => {
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores config value and does not create instance", () => {
|
||||
mockConfig({
|
||||
posthog: {
|
||||
api_host: "https://api.example.com.localhost",
|
||||
api_key: "api_key",
|
||||
},
|
||||
it("stays off when configured without credentials", () => {
|
||||
PosthogAnalytics.configure({ matrixBackend: "jssdk" });
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("stays off when given only a key", () => {
|
||||
PosthogAnalytics.configure({
|
||||
matrixBackend: "jssdk",
|
||||
apiKey: "api_key",
|
||||
});
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("uses URL params if both set", () => {
|
||||
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
|
||||
it("turns on when given both a key and a host", () => {
|
||||
PosthogAnalytics.configure({
|
||||
matrixBackend: "jssdk",
|
||||
apiKey: "api_key",
|
||||
apiHost: "https://api.example.com.localhost",
|
||||
});
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("full package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
});
|
||||
|
||||
// Which of the URL and config.json the credentials come from is a deliberate
|
||||
// policy: an embedder is responsible for its own users' telemetry, so it must
|
||||
// not pick up the deployment's, and vice versa.
|
||||
describe("analyticsConfigFromEnvironment", () => {
|
||||
beforeEach(() => {
|
||||
mockConfig({});
|
||||
window.location.hash = "#";
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does not create instance without config value", () => {
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
const urlCredentials = `posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=url_key`;
|
||||
const configCredentials = {
|
||||
posthog: {
|
||||
api_host: "https://config.example.com.localhost",
|
||||
api_key: "config_key",
|
||||
},
|
||||
};
|
||||
|
||||
it("ignores URL params and does not create instance", () => {
|
||||
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it("creates instance with config value", () => {
|
||||
mockConfig({
|
||||
posthog: {
|
||||
api_host: "https://api.example.com.localhost",
|
||||
api_key: "api_key",
|
||||
},
|
||||
describe("embedded package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "embedded");
|
||||
});
|
||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
|
||||
|
||||
it("has no credentials without URL params", () => {
|
||||
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it("takes the credentials from the URL", () => {
|
||||
window.location.hash = `#?${urlCredentials}`;
|
||||
expect(analyticsConfigFromEnvironment()).toMatchObject({
|
||||
apiKey: "url_key",
|
||||
apiHost: "https://url.example.com.localhost",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the deployment's config", () => {
|
||||
mockConfig(configCredentials);
|
||||
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("full package", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
});
|
||||
|
||||
it("has no credentials without config", () => {
|
||||
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it("takes the credentials from the config", () => {
|
||||
mockConfig(configCredentials);
|
||||
expect(analyticsConfigFromEnvironment()).toMatchObject({
|
||||
apiKey: "config_key",
|
||||
apiHost: "https://config.example.com.localhost",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the URL params", () => {
|
||||
window.location.hash = `#?${urlCredentials}`;
|
||||
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Who owns the user's analytics identity depends on how Element Call is
|
||||
// running, not on which package it was built as.
|
||||
it("reports the embedded backend when running as a widget", () => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
window.location.hash = `#?widgetId=id&parentUrl=${encodeURIComponent("https://host.example.com.localhost")}&posthogUserId=given_id`;
|
||||
expect(analyticsConfigFromEnvironment()).toMatchObject({
|
||||
matrixBackend: "embedded",
|
||||
hostAnalyticsId: "given_id",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports the jssdk backend when running standalone", () => {
|
||||
expect(analyticsConfigFromEnvironment().matrixBackend).toBe("jssdk");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,22 +252,13 @@ describe("PosthogAnalytics", () => {
|
||||
// posthog-js bumps renaming/removing the hook. The filter logic itself is
|
||||
// covered by the applyPrivacyFilters block above.
|
||||
describe("posthog.init wiring", () => {
|
||||
beforeAll(() => {
|
||||
vi.stubEnv("VITE_PACKAGE", "full");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockConfig({
|
||||
posthog: {
|
||||
api_host: "https://api.example.com.localhost",
|
||||
api_key: "api_key",
|
||||
},
|
||||
});
|
||||
PosthogAnalytics.resetInstance();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllEnvs();
|
||||
PosthogAnalytics.configure({
|
||||
matrixBackend: "jssdk",
|
||||
apiKey: "api_key",
|
||||
apiHost: "https://api.example.com.localhost",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes events through the privacy filter via before_send", () => {
|
||||
@@ -244,3 +283,86 @@ describe("PosthogAnalytics", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("identifying the user", () => {
|
||||
const credentials = {
|
||||
apiKey: "api_key",
|
||||
apiHost: "https://api.example.com.localhost",
|
||||
};
|
||||
|
||||
function mockClient(accountDataId: string | null): MatrixClient {
|
||||
return {
|
||||
isGuest: () => false,
|
||||
getCrypto: () => undefined,
|
||||
getAccountDataFromServer: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
accountDataId === null ? null : { id: accountDataId },
|
||||
),
|
||||
setAccountData: vi.fn().mockResolvedValue({}),
|
||||
} as Partial<MatrixClient> as MatrixClient;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
PosthogAnalytics.resetInstance();
|
||||
optInAnalytics.setValue(true);
|
||||
});
|
||||
|
||||
it("reports under the ID its host assigned, and stores nothing", async () => {
|
||||
const client = mockClient(null);
|
||||
window.matrixclient = client;
|
||||
PosthogAnalytics.configure({
|
||||
...credentials,
|
||||
matrixBackend: "embedded",
|
||||
hostAnalyticsId: "assigned-by-host",
|
||||
});
|
||||
const identify = vi.spyOn(posthog, "identify");
|
||||
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
await vi.waitFor(() =>
|
||||
expect(identify).toHaveBeenCalledWith("assigned-by-host"),
|
||||
);
|
||||
|
||||
// The host owns the user's account, so Element Call must not write to it
|
||||
expect(client.setAccountData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps its own ID in account data when it owns the session", async () => {
|
||||
const client = mockClient(null);
|
||||
window.matrixclient = client;
|
||||
PosthogAnalytics.configure({ ...credentials, matrixBackend: "jssdk" });
|
||||
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
|
||||
// No ID on the server yet, so one is minted and stored for other devices
|
||||
await vi.waitFor(() => expect(client.setAccountData).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("reuses the ID already in account data", async () => {
|
||||
const client = mockClient("stored-earlier");
|
||||
window.matrixclient = client;
|
||||
PosthogAnalytics.configure({ ...credentials, matrixBackend: "jssdk" });
|
||||
const identify = vi.spyOn(posthog, "identify");
|
||||
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
await vi.waitFor(() =>
|
||||
expect(identify).toHaveBeenCalledWith("stored-earlier"),
|
||||
);
|
||||
|
||||
expect(client.setAccountData).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records how it reaches Matrix as a super property", async () => {
|
||||
window.matrixclient = mockClient("stored-earlier");
|
||||
PosthogAnalytics.configure({ ...credentials, matrixBackend: "embedded" });
|
||||
const register = vi.spyOn(posthog, "register");
|
||||
|
||||
PosthogAnalytics.instance.startListeningToSettingsChanges();
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ matrixBackend: "embedded" }),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type Subscription } from "rxjs";
|
||||
|
||||
import { widget } from "../widget";
|
||||
import {
|
||||
CallEndedTracker,
|
||||
CallStartedTracker,
|
||||
@@ -29,8 +28,6 @@ import {
|
||||
CallConnectDurationTracker,
|
||||
CallReconnectingTracker,
|
||||
} from "./PosthogEvents";
|
||||
import { Config } from "../config/Config";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { optInAnalytics } from "../settings/settings";
|
||||
|
||||
/* Posthog analytics tracking.
|
||||
@@ -140,6 +137,27 @@ interface PlatformProperties {
|
||||
cryptoVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How analytics reporting should be set up, supplied by whoever is starting
|
||||
* Element Call rather than discovered from the page it happens to be on.
|
||||
*/
|
||||
export interface AnalyticsConfig {
|
||||
/** The PostHog project key. Without one, analytics stay switched off. */
|
||||
apiKey?: string;
|
||||
apiHost?: string;
|
||||
/**
|
||||
* How Element Call reaches Matrix. When `embedded`, the host owns the user's
|
||||
* identity: it supplies the analytics ID, and Element Call must not store one
|
||||
* in the user's account data.
|
||||
*/
|
||||
matrixBackend: "embedded" | "jssdk";
|
||||
/** The analytics ID the host has assigned to this user, when embedded. */
|
||||
hostAnalyticsId?: string | null;
|
||||
}
|
||||
|
||||
/** Analytics are off until someone asks for them. */
|
||||
const analyticsDisabled: AnalyticsConfig = { matrixBackend: "jssdk" };
|
||||
|
||||
export class PosthogAnalytics {
|
||||
/* Wrapper for Posthog analytics.
|
||||
* 3 modes of anonymity are supported, governed by this.anonymity
|
||||
@@ -167,13 +185,32 @@ export class PosthogAnalytics {
|
||||
private registrationType: RegistrationType = RegistrationType.Guest;
|
||||
private optInListener: Subscription | null = null;
|
||||
|
||||
private static analyticsConfig: AnalyticsConfig = analyticsDisabled;
|
||||
|
||||
/**
|
||||
* Sets up analytics reporting. Must be called before the instance is first
|
||||
* used; without it, analytics stay switched off.
|
||||
*/
|
||||
public static configure(config: AnalyticsConfig): void {
|
||||
if (this.internalInstance)
|
||||
// Configuration is read once, when the instance is built, so arriving
|
||||
// late means analytics are already running unconfigured.
|
||||
logger.warn(
|
||||
"Analytics were configured after they had already been started; the new configuration will not take effect",
|
||||
);
|
||||
this.analyticsConfig = config;
|
||||
}
|
||||
|
||||
public static hasInstance(): boolean {
|
||||
return Boolean(this.internalInstance);
|
||||
}
|
||||
|
||||
public static get instance(): PosthogAnalytics {
|
||||
if (!this.internalInstance) {
|
||||
this.internalInstance = new PosthogAnalytics(posthog);
|
||||
this.internalInstance = new PosthogAnalytics(
|
||||
posthog,
|
||||
PosthogAnalytics.analyticsConfig,
|
||||
);
|
||||
}
|
||||
return this.internalInstance;
|
||||
}
|
||||
@@ -181,20 +218,14 @@ export class PosthogAnalytics {
|
||||
public static resetInstance(): void {
|
||||
// Reset the singleton instance
|
||||
this.internalInstance = null;
|
||||
this.analyticsConfig = analyticsDisabled;
|
||||
}
|
||||
|
||||
private constructor(private readonly posthog: PostHog) {
|
||||
let apiKey: string | undefined;
|
||||
let apiHost: string | undefined;
|
||||
if (import.meta.env.VITE_PACKAGE === "embedded") {
|
||||
// for the embedded package we always use the values from the URL as the widget host is responsible for analytics configuration
|
||||
apiKey = getUrlParams().posthogApiKey ?? undefined;
|
||||
apiHost = getUrlParams().posthogApiHost ?? undefined;
|
||||
} else if (import.meta.env.VITE_PACKAGE === "full") {
|
||||
// in full package it is the server responsible for the analytics
|
||||
apiKey = Config.get().posthog?.api_key;
|
||||
apiHost = Config.get().posthog?.api_host;
|
||||
}
|
||||
private constructor(
|
||||
private readonly posthog: PostHog,
|
||||
private readonly config: AnalyticsConfig,
|
||||
) {
|
||||
const { apiKey, apiHost } = config;
|
||||
|
||||
if (apiKey && apiHost) {
|
||||
const beforeSend = (event: CaptureResult | null): CaptureResult | null =>
|
||||
@@ -225,15 +256,15 @@ export class PosthogAnalytics {
|
||||
}
|
||||
}
|
||||
|
||||
private static getPlatformProperties(): PlatformProperties {
|
||||
private getPlatformProperties(): PlatformProperties {
|
||||
const appVersion = import.meta.env.VITE_APP_VERSION || "dev";
|
||||
return {
|
||||
appVersion,
|
||||
matrixBackend: widget ? "embedded" : "jssdk",
|
||||
matrixBackend: this.config.matrixBackend,
|
||||
callBackend: "livekit",
|
||||
cryptoVersion: widget
|
||||
? undefined
|
||||
: window.matrixclient?.getCrypto()?.getVersion(),
|
||||
// Undefined when Element Call has no crypto of its own, which is the case
|
||||
// whenever a host is doing the encrypting for it.
|
||||
cryptoVersion: window.matrixclient?.getCrypto()?.getVersion(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -283,8 +314,8 @@ export class PosthogAnalytics {
|
||||
// different devices to send the same ID.
|
||||
let analyticsID = await this.getAnalyticsId();
|
||||
try {
|
||||
if (!analyticsID && !widget) {
|
||||
// only try setting up a new analytics ID in the standalone app.
|
||||
if (!analyticsID && this.config.matrixBackend !== "embedded") {
|
||||
// only mint an analytics ID when we are the ones storing it.
|
||||
|
||||
// Couldn't retrieve an analytics ID from user settings, so create one and set it on the server.
|
||||
// Note there's a race condition here - if two devices do these steps at the same time, last write
|
||||
@@ -313,8 +344,8 @@ export class PosthogAnalytics {
|
||||
|
||||
private async getAnalyticsId(): Promise<string | null> {
|
||||
const client: MatrixClient = window.matrixclient;
|
||||
if (widget) {
|
||||
return getUrlParams().posthogUserId;
|
||||
if (this.config.matrixBackend === "embedded") {
|
||||
return this.config.hostAnalyticsId ?? null;
|
||||
} else {
|
||||
const accountData = await client.getAccountDataFromServer(
|
||||
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
|
||||
@@ -324,7 +355,7 @@ export class PosthogAnalytics {
|
||||
}
|
||||
|
||||
private async setAccountAnalyticsId(analyticsID: string): Promise<void> {
|
||||
if (!widget) {
|
||||
if (this.config.matrixBackend !== "embedded") {
|
||||
const client = window.matrixclient;
|
||||
|
||||
// the analytics ID only needs to be set in the standalone version.
|
||||
@@ -362,7 +393,7 @@ export class PosthogAnalytics {
|
||||
// These properties will be subsequently passed in every event.
|
||||
//
|
||||
// This only needs to be done once per page lifetime. Note that getPlatformProperties
|
||||
this.platformSuperProperties = PosthogAnalytics.getPlatformProperties();
|
||||
this.platformSuperProperties = this.getPlatformProperties();
|
||||
this.registerSuperProperties({
|
||||
...this.platformSuperProperties,
|
||||
registrationType:
|
||||
|
||||
@@ -17,7 +17,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
||||
import { initClient } from "../utils/matrix";
|
||||
import { type Session } from "../ClientContext";
|
||||
import { Config } from "../config/Config";
|
||||
import { widget } from "../widget";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
export const useInteractiveRegistration = (
|
||||
oldClient?: MatrixClient,
|
||||
@@ -32,6 +32,7 @@ export const useInteractiveRegistration = (
|
||||
passwordlessUser: boolean,
|
||||
) => Promise<[MatrixClient, Session]>;
|
||||
} => {
|
||||
const { isWidget } = useUrlParams();
|
||||
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
|
||||
undefined,
|
||||
);
|
||||
@@ -47,7 +48,7 @@ export const useInteractiveRegistration = (
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (widget) return;
|
||||
if (isWidget) return;
|
||||
// An empty registerRequest is used to get the privacy policy and recaptcha key.
|
||||
authClient.current!.registerRequest({}).catch((error) => {
|
||||
setPrivacyPolicyUrl(
|
||||
@@ -55,7 +56,7 @@ export const useInteractiveRegistration = (
|
||||
);
|
||||
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
|
||||
});
|
||||
}, []);
|
||||
}, [isWidget]);
|
||||
|
||||
const register = useCallback(
|
||||
async (
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useClient } from "../ClientContext";
|
||||
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
||||
import { generateRandomName } from "../auth/generateRandomName";
|
||||
import { useRecaptcha } from "../auth/useRecaptcha";
|
||||
import { widget } from "../widget";
|
||||
import { useUrlParams } from "../UrlParams";
|
||||
|
||||
interface UseRegisterPasswordlessUserType {
|
||||
privacyPolicyUrl?: string;
|
||||
@@ -22,6 +22,7 @@ interface UseRegisterPasswordlessUserType {
|
||||
|
||||
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||
const { setClient } = useClient();
|
||||
const { isWidget } = useUrlParams();
|
||||
const { privacyPolicyUrl, recaptchaKey, register } =
|
||||
useInteractiveRegistration();
|
||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||
@@ -31,7 +32,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||
if (!setClient) {
|
||||
throw new Error("No client context");
|
||||
}
|
||||
if (widget) {
|
||||
if (isWidget) {
|
||||
throw new Error(
|
||||
"Registration was skipped: We should never try to register password-less user in embedded mode.",
|
||||
);
|
||||
@@ -53,7 +54,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[execute, reset, register, setClient],
|
||||
[execute, reset, register, setClient, isWidget],
|
||||
);
|
||||
|
||||
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
Copyright 2021-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.
|
||||
*/
|
||||
|
||||
/* 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. 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
|
||||
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;
|
||||
--font-size-micro: calc(10px * var(--font-scale));
|
||||
--font-size-caption: calc(12px * var(--font-scale));
|
||||
--font-size-body: calc(15px * var(--font-scale));
|
||||
--font-size-subtitle: calc(18px * var(--font-scale));
|
||||
--font-size-title: calc(24px * var(--font-scale));
|
||||
--font-size-headline: calc(32px * var(--font-scale));
|
||||
|
||||
--cpd-color-border-accent: var(--cpd-color-green-800);
|
||||
/* The distance to inset non-full-width content from the edge of Element
|
||||
Call's root along the inline axis. This ramps up from 16px for typical mobile
|
||||
windows, to 96px for typical desktop windows, and accounts for the safe area.
|
||||
Container units resolve where the property is used, so this must only be used
|
||||
by elements whose nearest query container is the root. */
|
||||
--content-inset-left: calc(
|
||||
env(safe-area-inset-left) +
|
||||
min(
|
||||
var(--cpd-space-24x),
|
||||
max(var(--cpd-space-4x), calc((100cqw - 900px) / 3))
|
||||
)
|
||||
);
|
||||
--content-inset-right: calc(
|
||||
env(safe-area-inset-right) +
|
||||
min(
|
||||
var(--cpd-space-24x),
|
||||
max(var(--cpd-space-4x), calc((100cqw - 900px) / 3))
|
||||
)
|
||||
);
|
||||
--small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15);
|
||||
--big-drop-shadow: 0px 0px 24px 0px #1b1d221a;
|
||||
--subtle-drop-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);
|
||||
|
||||
--call-view-overlay-layer: 1;
|
||||
--call-view-header-footer-layer: 2;
|
||||
}
|
||||
|
||||
:root,
|
||||
[class*="cpd-theme-"] {
|
||||
--video-tile-background: var(--cpd-color-bg-subtle-secondary);
|
||||
}
|
||||
|
||||
/* The breakpoints in Element Call's stylesheets are container queries against
|
||||
this element rather than media queries against the viewport. For the standalone
|
||||
app the two are the same thing, since the root is the page; for a host that
|
||||
embeds Element Call in a corner of its own page they are not, and it is the
|
||||
corner that the layout has to fit.
|
||||
|
||||
For the same reason, lengths that were once a share of the viewport (`100vw`)
|
||||
are a share of the nearest query container (`100cqw`). Container units cannot
|
||||
name their container, so they only mean this element where no other query
|
||||
container — a spotlight layout, a media tile — lies in between; check that
|
||||
before using one further down the tree. */
|
||||
[data-element-call-root] {
|
||||
container: element-call / size;
|
||||
}
|
||||
|
||||
.cpd-theme-dark {
|
||||
--cpd-color-border-accent: var(--cpd-color-green-1100);
|
||||
--stopgap-color-on-solid-accent: var(--cpd-color-text-primary);
|
||||
--stopgap-background-85: rgba(16, 19, 23, 0.85);
|
||||
}
|
||||
|
||||
@media (min-height: 330px) {
|
||||
[data-element-call-root][data-background="gradient"]::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
/* Chromium abruptly fades our images to fully transparent at the edge of
|
||||
the element. If we just make the element a little bigger than the viewport,
|
||||
this is no longer visible. */
|
||||
inset: -20px;
|
||||
background-image: url("graphics/mobile-gradient.png");
|
||||
background-size: 1400px 305px;
|
||||
background-position: bottom;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
[data-element-call-root][data-background="gradient"][data-platform="desktop"]::before {
|
||||
background-image: url("graphics/desktop-gradient.png");
|
||||
background-size: max(1440px, 100cqw) max(1440px, 100cqh);
|
||||
background-position: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* We use this to not render the page at all until we know the theme.*/
|
||||
.no-theme {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* On Android and iOS, prefer native system fonts. The global.css file of
|
||||
Compound Web is where these variables ultimately get consumed to set the page's
|
||||
font-family. */
|
||||
[data-element-call-root][data-platform="android"] {
|
||||
--cpd-font-family-sans: "Roboto", "Noto", "Inter", sans-serif;
|
||||
}
|
||||
|
||||
[data-element-call-root][data-platform="ios"] {
|
||||
--cpd-font-family-sans:
|
||||
-apple-system, BlinkMacSystemFont, "Inter", sans-serif;
|
||||
}
|
||||
|
||||
@layer compound-legacy {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
a {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Headline Semi Bold */
|
||||
h1 {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-headline);
|
||||
}
|
||||
|
||||
/* Title */
|
||||
h2 {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-title);
|
||||
}
|
||||
|
||||
/* Subtitle */
|
||||
h3 {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-subtitle);
|
||||
}
|
||||
|
||||
/* Body Semi Bold */
|
||||
h4 {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
p {
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--font-size-title);
|
||||
}
|
||||
|
||||
hr {
|
||||
width: calc(100% - 24px);
|
||||
border: none;
|
||||
border-top: 1px solid var(--cpd-color-border-interactive-secondary);
|
||||
color: var(--cpd-color-border-interactive-secondary);
|
||||
overflow: visible;
|
||||
text-align: center;
|
||||
height: 5px;
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-body);
|
||||
line-height: 24px;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
summary {
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
details > :not(summary) {
|
||||
margin-left: var(--font-size-body);
|
||||
}
|
||||
|
||||
details[open] > summary {
|
||||
margin-bottom: var(--font-size-body);
|
||||
}
|
||||
}
|
||||
|
||||
/* normalize.css sets the focus rings on buttons in Firefox to an unusual custom
|
||||
outline, which is inconsistent with our other components and is not sufficiently
|
||||
visible to be accessible. This resets it back to 'auto'. */
|
||||
button:-moz-focusring,
|
||||
[type="button"]:-moz-focusring,
|
||||
[type="reset"]:-moz-focusring,
|
||||
[type="submit"]:-moz-focusring {
|
||||
outline: auto;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
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 MouseEvent, type ReactNode, useCallback } from "react";
|
||||
import { Link } from "@vector-im/compound-web";
|
||||
|
||||
import { useLeaveToHome } from "../LeaveToHomeContext";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A link out of the call, to wherever the user came from. Renders nothing when
|
||||
* there is nowhere to go (see {@link useLeaveToHome}).
|
||||
*/
|
||||
export const LeaveToHomeLink: FC<Props> = ({ className, children }) => {
|
||||
const leaveToHome = useLeaveToHome();
|
||||
const onClick = useCallback(
|
||||
(e: MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
leaveToHome?.();
|
||||
},
|
||||
[leaveToHome],
|
||||
);
|
||||
|
||||
if (leaveToHome === null) return null;
|
||||
// Where this leads is the shell's business, so the link has no address of
|
||||
// its own to offer
|
||||
return (
|
||||
<Link className={className} href="#" onClick={onClick}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,7 @@
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
@container element-call (max-width: 420px) {
|
||||
.reactionPopupMenu {
|
||||
--reaction-button-padding: 8px;
|
||||
--reaction-button-fontsize: 16px;
|
||||
@@ -19,7 +19,12 @@
|
||||
}
|
||||
|
||||
div.reactionPopupMenuRoot.reactionPopupMenuModal {
|
||||
--overlay-top: 82vh;
|
||||
/* Down near the footer it belongs to, rather than centred like other modals.
|
||||
A percentage, not a viewport unit: the overlay is positioned `fixed`, so this
|
||||
resolves against the page in the standalone app and against the container
|
||||
when a host embeds us — where 82vh would put it below the container
|
||||
entirely. */
|
||||
--overlay-top: 82%;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@@ -30,7 +35,7 @@ div.reactionPopupMenuRoot {
|
||||
|
||||
.reactionPopupMenuRoot > div {
|
||||
width: fit-content;
|
||||
max-width: 100vw;
|
||||
max-width: 100cqw;
|
||||
}
|
||||
|
||||
div.reactionPopupMenuRoot.reactionPopupMenuModal > div > div {
|
||||
|
||||
@@ -77,7 +77,7 @@ Please see LICENSE in the repository root for full details.
|
||||
}
|
||||
|
||||
/*First hide the logo*/
|
||||
@media (max-width: 750px) {
|
||||
@container element-call (max-width: 750px) {
|
||||
.logo {
|
||||
display: none;
|
||||
}
|
||||
@@ -94,7 +94,7 @@ Please see LICENSE in the repository root for full details.
|
||||
With the logo hidden >500px is enough space to show overflow, buttons, layout.
|
||||
Once we exceed 500 we hide everything except the buttons.
|
||||
*/
|
||||
@media (max-width: 500px) {
|
||||
@container element-call (max-width: 500px) {
|
||||
.footer {
|
||||
grid-template-areas: "buttons buttons buttons";
|
||||
}
|
||||
@@ -115,27 +115,27 @@ Once we exceed 500 we hide everything except the buttons.
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 800px) {
|
||||
@container element-call (max-height: 800px) {
|
||||
.footer {
|
||||
padding-block: var(--cpd-space-8x)
|
||||
calc(env(safe-area-inset-bottom) + var(--cpd-space-8x));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 400px) {
|
||||
@container element-call (max-height: 400px) {
|
||||
.footer {
|
||||
padding-block: var(--cpd-space-4x)
|
||||
calc(env(safe-area-inset-bottom) + var(--cpd-space-4x));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 370px) {
|
||||
@container element-call (max-width: 370px) {
|
||||
.shareScreen {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* PIP custom css */
|
||||
@media (max-height: 400px) {
|
||||
@container element-call (max-height: 400px) {
|
||||
.shareScreen {
|
||||
display: flex;
|
||||
}
|
||||
@@ -148,13 +148,13 @@ Once we exceed 500 we hide everything except the buttons.
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 320px) {
|
||||
@container element-call (max-width: 320px) {
|
||||
.raiseHand {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 800px) {
|
||||
@container element-call (min-width: 800px) {
|
||||
.buttons {
|
||||
gap: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ const reactionData = {
|
||||
reactions$: new BehaviorSubject({}),
|
||||
};
|
||||
|
||||
const mediaDevices = new MediaDevices(globalScope);
|
||||
const mediaDevices = new MediaDevices(globalScope, {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* A wrapper component that is used for:
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { Alignment, Layout } from "../state/layout-types";
|
||||
import type { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||
import type { DeviceLabel } from "../state/MediaDevices";
|
||||
import { createCallFooterViewModel } from "./CallFooterViewModel";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
|
||||
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||
vi.mock("../Platform", () => ({
|
||||
@@ -105,6 +106,7 @@ describe("createCallFooterViewModel", () => {
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
|
||||
expect(vm.audioOptions$.value).toEqual([]);
|
||||
@@ -126,6 +128,7 @@ describe("createCallFooterViewModel", () => {
|
||||
mockMuteStates(),
|
||||
twoMicsAndOneCamMediaDevices,
|
||||
/* reactionIdentifier */ undefined,
|
||||
{ showControls: true, header: HeaderStyle.Standard },
|
||||
);
|
||||
|
||||
expect(vm.audioOptions$?.value).toEqual([
|
||||
|
||||
@@ -19,7 +19,7 @@ import { type Behavior, constant } from "../state/Behavior";
|
||||
import type { ObservableScope } from "../state/ObservableScope";
|
||||
import { type MuteStates } from "../state/MuteStates";
|
||||
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
|
||||
import { getUrlParams, HeaderStyle } from "../UrlParams";
|
||||
import { HeaderStyle } from "../UrlParams";
|
||||
import { platform } from "../Platform";
|
||||
import { type FooterSnapshot } from "./CallFooter";
|
||||
|
||||
@@ -138,6 +138,8 @@ function buildDeviceBehaviors(
|
||||
* @param mediaDevices - Available and selected input devices.
|
||||
* @param reactionIdentifier - The local user's reaction identifier string, or
|
||||
* undefined when reactions are not supported (hides the reaction button).
|
||||
* @param options - `showControls`: whether the call controls should be shown.
|
||||
* `header`: the style of header, which decides whether to show the logo.
|
||||
*/
|
||||
export function createCallFooterViewModel(
|
||||
scope: ObservableScope,
|
||||
@@ -145,8 +147,9 @@ export function createCallFooterViewModel(
|
||||
muteStates: MuteStates,
|
||||
mediaDevices: MediaDevices,
|
||||
reactionIdentifier: string | undefined,
|
||||
options: { showControls: boolean; header: HeaderStyle },
|
||||
): ViewModel<FooterSnapshot> {
|
||||
const { showControls, header: headerStyle } = getUrlParams();
|
||||
const { showControls, header: headerStyle } = options;
|
||||
const showLogo = headerStyle === HeaderStyle.Standard;
|
||||
|
||||
const isPip$ = scope.behavior(
|
||||
|
||||
@@ -14,7 +14,9 @@ import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||
import { MediaDevices } from "../state/MediaDevices";
|
||||
import { globalScope } from "../state/ObservableScope";
|
||||
|
||||
const mediaDevices = new MediaDevices(globalScope);
|
||||
const mediaDevices = new MediaDevices(globalScope, {
|
||||
controlledAudioDevices: false,
|
||||
});
|
||||
|
||||
const meta = {
|
||||
component: MediaMuteAndSwitchButton,
|
||||
|
||||
@@ -8,8 +8,8 @@ Please see LICENSE in the repository root for full details.
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import { validateConfig } from "./Config";
|
||||
import { MatrixRTCMode } from "./ConfigOptions";
|
||||
import { Config, validateConfig } from "./Config";
|
||||
import { DEFAULT_CONFIG, MatrixRTCMode } from "./ConfigOptions";
|
||||
|
||||
describe("validateConfig", () => {
|
||||
afterEach(() => {
|
||||
@@ -52,3 +52,52 @@ describe("validateConfig", () => {
|
||||
expect(result.ssla).toBe("https://example.invalid/ssla");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Config.initWith", () => {
|
||||
// vitest.setup.ts has already called initDefault(), so every test here is
|
||||
// free to re-initialize; the last call wins.
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
Config.initDefault();
|
||||
});
|
||||
|
||||
it("makes the supplied config readable", () => {
|
||||
Config.initWith({ ssla: "https://example.invalid/ssla" });
|
||||
expect(Config.get().ssla).toBe("https://example.invalid/ssla");
|
||||
});
|
||||
|
||||
it("fills in defaults for keys the embedder did not supply", () => {
|
||||
Config.initWith({ ssla: "https://example.invalid/ssla" });
|
||||
expect(Config.get().media_quality).toEqual(DEFAULT_CONFIG.media_quality);
|
||||
});
|
||||
|
||||
it("validates the supplied config just as a fetched one would be", () => {
|
||||
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
|
||||
Config.initWith({
|
||||
matrix_rtc_mode: "nonsense" as unknown as MatrixRTCMode,
|
||||
});
|
||||
expect(Config.get().matrix_rtc_mode).toBeUndefined();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not share nested state with DEFAULT_CONFIG", () => {
|
||||
Config.initWith({});
|
||||
expect(Config.get().media_quality).not.toBe(DEFAULT_CONFIG.media_quality);
|
||||
});
|
||||
|
||||
it("stops a later init() from fetching over the top of it", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
Config.initWith({ ssla: "https://example.invalid/ssla" });
|
||||
|
||||
await Config.init();
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(Config.get().ssla).toBe("https://example.invalid/ssla");
|
||||
});
|
||||
|
||||
it("replaces a config initialized earlier", () => {
|
||||
Config.initWith({ ssla: "https://first.invalid/ssla" });
|
||||
Config.initWith({ ssla: "https://second.invalid/ssla" });
|
||||
expect(Config.get().ssla).toBe("https://second.invalid/ssla");
|
||||
});
|
||||
});
|
||||
|
||||
+41
-7
@@ -30,6 +30,14 @@ export class Config {
|
||||
return this.internalInstance.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the config by fetching `config.json`, locating it relative to
|
||||
* the current page.
|
||||
*
|
||||
* Does nothing if the config has already been initialized, including by
|
||||
* {@link Config.initWith}, so that the regular startup path can run unchanged
|
||||
* when a component host has already supplied the config.
|
||||
*/
|
||||
public static async init(): Promise<void> {
|
||||
if (!Config.internalInstance?.initPromise) {
|
||||
const internalInstance = new Config();
|
||||
@@ -50,17 +58,35 @@ export class Config {
|
||||
|
||||
Config.internalInstance.initPromise = downloadConfig(fetchTarget).then(
|
||||
(config) => {
|
||||
internalInstance.config = merge(
|
||||
{},
|
||||
DEFAULT_CONFIG,
|
||||
validateConfig(config),
|
||||
);
|
||||
internalInstance.config = resolveConfig(config);
|
||||
},
|
||||
);
|
||||
}
|
||||
return Config.internalInstance.initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the config from an object supplied by the application hosting
|
||||
* the component, instead of fetching `config.json`.
|
||||
*
|
||||
* {@link Config.init} derives the location of `config.json` from
|
||||
* `window.location`, which only makes sense while Element Call owns the page.
|
||||
* As a component, the host owns the configuration and passes it in here.
|
||||
*
|
||||
* The config goes through the same validation and defaulting as a fetched
|
||||
* one, so that a supplied config behaves identically to a fetched one.
|
||||
*
|
||||
* Replaces any config initialized earlier.
|
||||
*/
|
||||
public static initWith(config: ConfigOptions): void {
|
||||
const internalInstance = new Config();
|
||||
internalInstance.config = resolveConfig(config);
|
||||
// Mark initialization as already done, so that a later init() resolves
|
||||
// immediately rather than fetching config.json over the top of this.
|
||||
internalInstance.initPromise = Promise.resolve();
|
||||
Config.internalInstance = internalInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is a alternative initializer that does not load anything
|
||||
* from a hosted config file but instead just initializes the config using the
|
||||
@@ -69,8 +95,7 @@ export class Config {
|
||||
* It is supposed to only be used in tests. (It is executed in `vite.setup.js`)
|
||||
*/
|
||||
public static initDefault(): void {
|
||||
Config.internalInstance = new Config();
|
||||
Config.internalInstance.config = { ...DEFAULT_CONFIG };
|
||||
Config.initWith({});
|
||||
}
|
||||
|
||||
// Convenience accessors
|
||||
@@ -94,6 +119,15 @@ export class Config {
|
||||
private initPromise?: Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies validation and the built-in defaults to a config, however it was
|
||||
* obtained. Deep-merges onto a fresh object so that the result never shares
|
||||
* nested state with {@link DEFAULT_CONFIG}.
|
||||
*/
|
||||
function resolveConfig(config: ConfigOptions): ResolvedConfigOptions {
|
||||
return merge({}, DEFAULT_CONFIG, validateConfig(config));
|
||||
}
|
||||
|
||||
export function validateConfig(config: ConfigOptions): ConfigOptions {
|
||||
const mode = config.matrix_rtc_mode;
|
||||
if (mode !== undefined && !VALID_MATRIX_RTC_MODES.has(mode)) {
|
||||
|
||||
+55
-33
@@ -24,6 +24,35 @@ export enum MatrixRTCMode {
|
||||
Matrix_2_0 = "matrix_2_0",
|
||||
}
|
||||
|
||||
export interface DelayedLeaveTimings {
|
||||
/**
|
||||
* The delay (in milliseconds) with which delayed leave events are sent.
|
||||
*
|
||||
* If the server receives no keep-alives from the client for any longer than
|
||||
* this duration, it will send the leave event, automatically removing the
|
||||
* user from the call.
|
||||
*/
|
||||
delay_ms?: number;
|
||||
|
||||
/**
|
||||
* How frequently (in milliseconds) the client sends keep-alives to the server
|
||||
* to restart the timer for a delayed leave event. Should be less than
|
||||
* {@link DelayedLeaveTimings.delay_ms}.
|
||||
*/
|
||||
restart_ms?: number;
|
||||
|
||||
/**
|
||||
* The time (in milliseconds) after which we consider a delayed event restart HTTP request to have failed.
|
||||
* Setting this to a lower value will result in more frequent retries, but then we will also give up earlier.
|
||||
*
|
||||
* In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs
|
||||
* helps by keeping more delayed event reset candidates in flight,
|
||||
* improving the chances of a successful reset. (its is equivalent to the js-sdk `localTimeout` configuration,
|
||||
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
|
||||
*/
|
||||
restart_timeout_ms?: number;
|
||||
}
|
||||
|
||||
export interface ConfigOptions {
|
||||
/**
|
||||
* The Posthog endpoint to which analytics data will be sent.
|
||||
@@ -184,29 +213,6 @@ export interface ConfigOptions {
|
||||
*/
|
||||
wait_for_key_rotation_ms?: number;
|
||||
|
||||
/**
|
||||
* The duration (in milliseconds) after the most recent keep-alive (delayed leave event restart)
|
||||
* that the server waits before sending the leave MatrixRTC membership event.
|
||||
*/
|
||||
delayed_leave_event_delay_ms?: number;
|
||||
|
||||
/**
|
||||
* The time (in milliseconds) after which we consider a delayed event restart http request to have failed.
|
||||
* Setting this to a lower value will result in more frequent retries but also a higher chance of failiour.
|
||||
*
|
||||
* In the presence of network packet loss (hurting TCP connections), the custom delayedEventRestartLocalTimeoutMs
|
||||
* helps by keeping more delayed event reset candidates in flight,
|
||||
* improving the chances of a successful reset. (its is equivalent to the js-sdk `localTimeout` configuration,
|
||||
* but only applies to calls to the `_unstable_updateDelayedEvent` endpoint with a body of `{action:"restart"}`.)
|
||||
*/
|
||||
delayed_leave_event_restart_local_timeout_ms?: number;
|
||||
|
||||
/**
|
||||
* The time interval (in milliseconds) at which the client sends membership keep-alive
|
||||
* messages to the server by restarting the timer for the delayed leave event.
|
||||
*/
|
||||
delayed_leave_event_restart_ms?: number;
|
||||
|
||||
/**
|
||||
* How long we wait before retrying after a network error on any of the requests.
|
||||
*/
|
||||
@@ -231,9 +237,28 @@ export interface ConfigOptions {
|
||||
* Defaults to the js-sdk default (undefined). Which means that rotation will always happen.
|
||||
*/
|
||||
key_rotation_participant_limit?: number;
|
||||
|
||||
/**
|
||||
* Timing options for delayed leave events, which are used to remove a user
|
||||
* from a call when they lose connection.
|
||||
*/
|
||||
delayed_leave?: DelayedLeaveTimings;
|
||||
|
||||
/**
|
||||
* Timing options for delayed leave events, in cases where the ability to
|
||||
* send the event can be delegated to the SFU.
|
||||
*
|
||||
* We recommend setting {@link DelayedLeaveTimings.delay_ms} >>
|
||||
* {@link sync_disconnect_grace_period_ms} here.
|
||||
*/
|
||||
delegated_delayed_leave?: DelayedLeaveTimings;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ResolvedDelayedLeaveTimings extends DelayedLeaveTimings {
|
||||
delay_ms: number; // Required
|
||||
}
|
||||
|
||||
// Overrides members from ConfigOptions that are always provided by the
|
||||
// default config and are therefore non-optional.
|
||||
export interface ResolvedConfigOptions extends ConfigOptions {
|
||||
@@ -257,19 +282,15 @@ export interface ResolvedConfigOptions extends ConfigOptions {
|
||||
>
|
||||
>;
|
||||
};
|
||||
matrix_rtc_session: {
|
||||
wait_for_key_rotation_ms?: number;
|
||||
delayed_leave_event_delay_ms: number;
|
||||
delayed_leave_event_restart_local_timeout_ms?: number;
|
||||
delayed_leave_event_restart_ms?: number;
|
||||
matrix_rtc_session: ConfigOptions["matrix_rtc_session"] & {
|
||||
network_error_retry_ms: number;
|
||||
membership_event_expiry_ms?: number;
|
||||
key_rotation_participant_limit?: number;
|
||||
delayed_leave: ResolvedDelayedLeaveTimings;
|
||||
delegated_delayed_leave: ResolvedDelayedLeaveTimings;
|
||||
};
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
||||
sync_disconnect_grace_period_ms: 10000,
|
||||
sync_disconnect_grace_period_ms: 10_000,
|
||||
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
|
||||
media_quality: {
|
||||
video_codec: "vp8",
|
||||
@@ -285,7 +306,8 @@ export const DEFAULT_CONFIG: ResolvedConfigOptions = {
|
||||
},
|
||||
},
|
||||
matrix_rtc_session: {
|
||||
delayed_leave_event_delay_ms: 10000,
|
||||
network_error_retry_ms: 1000,
|
||||
network_error_retry_ms: 1_000,
|
||||
delayed_leave: { delay_ms: 18_000, restart_ms: 4_000 },
|
||||
delegated_delayed_leave: { delay_ms: 3_600_000, restart_ms: 300_000 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
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, describe, expect, test } from "vitest";
|
||||
|
||||
import { getKeyForRoom, saveKeyForRoom } from "./sharedKeyManagement";
|
||||
|
||||
const roomId = "!room:example.org";
|
||||
|
||||
describe("getKeyForRoom", () => {
|
||||
afterEach(() => {
|
||||
window.location.hash = "#";
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
test("prefers a key given in the parameters over the stored one", () => {
|
||||
saveKeyForRoom(roomId, "stored");
|
||||
window.location.hash = `#?roomId=${encodeURIComponent(roomId)}&password=from-the-link`;
|
||||
|
||||
expect(getKeyForRoom(roomId)).toBe("from-the-link");
|
||||
});
|
||||
|
||||
test("falls back to the stored key", () => {
|
||||
saveKeyForRoom(roomId, "stored");
|
||||
|
||||
expect(getKeyForRoom(roomId)).toBe("stored");
|
||||
});
|
||||
|
||||
test("has no key to offer for a room it has never seen", () => {
|
||||
expect(getKeyForRoom(roomId)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
setLocalStorageItemReactive,
|
||||
useLocalStorage,
|
||||
} from "../useLocalStorage";
|
||||
import { getUrlParams } from "../UrlParams";
|
||||
import { getUrlParams, useUrlParams } from "../UrlParams";
|
||||
import { E2eeType } from "./e2eeType";
|
||||
import { useClient } from "../ClientContext";
|
||||
|
||||
@@ -57,19 +57,31 @@ const useRoomSharedKey = (
|
||||
return [setInitialValue ?? roomSharedKey, setRoomSharedKey];
|
||||
};
|
||||
|
||||
export function getKeyForRoom(roomId: string): string | null {
|
||||
const { roomId: urlRoomId, password } = getUrlParams();
|
||||
if (roomId !== urlRoomId)
|
||||
/**
|
||||
* The shared key for a room, preferring one supplied in the parameters Element
|
||||
* Call was started with over whatever is in local storage.
|
||||
*/
|
||||
function keyForRoom(
|
||||
roomId: string,
|
||||
paramsRoomId: string | null,
|
||||
password: string | null,
|
||||
): string | null {
|
||||
if (roomId !== paramsRoomId)
|
||||
logger.warn(
|
||||
"requested key for a roomId which is not the current call room id (from the URL)",
|
||||
roomId,
|
||||
urlRoomId,
|
||||
paramsRoomId,
|
||||
);
|
||||
return (
|
||||
password ?? localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId))
|
||||
);
|
||||
}
|
||||
|
||||
export function getKeyForRoom(roomId: string): string | null {
|
||||
const { roomId: paramsRoomId, password } = getUrlParams();
|
||||
return keyForRoom(roomId, paramsRoomId, password);
|
||||
}
|
||||
|
||||
export type Unencrypted = { kind: E2eeType.NONE };
|
||||
export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
|
||||
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
|
||||
@@ -77,10 +89,15 @@ export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
|
||||
|
||||
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
||||
const { client } = useClient();
|
||||
const { roomId: paramsRoomId, password } = useUrlParams();
|
||||
|
||||
const [storedPassword] = useRoomSharedKey(
|
||||
// TODO: this passes an already-prefixed key where a room ID is expected, so
|
||||
// the local storage key ends up prefixed twice and never matches what
|
||||
// saveKeyForRoom writes. Preserved as-is here to keep this commit a pure
|
||||
// refactor; the reactive read is effectively dead until it is fixed.
|
||||
getRoomSharedKeyLocalStorageKey(roomId),
|
||||
getKeyForRoom(roomId) ?? undefined,
|
||||
keyForRoom(roomId, paramsRoomId, password) ?? undefined,
|
||||
);
|
||||
|
||||
const room = client?.getRoom(roomId);
|
||||
|
||||
@@ -30,7 +30,7 @@ Please see LICENSE in the repository root for full details.
|
||||
block-size: 140px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
@container element-call (max-width: 600px) {
|
||||
/* Give the PiP a portrait aspect ratio */
|
||||
.pip[data-size="sm"] {
|
||||
inline-size: 88px;
|
||||
|
||||
@@ -25,7 +25,7 @@ Please see LICENSE in the repository root for full details.
|
||||
var(--content-inset-left);
|
||||
}
|
||||
|
||||
@media (min-width: 600px) {
|
||||
@container element-call (min-width: 600px) {
|
||||
.pip {
|
||||
inline-size: 180px;
|
||||
block-size: 135px;
|
||||
|
||||
@@ -13,7 +13,6 @@ import { ErrorPage, LoadingPage } from "../FullScreenView";
|
||||
import { UnauthenticatedView } from "./UnauthenticatedView";
|
||||
import { RegisteredView } from "./RegisteredView";
|
||||
import { usePageTitle } from "../usePageTitle";
|
||||
import { widget } from "../widget.ts";
|
||||
|
||||
export const HomePage: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -24,7 +23,7 @@ export const HomePage: FC = () => {
|
||||
if (!clientState) {
|
||||
return <LoadingPage />;
|
||||
} else if (clientState.state === "error") {
|
||||
return <ErrorPage widget={widget} error={clientState.error} />;
|
||||
return <ErrorPage error={clientState.error} />;
|
||||
} else {
|
||||
return clientState.authenticated ? (
|
||||
<RegisteredView client={clientState.authenticated.client} />
|
||||
|
||||
+9
-182
@@ -5,64 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
@layer normalize, compound-legacy, compound;
|
||||
/* 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. */
|
||||
|
||||
@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;
|
||||
--font-size-micro: calc(10px * var(--font-scale));
|
||||
--font-size-caption: calc(12px * var(--font-scale));
|
||||
--font-size-body: calc(15px * var(--font-scale));
|
||||
--font-size-subtitle: calc(18px * var(--font-scale));
|
||||
--font-size-title: calc(24px * var(--font-scale));
|
||||
--font-size-headline: calc(32px * var(--font-scale));
|
||||
|
||||
--cpd-color-border-accent: var(--cpd-color-green-800);
|
||||
/* The distance to inset non-full-width content from the edge of the window
|
||||
along the inline axis. This ramps up from 16px for typical mobile windows, to
|
||||
96px for typical desktop windows, and accounts for the safe area. */
|
||||
--content-inset-left: calc(
|
||||
env(safe-area-inset-left) +
|
||||
min(
|
||||
var(--cpd-space-24x),
|
||||
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
||||
)
|
||||
);
|
||||
--content-inset-right: calc(
|
||||
env(safe-area-inset-right) +
|
||||
min(
|
||||
var(--cpd-space-24x),
|
||||
max(var(--cpd-space-4x), calc((100vw - 900px) / 3))
|
||||
)
|
||||
);
|
||||
--small-drop-shadow: 0px 1.2px 2.4px 0px rgba(0, 0, 0, 0.15);
|
||||
--big-drop-shadow: 0px 0px 24px 0px #1b1d221a;
|
||||
--subtle-drop-shadow: 0px 1px 2px 0px rgba(16, 24, 40, 0.05);
|
||||
|
||||
--call-view-overlay-layer: 1;
|
||||
--call-view-header-footer-layer: 2;
|
||||
}
|
||||
|
||||
:root,
|
||||
[class*="cpd-theme-"] {
|
||||
--video-tile-background: var(--cpd-color-bg-subtle-secondary);
|
||||
}
|
||||
|
||||
.cpd-theme-dark {
|
||||
--cpd-color-border-accent: var(--cpd-color-green-1100);
|
||||
--stopgap-color-on-solid-accent: var(--cpd-color-text-primary);
|
||||
--stopgap-background-85: rgba(16, 19, 23, 0.85);
|
||||
}
|
||||
@import url("./base.css");
|
||||
|
||||
body {
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
@@ -74,39 +21,16 @@ body {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@media (min-height: 330px) {
|
||||
body[data-background="gradient"]::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
/* Chromium abruptly fades our images to fully transparent at the edge of
|
||||
the element. If we just make the element a little bigger than the viewport,
|
||||
this is no longer visible. */
|
||||
inset: -20px;
|
||||
background-image: url("graphics/mobile-gradient.png");
|
||||
background-size: 1400px 305px;
|
||||
background-position: bottom;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
body[data-background="gradient"][data-platform="desktop"]::before {
|
||||
background-image: url("graphics/desktop-gradient.png");
|
||||
background-size: max(1440px, 100vw) max(1440px, 100vh);
|
||||
background-position: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* This prohibits the view to scroll for pages smaller than 122px in width
|
||||
we use this for mobile pip webviews */
|
||||
.no-scroll-body {
|
||||
we use this for mobile pip webviews. Element Call adds this class to whatever
|
||||
it treats as its root, but it is only ever the page that should be pinned like
|
||||
this — done to a container inside a host application it would take that
|
||||
container out of the host's layout — so the selector says so. */
|
||||
body.no-scroll-body {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* We use this to not render the page at all until we know the theme.*/
|
||||
.no-theme {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
@@ -123,104 +47,7 @@ body,
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* On Android and iOS, prefer native system fonts. The global.css file of
|
||||
Compound Web is where these variables ultimately get consumed to set the page's
|
||||
font-family. */
|
||||
body[data-platform="android"] {
|
||||
--cpd-font-family-sans: "Roboto", "Noto", "Inter", sans-serif;
|
||||
}
|
||||
|
||||
body[data-platform="ios"] {
|
||||
--cpd-font-family-sans:
|
||||
-apple-system, BlinkMacSystemFont, "Inter", sans-serif;
|
||||
}
|
||||
|
||||
@layer compound-legacy {
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
p,
|
||||
a {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* Headline Semi Bold */
|
||||
h1 {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-headline);
|
||||
}
|
||||
|
||||
/* Title */
|
||||
h2 {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-title);
|
||||
}
|
||||
|
||||
/* Subtitle */
|
||||
h3 {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-subtitle);
|
||||
}
|
||||
|
||||
/* Body Semi Bold */
|
||||
h4 {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
p {
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--font-size-title);
|
||||
}
|
||||
|
||||
hr {
|
||||
width: calc(100% - 24px);
|
||||
border: none;
|
||||
border-top: 1px solid var(--cpd-color-border-interactive-secondary);
|
||||
color: var(--cpd-color-border-interactive-secondary);
|
||||
overflow: visible;
|
||||
text-align: center;
|
||||
height: 5px;
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-body);
|
||||
line-height: 24px;
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
summary {
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
details > :not(summary) {
|
||||
margin-left: var(--font-size-body);
|
||||
}
|
||||
|
||||
details[open] > summary {
|
||||
margin-bottom: var(--font-size-body);
|
||||
}
|
||||
}
|
||||
|
||||
#root > [data-overlay-container] {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* normalize.css sets the focus rings on buttons in Firefox to an unusual custom
|
||||
outline, which is inconsistent with our other components and is not sufficiently
|
||||
visible to be accessible. This resets it back to 'auto'. */
|
||||
button:-moz-focusring,
|
||||
[type="button"]:-moz-focusring,
|
||||
[type="reset"]:-moz-focusring,
|
||||
[type="submit"]:-moz-focusring {
|
||||
outline: auto;
|
||||
}
|
||||
|
||||
+50
-21
@@ -6,12 +6,11 @@ Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import i18n, {
|
||||
import {
|
||||
type BackendModule,
|
||||
type ReadCallback,
|
||||
type ResourceKey,
|
||||
} from "i18next";
|
||||
import { initReactI18next } from "react-i18next";
|
||||
import LanguageDetector from "i18next-browser-languagedetector";
|
||||
import * as Sentry from "@sentry/react";
|
||||
import { logger } from "matrix-js-sdk/lib/logger";
|
||||
@@ -26,15 +25,20 @@ import {
|
||||
import {
|
||||
setLogExtension as setLKLogExtension,
|
||||
setLogLevel as setLKLogLevel,
|
||||
LoggerNames as LKLoggerNames,
|
||||
} from "livekit-client";
|
||||
|
||||
import { getUrlParams } from "./UrlParams";
|
||||
import { Config } from "./config/Config";
|
||||
import { seedSettingsFromConfig } from "./settings/settings";
|
||||
import { platform } from "./Platform";
|
||||
import { isFailure } from "./utils/fetch";
|
||||
import { initializeWidget } from "./widget";
|
||||
import { initializeWidget, type WidgetHelpers } from "./widget";
|
||||
import { enableExtendedLivekitLogs } from "./settings/settings.ts";
|
||||
import {
|
||||
type AnalyticsConfig,
|
||||
PosthogAnalytics,
|
||||
} from "./analytics/PosthogAnalytics.ts";
|
||||
import { i18n, languageOfLocalePath } from "./utils/i18n.ts";
|
||||
|
||||
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
|
||||
// {
|
||||
@@ -53,17 +57,7 @@ const getLocaleUrl = (
|
||||
): string | undefined => locales[`../locales/${language}/${namespace}.json`];
|
||||
|
||||
const supportedLngs = [
|
||||
...new Set(
|
||||
Object.keys(locales).map((url) => {
|
||||
// The URLs are of the form ../locales/en/app.json
|
||||
// This extracts the language code from the URL
|
||||
const lang = url.match(/\/([^/]+)\/[^/]+\.json$/)?.[1];
|
||||
if (!lang) {
|
||||
throw new Error(`Could not parse locale URL ${url}`);
|
||||
}
|
||||
return lang;
|
||||
}),
|
||||
),
|
||||
...new Set(Object.keys(locales).map(languageOfLocalePath)),
|
||||
];
|
||||
|
||||
// A backend that fetches the locale files from the URLs generated by the glob above
|
||||
@@ -98,6 +92,36 @@ const Backend = {
|
||||
},
|
||||
} satisfies BackendModule;
|
||||
|
||||
/**
|
||||
* Where analytics reporting is configured from.
|
||||
*
|
||||
* Note the two halves are decided differently, and deliberately so. *Where the
|
||||
* PostHog credentials come from* depends on the package: an embedder passes
|
||||
* them in through the URL because it is responsible for its own users'
|
||||
* telemetry, whereas a standalone deployment is configured by whoever operates
|
||||
* it. *Who owns the user's analytics identity*, on the other hand, depends on
|
||||
* how Element Call is actually running right now — the full package can be used
|
||||
* as a widget too.
|
||||
*/
|
||||
// Exported for testing
|
||||
export function analyticsConfigFromEnvironment(): AnalyticsConfig {
|
||||
const { posthogApiKey, posthogApiHost, posthogUserId, isWidget } =
|
||||
getUrlParams();
|
||||
return {
|
||||
matrixBackend: isWidget ? "embedded" : "jssdk",
|
||||
hostAnalyticsId: posthogUserId,
|
||||
...(import.meta.env.VITE_PACKAGE === "embedded"
|
||||
? {
|
||||
apiKey: posthogApiKey ?? undefined,
|
||||
apiHost: posthogApiHost ?? undefined,
|
||||
}
|
||||
: {
|
||||
apiKey: Config.get().posthog?.api_key,
|
||||
apiHost: Config.get().posthog?.api_host,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
enum LoadState {
|
||||
None,
|
||||
Loading,
|
||||
@@ -121,8 +145,8 @@ export class Initializer {
|
||||
return !!Initializer.internalInstance?.isInitialized;
|
||||
}
|
||||
|
||||
public static async initBeforeReact(): Promise<void> {
|
||||
initializeWidget();
|
||||
public static async initBeforeReact(): Promise<WidgetHelpers | null> {
|
||||
const widget = initializeWidget();
|
||||
|
||||
const polyfills: Promise<unknown>[] = [];
|
||||
if (shouldPolyfillSegmenter()) {
|
||||
@@ -148,10 +172,12 @@ export class Initializer {
|
||||
document.documentElement.lang = lng;
|
||||
});
|
||||
|
||||
// Note: deliberately no `.use(initReactI18next)` — that would register this
|
||||
// instance as react-i18next's global default, which is the very global we
|
||||
// are avoiding. Components receive it through `<I18nextProvider>` instead.
|
||||
await i18n
|
||||
.use(Backend)
|
||||
.use(languageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: "en",
|
||||
defaultNS: "app",
|
||||
@@ -193,9 +219,6 @@ export class Initializer {
|
||||
);
|
||||
}
|
||||
|
||||
// Add the platform to the DOM, so CSS can query it
|
||||
document.body.setAttribute("data-platform", platform);
|
||||
|
||||
// livekit logging configuration
|
||||
setLKLogExtension((level, msg, context) => {
|
||||
// we pass a synthetic logger name of "livekit" to the rageshake to make it easier to read
|
||||
@@ -204,9 +227,14 @@ export class Initializer {
|
||||
|
||||
enableExtendedLivekitLogs.value$.subscribe((enabled) => {
|
||||
setLKLogLevel(enabled ? "trace" : "info");
|
||||
// ICE candidate types and connection states are what you need to diagnose
|
||||
// "could not establish pc connection", so always keep those in the rageshake
|
||||
setLKLogLevel("debug", LKLoggerNames.ICE);
|
||||
});
|
||||
|
||||
window.setLKLogLevel = setLKLogLevel;
|
||||
|
||||
return widget;
|
||||
}
|
||||
|
||||
public static init(): Promise<void> | null {
|
||||
@@ -239,6 +267,7 @@ export class Initializer {
|
||||
Config.init().then(
|
||||
() => {
|
||||
seedSettingsFromConfig(Config.get().media_quality);
|
||||
PosthogAnalytics.configure(analyticsConfigFromEnvironment());
|
||||
this.loadStates.config = LoadState.Loaded;
|
||||
this.initStep(resolve);
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user