mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
Merge pull request #4233 from element-hq/valere/component_ec_M1
ElementCall as a react component: refactor + a local dev harness
This commit is contained in:
@@ -7,14 +7,16 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import type { Preview } from "@storybook/react-vite";
|
import type { Preview } from "@storybook/react-vite";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
import i18n from "i18next";
|
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
import EN from "../locales/en/app.json";
|
import EN from "../locales/en/app.json";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import { i18n } from "../src/utils/i18n";
|
||||||
import "../src/index.css";
|
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
|
i18n
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
|
|||||||
@@ -36,8 +36,9 @@ You can find the latest development version continuously deployed to
|
|||||||
✅ **Decentralized & Federated** – No central authority; works across Matrix
|
✅ **Decentralized & Federated** – No central authority; works across Matrix
|
||||||
homeservers.
|
homeservers.
|
||||||
✅ **End-to-End Encrypted** – Secure and private calls.
|
✅ **End-to-End Encrypted** – Secure and private calls.
|
||||||
✅ **Standalone & Widget Mode** – Use as an independent app or embed in Matrix
|
✅ **Standalone, Widget & Component Mode** – Use as an independent app, embed
|
||||||
clients.
|
in Matrix clients as a widget, or (experimentally) mount it as a React component
|
||||||
|
inside your own application.
|
||||||
✅ **WebRTC-based** – No additional software required.
|
✅ **WebRTC-based** – No additional software required.
|
||||||
✅ **Scalable with LiveKit** – Supports large meetings via SFU
|
✅ **Scalable with LiveKit** – Supports large meetings via SFU
|
||||||
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
|
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
|
||||||
@@ -90,7 +91,9 @@ and voice calls within Matrix rooms.
|
|||||||
|
|
||||||
Element Call offers two packaging options: one for standalone or widget
|
Element Call offers two packaging options: one for standalone or widget
|
||||||
deployment, and another for seamless widget-based integration into messenger
|
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
|
**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.
|
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">
|
<img src="./docs/embedded_package.drawio.png" alt="Element Call Embedded Package">
|
||||||
</p>
|
</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
|
For more details on the packages, see the
|
||||||
[Embedded vs. Standalone Guide](./docs/embedded_standalone.md).
|
[Embedded vs. Standalone Guide](./docs/embedded_standalone.md).
|
||||||
|
|
||||||
@@ -213,6 +221,94 @@ See also:
|
|||||||
|
|
||||||
- [Developing with linked packages](./docs/linking.md)
|
- [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
|
### Backend
|
||||||
|
|
||||||
A docker compose file `docker-compose-dev.yml` is provided to start the
|
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
|
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
|
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:
|
The local backend environment should be running for the test to work:
|
||||||
`pnpm backend`
|
`pnpm backend`
|
||||||
@@ -373,7 +472,7 @@ We do this so that we can reuse the labels between repositories.
|
|||||||
|
|
||||||
## 📝 Copyright & License
|
## 📝 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
|
This software is dual-licensed by New Vector Ltd (Element). It can be used
|
||||||
either:
|
either:
|
||||||
|
|||||||
@@ -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": []
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
This folder contains documentation for setup, usage, and development of Element Call.
|
This folder contains documentation for setup, usage, and development of Element Call.
|
||||||
|
|
||||||
- [Embedded vs standalone mode](./embedded_standalone.md)
|
- [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)
|
- [Url format and parameters](./url_params.md)
|
||||||
- [Global JS controls](./controls.md)
|
- [Global JS controls](./controls.md)
|
||||||
- [MatrixRTC modes](./matrix_rtc_modes.md)
|
- [MatrixRTC modes](./matrix_rtc_modes.md)
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
Element Call is available as two different packages: Full Package and Embedded Package.
|
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.
|
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:
|
The table below provides a comparison of the two packages:
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ import { type KnipConfig } from "knip";
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
vite: {
|
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"],
|
entry: ["src/main.tsx", "eslint/index.js", "i18next.config.ts"],
|
||||||
ignoreBinaries: [
|
ignoreBinaries: [
|
||||||
|
|||||||
+11
-3
@@ -6,6 +6,7 @@
|
|||||||
"dev": "pnpm dev:full",
|
"dev": "pnpm dev:full",
|
||||||
"dev:full": "vite",
|
"dev:full": "vite",
|
||||||
"dev:embedded": "vite --config vite-embedded.config.js",
|
"dev:embedded": "vite --config vite-embedded.config.js",
|
||||||
|
"dev:component": "vite --config vite-component-dev.config.ts",
|
||||||
"build": "pnpm build:full",
|
"build": "pnpm build:full",
|
||||||
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
|
"build:full": "NODE_OPTIONS=--max-old-space-size=16384 vite build",
|
||||||
"build:full:production": "pnpm build:full",
|
"build:full:production": "pnpm build:full",
|
||||||
@@ -16,13 +17,19 @@
|
|||||||
"build:sdk:development": "pnpm build:sdk --mode development",
|
"build:sdk:development": "pnpm build:sdk --mode development",
|
||||||
"build:sdk": "pnpm build:full --config vite-sdk.config.js",
|
"build:sdk": "pnpm build:full --config vite-sdk.config.js",
|
||||||
"build:sdk:production": "pnpm build:sdk",
|
"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",
|
"serve": "vite preview",
|
||||||
"format": "oxfmt",
|
"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",
|
"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": "pnpm lint:types && pnpm lint:oxlint && pnpm lint:knip && pnpm lint:externals",
|
||||||
"lint:oxlint": "oxlint src playwright",
|
"lint:oxlint": "oxlint src component playwright",
|
||||||
"lint:oxlint-fix": "oxlint --fix src playwright",
|
"lint:oxlint-fix": "oxlint --fix src component playwright",
|
||||||
"lint:knip": "knip",
|
"lint:knip": "knip",
|
||||||
|
"lint:externals": "node scripts/check-component-externals.mjs",
|
||||||
"lint:types": "tsc",
|
"lint:types": "tsc",
|
||||||
"i18n": "npx i18next-cli extract",
|
"i18n": "npx i18next-cli extract",
|
||||||
"i18n:check": "npx i18next-cli extract --ci",
|
"i18n:check": "npx i18next-cli extract --ci",
|
||||||
@@ -107,6 +114,7 @@
|
|||||||
"pako": "^2.0.4",
|
"pako": "^2.0.4",
|
||||||
"postcss": "^8.4.41",
|
"postcss": "^8.4.41",
|
||||||
"postcss-preset-env": "^10.0.0",
|
"postcss-preset-env": "^10.0.0",
|
||||||
|
"postcss-selector-parser": "^7.1.1",
|
||||||
"posthog-js": "1.408.2",
|
"posthog-js": "1.408.2",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "19",
|
"react": "19",
|
||||||
|
|||||||
+31
-9
@@ -11,6 +11,8 @@ import { join } from "path";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { COMPONENT_HARNESS_URL } from "./playwright/component/harness.ts";
|
||||||
|
|
||||||
const baseURL = process.env.USE_DOCKER
|
const baseURL = process.env.USE_DOCKER
|
||||||
? "http://localhost:8080"
|
? "http://localhost:8080"
|
||||||
: "https://localhost:3000";
|
: "https://localhost:3000";
|
||||||
@@ -84,6 +86,11 @@ export default defineConfig({
|
|||||||
// enumerateDevices work on CI runners without real hardware.
|
// enumerateDevices work on CI runners without real hardware.
|
||||||
"media.navigator.streams.fake": true,
|
"media.navigator.streams.fake": true,
|
||||||
"media.navigator.permission.disabled": 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 */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: {
|
webServer: [
|
||||||
command: "./scripts/playwright-webserver-command.sh",
|
{
|
||||||
url: baseURL,
|
command: "./scripts/playwright-webserver-command.sh",
|
||||||
reuseExistingServer: !process.env.CI,
|
url: baseURL,
|
||||||
ignoreHTTPSErrors: true,
|
reuseExistingServer: !process.env.CI,
|
||||||
gracefulShutdown: {
|
ignoreHTTPSErrors: true,
|
||||||
signal: "SIGTERM",
|
gracefulShutdown: {
|
||||||
timeout: 500,
|
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);
|
||||||
|
}
|
||||||
Generated
+3
@@ -223,6 +223,9 @@ importers:
|
|||||||
postcss-preset-env:
|
postcss-preset-env:
|
||||||
specifier: ^10.0.0
|
specifier: ^10.0.0
|
||||||
version: 10.6.1(postcss@8.5.26)
|
version: 10.6.1(postcss@8.5.26)
|
||||||
|
postcss-selector-parser:
|
||||||
|
specifier: ^7.1.1
|
||||||
|
version: 7.1.1
|
||||||
posthog-js:
|
posthog-js:
|
||||||
specifier: 1.408.2
|
specifier: 1.408.2
|
||||||
version: 1.408.2
|
version: 1.408.2
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
supportedArchitectures:
|
||||||
|
os: [current, linux]
|
||||||
|
cpu: [current, arm64]
|
||||||
|
libc: [current, glibc]
|
||||||
minimumReleaseAgeExclude:
|
minimumReleaseAgeExclude:
|
||||||
- "@vector-im/compound-design-tokens"
|
- "@vector-im/compound-design-tokens"
|
||||||
- "@vector-im/compound-web"
|
- "@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
|
// Can this be done in the tsconfig.json
|
||||||
import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/room/types";
|
import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/room/types";
|
||||||
import { type Behavior, constant } from "../src/state/Behavior";
|
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 { ObservableScope } from "../src/state/ObservableScope";
|
||||||
import { getUrlParams } from "../src/UrlParams";
|
import { getUrlParams } from "../src/UrlParams";
|
||||||
import { MuteStates } from "../src/state/MuteStates";
|
import { MuteStates } from "../src/state/MuteStates";
|
||||||
@@ -54,12 +57,10 @@ import { MediaDevices } from "../src/state/MediaDevices";
|
|||||||
import { E2eeType } from "../src/e2ee/e2eeType";
|
import { E2eeType } from "../src/e2ee/e2eeType";
|
||||||
import { currentAndPrev, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
|
import { currentAndPrev, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
|
||||||
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
|
||||||
import {
|
import { initializeWidget } from "../src/widget";
|
||||||
ElementWidgetActions,
|
|
||||||
widget as _widget,
|
|
||||||
initializeWidget,
|
|
||||||
} from "../src/widget";
|
|
||||||
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
|
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
|
||||||
|
import { createWidgetHostBridge } from "../src/HostBridge";
|
||||||
|
import { observeElementSize$ } from "../src/utils/elementSize";
|
||||||
|
|
||||||
interface MatrixRTCSdk {
|
interface MatrixRTCSdk {
|
||||||
/**
|
/**
|
||||||
@@ -109,14 +110,15 @@ export async function createMatrixRTCSdk(
|
|||||||
const scope = new ObservableScope();
|
const scope = new ObservableScope();
|
||||||
|
|
||||||
// widget client
|
// widget client
|
||||||
initializeWidget(application, true);
|
const widget = initializeWidget(application, true);
|
||||||
const widget = _widget;
|
|
||||||
if (!widget) throw Error("No widget. This webapp can only start as a widget");
|
if (!widget) throw Error("No widget. This webapp can only start as a widget");
|
||||||
const client = await widget.client;
|
const client = await widget.client;
|
||||||
|
const hostBridge = createWidgetHostBridge(widget);
|
||||||
logger.info("client created");
|
logger.info("client created");
|
||||||
|
|
||||||
// url params
|
// 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");
|
if (roomId === null) throw Error("could not get roomId from url params");
|
||||||
const room = client.getRoom(roomId);
|
const room = client.getRoom(roomId);
|
||||||
if (room === null) throw Error("could not get room from client");
|
if (room === null) throw Error("could not get room from client");
|
||||||
@@ -128,11 +130,16 @@ export async function createMatrixRTCSdk(
|
|||||||
const rtcSession = rtcSessionManager.getRoomSession(room);
|
const rtcSession = rtcSessionManager.getRoomSession(room);
|
||||||
|
|
||||||
// media devices
|
// media devices
|
||||||
const mediaDevices = new MediaDevices(scope);
|
const mediaDevices = new MediaDevices(scope, {
|
||||||
const muteStates = new MuteStates(scope, mediaDevices, {
|
controlledAudioDevices,
|
||||||
audioEnabled: false,
|
callIntent,
|
||||||
videoEnabled: false,
|
|
||||||
});
|
});
|
||||||
|
const muteStates = new MuteStates(
|
||||||
|
scope,
|
||||||
|
mediaDevices,
|
||||||
|
{ audioEnabled: false, videoEnabled: false },
|
||||||
|
hostBridge,
|
||||||
|
);
|
||||||
|
|
||||||
// call view model
|
// call view model
|
||||||
const callViewModel = createCallViewModel$(
|
const callViewModel = createCallViewModel$(
|
||||||
@@ -141,7 +148,13 @@ export async function createMatrixRTCSdk(
|
|||||||
room,
|
room,
|
||||||
mediaDevices,
|
mediaDevices,
|
||||||
muteStates,
|
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({}),
|
||||||
of({}),
|
of({}),
|
||||||
constant({ supported: false, processor: undefined }),
|
constant({ supported: false, processor: undefined }),
|
||||||
@@ -282,18 +295,17 @@ export async function createMatrixRTCSdk(
|
|||||||
});
|
});
|
||||||
await leaveResolver.promise;
|
await leaveResolver.promise;
|
||||||
logger.info("send Unstick");
|
logger.info("send Unstick");
|
||||||
await widget.api
|
await hostBridge
|
||||||
.setAlwaysOnScreen(false)
|
.setAlwaysOnScreen(false)
|
||||||
.catch((e) =>
|
.catch((e: unknown) =>
|
||||||
logger.error(
|
logger.error("Failed to set `alwaysOnScreen` to false", e),
|
||||||
"Failed to set call widget `alwaysOnScreen` to false",
|
|
||||||
e,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
logger.info("send Close");
|
logger.info("send Close");
|
||||||
await widget.api.transport
|
await hostBridge
|
||||||
.send(ElementWidgetActions.Close, {})
|
.close?.()
|
||||||
.catch((e) => logger.error("Failed to send close action", e));
|
.catch((e: unknown) =>
|
||||||
|
logger.error("Failed to ask the host to close", e),
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// schedule close first and then leave (scope.end)
|
// schedule close first and then leave (scope.end)
|
||||||
|
|||||||
+120
-47
@@ -9,14 +9,22 @@ import {
|
|||||||
type FC,
|
type FC,
|
||||||
type JSX,
|
type JSX,
|
||||||
Suspense,
|
Suspense,
|
||||||
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} 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 * as Sentry from "@sentry/react";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
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 { HomePage } from "./home/HomePage";
|
||||||
import { LoginPage } from "./auth/LoginPage";
|
import { LoginPage } from "./auth/LoginPage";
|
||||||
@@ -25,13 +33,27 @@ import { RoomPage } from "./room/RoomPage";
|
|||||||
import { ClientProvider } from "./ClientContext";
|
import { ClientProvider } from "./ClientContext";
|
||||||
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
import { ErrorPage, LoadingPage } from "./FullScreenView";
|
||||||
import { Initializer } from "./initializer";
|
import { Initializer } from "./initializer";
|
||||||
import { widget } from "./widget";
|
import { type WidgetHelpers } from "./widget";
|
||||||
import { useTheme } from "./useTheme";
|
import { useTheme } from "./useTheme";
|
||||||
import { ProcessorProvider } from "./livekit/TrackProcessorContext";
|
import { ProcessorProvider } from "./livekit/TrackProcessorContext";
|
||||||
import { type AppViewModel } from "./state/AppViewModel";
|
import { type AppViewModel } from "./state/AppViewModel";
|
||||||
import { MediaDevicesContext } from "./MediaDevicesContext";
|
import { MediaDevicesContext } from "./MediaDevicesContext";
|
||||||
import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams";
|
import {
|
||||||
|
HeaderStyle,
|
||||||
|
UrlParamsProvider,
|
||||||
|
useUrlParams,
|
||||||
|
useUrlParamsFromLocation,
|
||||||
|
} from "./UrlParams";
|
||||||
import { AppBar } from "./AppBar";
|
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);
|
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
|
||||||
|
|
||||||
@@ -39,15 +61,38 @@ interface SimpleProviderProps {
|
|||||||
children: JSX.Element;
|
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 BackgroundProvider: FC<SimpleProviderProps> = ({ children }) => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { background } = useUrlParams();
|
const { background } = useUrlParams();
|
||||||
|
const rootElement = useRootElement();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document
|
rootElement.setAttribute("data-background", background);
|
||||||
.getElementsByTagName("body")[0]
|
}, [pathname, background, rootElement]);
|
||||||
.setAttribute("data-background", background);
|
|
||||||
}, [pathname, background]);
|
|
||||||
|
|
||||||
return children;
|
return children;
|
||||||
};
|
};
|
||||||
@@ -57,61 +102,89 @@ const ThemeProvider: FC<SimpleProviderProps> = ({ children }) => {
|
|||||||
return 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 {
|
interface Props {
|
||||||
vm: AppViewModel;
|
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);
|
const [loaded, setLoaded] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Initializer.init()
|
Initializer.init()
|
||||||
?.then(async () => {
|
?.then(async () => {
|
||||||
if (loaded) return;
|
if (loaded) return;
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
await widget?.api.sendContentLoaded();
|
await hostBridge.contentLoaded();
|
||||||
})
|
})
|
||||||
.catch(logger.error);
|
.catch(logger.error);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Since we are outside the router component, we cannot use useUrlParams here
|
// As a widget, the client comes from the host over the widget API. Standalone,
|
||||||
const { header } = useMemo(getUrlParams, []);
|
// Element Call finds one itself, so there is nothing to wait for here.
|
||||||
|
const [widgetClient, setWidgetClient] = useState<MatrixClient | undefined>(
|
||||||
const content = loaded ? (
|
undefined,
|
||||||
<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 />
|
|
||||||
);
|
);
|
||||||
|
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 (
|
return (
|
||||||
<BrowserRouter>
|
<I18nextProvider i18n={i18n}>
|
||||||
<BackgroundProvider>
|
<HostBridgeProvider value={hostBridge}>
|
||||||
<ThemeProvider>
|
<BrowserRouter>
|
||||||
<TooltipProvider>
|
<LocationUrlParamsProvider>
|
||||||
<Suspense fallback={null}>
|
<HomeProvider>
|
||||||
{header === HeaderStyle.AppBar ? (
|
<BackgroundProvider>
|
||||||
<AppBar>{content}</AppBar>
|
<ThemeProvider>
|
||||||
) : (
|
<TooltipProvider>
|
||||||
content
|
<Suspense fallback={null}>
|
||||||
)}
|
<MaybeAppBar>{content}</MaybeAppBar>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</BackgroundProvider>
|
</BackgroundProvider>
|
||||||
</BrowserRouter>
|
</HomeProvider>
|
||||||
|
</LocationUrlParamsProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</HostBridgeProvider>
|
||||||
|
</I18nextProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Hide everything but the subtitle in small windows */
|
/* Hide everything but the subtitle in small windows */
|
||||||
@media (max-height: 450px) {
|
@container element-call (max-height: 450px) {
|
||||||
.bar {
|
.bar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-platform="ios"] {
|
[data-element-call-root][data-platform="ios"] {
|
||||||
.bar > header {
|
.bar > header {
|
||||||
grid-template-rows: minmax(var(--cpd-space-11x), auto) var(--cpd-space-4x);
|
grid-template-rows: minmax(var(--cpd-space-11x), auto) var(--cpd-space-4x);
|
||||||
grid-template-areas: "primaryButton title secondaryButton";
|
grid-template-areas: "primaryButton title secondaryButton";
|
||||||
@@ -166,7 +166,7 @@ body[data-platform="ios"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Hide everything but the subtitle in small windows */
|
/* Hide everything but the subtitle in small windows */
|
||||||
@media (max-height: 450px) {
|
@container element-call (max-height: 450px) {
|
||||||
.bar:has(.subtitle) > header {
|
.bar:has(.subtitle) > header {
|
||||||
grid-template-rows: var(--cpd-space-4x) minmax(var(--cpd-space-5x), auto);
|
grid-template-rows: var(--cpd-space-4x) minmax(var(--cpd-space-5x), auto);
|
||||||
grid-template-areas: "." "subtitle";
|
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 { render, screen } from "@testing-library/react";
|
||||||
import { type MatrixClient } from "matrix-js-sdk";
|
import { type MatrixClient } from "matrix-js-sdk";
|
||||||
import { type FC, type PropsWithChildren } from "react";
|
import { type FC, type PropsWithChildren } from "react";
|
||||||
import { type WidgetApi } from "matrix-widget-api";
|
|
||||||
|
|
||||||
import { ClientContextProvider } from "./ClientContext";
|
import { ClientContextProvider } from "./ClientContext";
|
||||||
import { Avatar, getAvatarFromWidgetAPI } from "./Avatar";
|
import { Avatar } from "./Avatar";
|
||||||
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
|
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
|
||||||
import { widget } from "./widget";
|
import {
|
||||||
|
type HostBridge,
|
||||||
|
HostBridgeProvider,
|
||||||
|
nullHostBridge,
|
||||||
|
} from "./HostBridge";
|
||||||
|
|
||||||
const TestComponent: FC<
|
const TestComponent: FC<
|
||||||
PropsWithChildren<{
|
PropsWithChildren<{
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
|
hostBridge?: HostBridge;
|
||||||
}>
|
}>
|
||||||
> = ({ client, children }) => {
|
> = ({ client, hostBridge = nullHostBridge, children }) => {
|
||||||
return (
|
return (
|
||||||
<ClientContextProvider
|
<ClientContextProvider
|
||||||
value={{
|
value={{
|
||||||
@@ -38,17 +42,11 @@ const TestComponent: FC<
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
<HostBridgeProvider value={hostBridge}>{children}</HostBridgeProvider>
|
||||||
</ClientContextProvider>
|
</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(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
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 expectedMXCUrl = "mxc://example.org/alice-avatar";
|
||||||
const expectedObjectURL = "my-object-url";
|
const expectedObjectURL = "my-object-url";
|
||||||
const theBlob = new Blob([]);
|
const theBlob = new Blob([]);
|
||||||
@@ -151,8 +149,8 @@ test("should attempt to use widget API if running as a widget", async () => {
|
|||||||
getAccessToken: () => undefined,
|
getAccessToken: () => undefined,
|
||||||
} as unknown as MatrixClient);
|
} as unknown as MatrixClient);
|
||||||
|
|
||||||
widget!.api = { downloadFile: vi.fn() } as unknown as WidgetApi;
|
const downloadMedia = vi.fn().mockResolvedValue(theBlob);
|
||||||
vi.spyOn(widget!.api, "downloadFile").mockResolvedValue({ file: theBlob });
|
const hostBridge: HostBridge = { ...nullHostBridge, downloadMedia };
|
||||||
const member = mockMatrixRoomMember(
|
const member = mockMatrixRoomMember(
|
||||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
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";
|
const displayName = "Alice";
|
||||||
render(
|
render(
|
||||||
<TestComponent client={client}>
|
<TestComponent client={client} hostBridge={hostBridge}>
|
||||||
<Avatar
|
<Avatar
|
||||||
id={member.userId}
|
id={member.userId}
|
||||||
name={displayName}
|
name={displayName}
|
||||||
@@ -176,38 +174,5 @@ test("should attempt to use widget API if running as a widget", async () => {
|
|||||||
document.querySelector(`img[src='${expectedObjectURL}']`),
|
document.querySelector(`img[src='${expectedObjectURL}']`),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(widget!.api.downloadFile).toBeCalledWith(expectedMXCUrl);
|
expect(downloadMedia).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);
|
|
||||||
});
|
});
|
||||||
|
|||||||
+7
-27
@@ -14,10 +14,9 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
|
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
|
||||||
import { type MatrixClient } from "matrix-js-sdk";
|
import { type MatrixClient } from "matrix-js-sdk";
|
||||||
import { type WidgetApi } from "matrix-widget-api";
|
|
||||||
|
|
||||||
import { useClientState } from "./ClientContext";
|
import { useClientState } from "./ClientContext";
|
||||||
import { widget } from "./widget";
|
import { useHostBridge } from "./HostBridge";
|
||||||
|
|
||||||
export enum Size {
|
export enum Size {
|
||||||
XS = "xs",
|
XS = "xs",
|
||||||
@@ -76,6 +75,7 @@ export const Avatar: FC<Props> = ({
|
|||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const clientState = useClientState();
|
const clientState = useClientState();
|
||||||
|
const hostBridge = useHostBridge();
|
||||||
|
|
||||||
const sizePx = useMemo(
|
const sizePx = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -87,7 +87,8 @@ export const Avatar: FC<Props> = ({
|
|||||||
|
|
||||||
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!src) {
|
if (!src) {
|
||||||
setAvatarUrl(undefined);
|
setAvatarUrl(undefined);
|
||||||
@@ -96,8 +97,8 @@ export const Avatar: FC<Props> = ({
|
|||||||
|
|
||||||
let blob: Promise<Blob>;
|
let blob: Promise<Blob>;
|
||||||
|
|
||||||
if (widget?.api) {
|
if (hostBridge.downloadMedia) {
|
||||||
blob = getAvatarFromWidgetAPI(widget.api, src);
|
blob = hostBridge.downloadMedia(src);
|
||||||
} else if (
|
} else if (
|
||||||
clientState?.state === "valid" &&
|
clientState?.state === "valid" &&
|
||||||
clientState.authenticated?.client &&
|
clientState.authenticated?.client &&
|
||||||
@@ -132,7 +133,7 @@ export const Avatar: FC<Props> = ({
|
|||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [clientState, src, sizePx]);
|
}, [clientState, hostBridge, src, sizePx]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CompoundAvatar
|
<CompoundAvatar
|
||||||
@@ -172,24 +173,3 @@ async function getAvatarFromServer(
|
|||||||
|
|
||||||
return blob;
|
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,
|
useMemo,
|
||||||
type JSX,
|
type JSX,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
|
||||||
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
|
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
|
||||||
|
|
||||||
import type { WidgetApi } from "matrix-widget-api";
|
|
||||||
import { ErrorPage } from "./FullScreenView";
|
import { ErrorPage } from "./FullScreenView";
|
||||||
import { widget } from "./widget";
|
import { useHostBridge } from "./HostBridge";
|
||||||
|
import { useLeaveToHome } from "./LeaveToHomeContext";
|
||||||
import {
|
import {
|
||||||
PosthogAnalytics,
|
PosthogAnalytics,
|
||||||
RegistrationType,
|
RegistrationType,
|
||||||
@@ -134,18 +133,48 @@ const loadChannel =
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: JSX.Element;
|
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 }) => {
|
export const ClientProvider: FC<Props> = ({ children, client }) => {
|
||||||
const navigate = useNavigate();
|
const leaveToHome = useLeaveToHome();
|
||||||
|
const hostBridge = useHostBridge();
|
||||||
|
|
||||||
// null = signed out, undefined = loading
|
// null = signed out, undefined = loading
|
||||||
const [initClientState, setInitClientState] = useState<
|
const [initClientState, setInitClientState] = useState<
|
||||||
InitResult | null | undefined
|
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);
|
const initializing = useRef(false);
|
||||||
useEffect(() => {
|
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
|
// 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
|
// React does in strict mode), we need to make sure not to doubly initialize
|
||||||
// the client.
|
// the client.
|
||||||
@@ -160,7 +189,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
|||||||
})
|
})
|
||||||
.catch((err) => logger.error(err))
|
.catch((err) => logger.error(err))
|
||||||
.finally(() => (initializing.current = false));
|
.finally(() => (initializing.current = false));
|
||||||
}, []);
|
}, [client]);
|
||||||
|
|
||||||
const changePassword = useCallback(
|
const changePassword = useCallback(
|
||||||
async (password: string) => {
|
async (password: string) => {
|
||||||
@@ -201,7 +230,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
|||||||
|
|
||||||
saveSession(session);
|
saveSession(session);
|
||||||
setInitClientState({
|
setInitClientState({
|
||||||
widgetApi: null,
|
|
||||||
client,
|
client,
|
||||||
passwordlessUser: session.passwordlessUser,
|
passwordlessUser: session.passwordlessUser,
|
||||||
});
|
});
|
||||||
@@ -221,18 +249,20 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
|||||||
await client.clearStores();
|
await client.clearStores();
|
||||||
clearSession();
|
clearSession();
|
||||||
setInitClientState(null);
|
setInitClientState(null);
|
||||||
await navigate("/");
|
leaveToHome?.();
|
||||||
PosthogAnalytics.instance.logout();
|
PosthogAnalytics.instance.logout();
|
||||||
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest);
|
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest);
|
||||||
}, [navigate, initClientState?.client]);
|
}, [leaveToHome, initClientState?.client]);
|
||||||
|
|
||||||
// To protect against multiple sessions writing to the same storage
|
// To protect against multiple sessions writing to the same storage
|
||||||
// simultaneously, we send a broadcast message that shuts down all other
|
// 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
|
// running instances of the app. Element Call only has storage of its own to
|
||||||
// a widget though, since then it'll be mostly stateless.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!widget) loadChannel?.postMessage({});
|
if (ownsSession) loadChannel?.postMessage({});
|
||||||
}, []);
|
}, [ownsSession]);
|
||||||
|
|
||||||
const [alreadyOpenedErr, setAlreadyOpenedErr] = useState<Error | undefined>(
|
const [alreadyOpenedErr, setAlreadyOpenedErr] = useState<Error | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
@@ -307,64 +337,36 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
|||||||
initClientState.client.on(ClientEvent.Sync, onSync);
|
initClientState.client.on(ClientEvent.Sync, onSync);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (initClientState.widgetApi) {
|
if (!hostBridge.supportsReactions)
|
||||||
const reactSend = initClientState.widgetApi.hasCapability(
|
logger.warn("The host does not permit reactions");
|
||||||
"org.matrix.msc2762.send.event:m.reaction",
|
setSupportsReactions(hostBridge.supportsReactions);
|
||||||
);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (): void => {
|
return (): void => {
|
||||||
if (initClientState.client) {
|
if (initClientState.client) {
|
||||||
initClientState.client.removeListener(ClientEvent.Sync, onSync);
|
initClientState.client.removeListener(ClientEvent.Sync, onSync);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [initClientState, onSync]);
|
}, [initClientState, onSync, hostBridge]);
|
||||||
|
|
||||||
if (alreadyOpenedErr) {
|
if (alreadyOpenedErr) {
|
||||||
return <ErrorPage widget={widget} error={alreadyOpenedErr} />;
|
return <ErrorPage error={alreadyOpenedErr} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ClientContext value={state}>{children}</ClientContext>;
|
return <ClientContext value={state}>{children}</ClientContext>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InitResult = {
|
export type InitResult = {
|
||||||
widgetApi: WidgetApi | null;
|
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
passwordlessUser: boolean;
|
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> {
|
async function loadClient(): Promise<InitResult | null> {
|
||||||
if (widget) {
|
const { initSPA } = await import("./utils/spa");
|
||||||
// We're inside a widget, so let's engage *matryoshka mode*
|
return initSPA(loadSession, clearSession);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
|
|||||||
+28
-35
@@ -20,8 +20,8 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
|||||||
import { RageshakeButton } from "./settings/RageshakeButton";
|
import { RageshakeButton } from "./settings/RageshakeButton";
|
||||||
import styles from "./ErrorView.module.css";
|
import styles from "./ErrorView.module.css";
|
||||||
import { useUrlParams } from "./UrlParams";
|
import { useUrlParams } from "./UrlParams";
|
||||||
import { LinkButton } from "./button";
|
import { useLeaveToHome } from "./LeaveToHomeContext";
|
||||||
import { ElementWidgetActions, type WidgetHelpers } from "./widget.ts";
|
import { useHostBridge } from "./HostBridge.ts";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
Icon: ComponentType<SVGAttributes<SVGElement>>;
|
Icon: ComponentType<SVGAttributes<SVGElement>>;
|
||||||
@@ -38,7 +38,6 @@ interface Props {
|
|||||||
*/
|
*/
|
||||||
fatal?: boolean;
|
fatal?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
widget: WidgetHelpers | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ErrorView: FC<Props> = ({
|
export const ErrorView: FC<Props> = ({
|
||||||
@@ -47,53 +46,47 @@ export const ErrorView: FC<Props> = ({
|
|||||||
rageshake,
|
rageshake,
|
||||||
fatal,
|
fatal,
|
||||||
children,
|
children,
|
||||||
widget,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { confineToRoom } = useUrlParams();
|
const { confineToRoom } = useUrlParams();
|
||||||
|
const hostBridge = useHostBridge();
|
||||||
|
const leaveToHome = useLeaveToHome();
|
||||||
|
|
||||||
const onReload = useCallback(() => {
|
const onReload = useCallback(() => {
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const CloseWidgetButton: FC<{ widget: WidgetHelpers }> = ({
|
const CloseButton: FC<{ close: () => Promise<void> }> = ({
|
||||||
widget,
|
close,
|
||||||
}): ReactElement => {
|
}): ReactElement => {
|
||||||
// in widget mode we don't want to show the return home button but a close button
|
// When the host can dismiss us, offer that instead of a link home
|
||||||
const closeWidget = (): void => {
|
const onClose = (): void => {
|
||||||
widget.api.transport
|
close().catch((e) => {
|
||||||
.send(ElementWidgetActions.Close, {})
|
// What to do here?
|
||||||
.catch((e) => {
|
logger.error("Failed to ask the host to close Element Call", e);
|
||||||
// What to do here?
|
});
|
||||||
logger.error("Failed to send close action", e);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
widget.api.transport.stop();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Button kind="primary" onClick={closeWidget}>
|
<Button kind="primary" onClick={onClose}>
|
||||||
{t("action.close")}
|
{t("action.close")}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Whether the error is considered fatal or pathname is `/` then reload the all app.
|
// Whether the error is considered fatal or pathname is `/` then reload the all app.
|
||||||
// If not then navigate to home page.
|
// If not then navigate to home page. Neither applies when there is no home
|
||||||
const ReturnToHomeButton = (): ReactElement => {
|
// to go to.
|
||||||
if (fatal || location.pathname === "/") {
|
const ReturnToHomeButton = (): ReactElement | null => {
|
||||||
return (
|
if (leaveToHome === null) return null;
|
||||||
<Button kind="tertiary" className={styles.homeLink} onClick={onReload}>
|
return (
|
||||||
{t("return_home_button")}
|
<Button
|
||||||
</Button>
|
kind="tertiary"
|
||||||
);
|
className={styles.homeLink}
|
||||||
} else {
|
onClick={fatal || location.pathname === "/" ? onReload : leaveToHome}
|
||||||
return (
|
>
|
||||||
<LinkButton kind="tertiary" className={styles.homeLink} to="/">
|
{t("return_home_button")}
|
||||||
{t("return_home_button")}
|
</Button>
|
||||||
</LinkButton>
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -108,8 +101,8 @@ export const ErrorView: FC<Props> = ({
|
|||||||
{rageshake && (
|
{rageshake && (
|
||||||
<RageshakeButton description={`***Error View***: ${title}`} />
|
<RageshakeButton description={`***Error View***: ${title}`} />
|
||||||
)}
|
)}
|
||||||
{widget ? (
|
{hostBridge.close ? (
|
||||||
<CloseWidgetButton widget={widget} />
|
<CloseButton close={hostBridge.close} />
|
||||||
) : (
|
) : (
|
||||||
!confineToRoom && <ReturnToHomeButton />
|
!confineToRoom && <ReturnToHomeButton />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import styles from "./FullScreenView.module.css";
|
|||||||
import { useUrlParams } from "./UrlParams";
|
import { useUrlParams } from "./UrlParams";
|
||||||
import { RichError } from "./RichError";
|
import { RichError } from "./RichError";
|
||||||
import { ErrorView } from "./ErrorView";
|
import { ErrorView } from "./ErrorView";
|
||||||
import { type WidgetHelpers } from "./widget.ts";
|
|
||||||
|
|
||||||
interface FullScreenViewProps {
|
interface FullScreenViewProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -48,12 +47,11 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
|
|||||||
|
|
||||||
interface ErrorPageProps {
|
interface ErrorPageProps {
|
||||||
error: unknown;
|
error: unknown;
|
||||||
widget: WidgetHelpers | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Due to this component being used as the crash fallback for Sentry, which has
|
// 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>
|
// 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();
|
const { t } = useTranslation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
@@ -66,7 +64,6 @@ export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => {
|
|||||||
error.richMessage
|
error.richMessage
|
||||||
) : (
|
) : (
|
||||||
<ErrorView
|
<ErrorView
|
||||||
widget={widget}
|
|
||||||
Icon={ErrorSolidIcon}
|
Icon={ErrorSolidIcon}
|
||||||
title={t("error.generic")}
|
title={t("error.generic")}
|
||||||
rageshake
|
rageshake
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ Please see LICENSE in the repository root for full details.
|
|||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-decoration: none;
|
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 {
|
.leftNav.hideMobile {
|
||||||
@@ -107,7 +112,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
gap: var(--cpd-space-1-5x);
|
gap: var(--cpd-space-1-5x);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 800px) {
|
@container element-call (min-width: 800px) {
|
||||||
.headerLogo,
|
.headerLogo,
|
||||||
.leftNav.hideMobile,
|
.leftNav.hideMobile,
|
||||||
.rightNav.hideMobile {
|
.rightNav.hideMobile {
|
||||||
|
|||||||
+26
-7
@@ -6,8 +6,13 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { type Ref, type FC, type HTMLAttributes, type ReactNode } from "react";
|
import {
|
||||||
import { Link } from "react-router-dom";
|
type Ref,
|
||||||
|
type FC,
|
||||||
|
type HTMLAttributes,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Heading, Text } from "@vector-im/compound-web";
|
import { Heading, Text } from "@vector-im/compound-web";
|
||||||
import { UserProfileIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
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 Logo from "./icons/Logo.svg?react";
|
||||||
import { Avatar, Size } from "./Avatar";
|
import { Avatar, Size } from "./Avatar";
|
||||||
import { EncryptionLock } from "./room/EncryptionLock";
|
import { EncryptionLock } from "./room/EncryptionLock";
|
||||||
import { useMediaQuery } from "./useMediaQuery";
|
import { useRootSizeMatches } from "./useRootSize";
|
||||||
|
import { useLeaveToHome } from "./LeaveToHomeContext";
|
||||||
import { DisconnectedBanner } from "./DisconnectedBanner";
|
import { DisconnectedBanner } from "./DisconnectedBanner";
|
||||||
|
|
||||||
interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
interface HeaderProps extends HTMLAttributes<HTMLElement> {
|
||||||
@@ -112,17 +118,30 @@ interface HeaderLogoProps {
|
|||||||
className?: string;
|
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 }) => {
|
export const HeaderLogo: FC<HeaderLogoProps> = ({ className }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const leaveToHome = useLeaveToHome();
|
||||||
|
const onClick = useCallback(() => leaveToHome?.(), [leaveToHome]);
|
||||||
|
|
||||||
|
if (leaveToHome === null)
|
||||||
|
return (
|
||||||
|
<div className={classNames(styles.headerLogo, className)}>
|
||||||
|
<Logo />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<Link
|
<button
|
||||||
|
type="button"
|
||||||
className={classNames(styles.headerLogo, className)}
|
className={classNames(styles.headerLogo, className)}
|
||||||
to="/"
|
onClick={onClick}
|
||||||
aria-label={t("header_label")}
|
aria-label={t("header_label")}
|
||||||
>
|
>
|
||||||
<Logo />
|
<Logo />
|
||||||
</Link>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -142,7 +161,7 @@ export const RoomHeaderInfo: FC<RoomHeaderInfoProps> = ({
|
|||||||
participantCount,
|
participantCount,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const size = useMediaQuery("(max-width: 550px)") ? "sm" : "lg";
|
const size = useRootSizeMatches(({ width }) => width <= 550) ? "sm" : "lg";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.roomHeaderInfo} data-size={size}>
|
<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);
|
--handle-inset-block-end: var(--cpd-space-4x);
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-platform="ios"] .drawer {
|
[data-element-call-root][data-platform="ios"] .drawer {
|
||||||
--border-radius: 10px;
|
--border-radius: 10px;
|
||||||
--handle-block-size: 5px;
|
--handle-block-size: 5px;
|
||||||
--handle-inline-size: 36px;
|
--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 styles from "./Modal.module.css";
|
||||||
import overlayStyles from "./Overlay.module.css";
|
import overlayStyles from "./Overlay.module.css";
|
||||||
import { useMediaQuery } from "./useMediaQuery";
|
import { useMediaQuery } from "./useMediaQuery";
|
||||||
|
import { useRootElement } from "./RootElementContext";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -78,6 +79,7 @@ export const Modal: FC<Props> = ({
|
|||||||
...rest
|
...rest
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const rootElement = useRootElement();
|
||||||
// Empirically, Chrome on Android can end up not matching (hover: none), but
|
// Empirically, Chrome on Android can end up not matching (hover: none), but
|
||||||
// still matching (pointer: coarse) :/
|
// still matching (pointer: coarse) :/
|
||||||
const touchscreen = useMediaQuery("(hover: none) or (pointer: coarse)");
|
const touchscreen = useMediaQuery("(hover: none) or (pointer: coarse)");
|
||||||
@@ -100,7 +102,7 @@ export const Modal: FC<Props> = ({
|
|||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
dismissible={onDismiss !== undefined}
|
dismissible={onDismiss !== undefined}
|
||||||
>
|
>
|
||||||
<Drawer.Portal>
|
<Drawer.Portal container={rootElement}>
|
||||||
<Drawer.Overlay className={classNames(overlayStyles.bg)} />
|
<Drawer.Overlay className={classNames(overlayStyles.bg)} />
|
||||||
<Drawer.Content
|
<Drawer.Content
|
||||||
className={classNames(
|
className={classNames(
|
||||||
@@ -155,7 +157,7 @@ export const Modal: FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
<DialogRoot open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogPortal>
|
<DialogPortal container={rootElement}>
|
||||||
<DialogOverlay
|
<DialogOverlay
|
||||||
className={classNames(overlayStyles.bg, overlayStyles.animate)}
|
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 { type FC, useEffect, useState } from "react";
|
||||||
import { toDataURL } from "qrcode";
|
import { toDataURL } from "qrcode";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { t } from "i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import styles from "./QrCode.module.css";
|
import styles from "./QrCode.module.css";
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const QrCode: FC<Props> = ({ data, className }) => {
|
export const QrCode: FC<Props> = ({ data, className }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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 type { FC, ReactNode } from "react";
|
||||||
import { ErrorView } from "./ErrorView";
|
import { ErrorView } from "./ErrorView";
|
||||||
import { widget } from "./widget.ts";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An error consisting of a terse message to be logged to the console and a
|
* 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();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorView
|
<ErrorView Icon={PopOutIcon} title={t("error.open_elsewhere")}>
|
||||||
widget={widget}
|
|
||||||
Icon={PopOutIcon}
|
|
||||||
title={t("error.open_elsewhere")}
|
|
||||||
>
|
|
||||||
<p>
|
<p>
|
||||||
{t("error.open_elsewhere_description", {
|
{t("error.open_elsewhere_description", {
|
||||||
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
|
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 styles from "./Toast.module.css";
|
||||||
import overlayStyles from "./Overlay.module.css";
|
import overlayStyles from "./Overlay.module.css";
|
||||||
|
import { useRootElement } from "./RootElementContext";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -64,6 +65,7 @@ export const Toast: FC<Props> = ({
|
|||||||
Icon,
|
Icon,
|
||||||
modal = true,
|
modal = true,
|
||||||
}) => {
|
}) => {
|
||||||
|
const rootElement = useRootElement();
|
||||||
const onOpenChange = useCallback(
|
const onOpenChange = useCallback(
|
||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
if (!open) onDismiss();
|
if (!open) onDismiss();
|
||||||
@@ -104,7 +106,11 @@ export const Toast: FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogRoot open={open} onOpenChange={onOpenChange} modal={modal}>
|
<DialogRoot open={open} onOpenChange={onOpenChange} modal={modal}>
|
||||||
{modal ? <DialogPortal>{content}</DialogPortal> : content}
|
{modal ? (
|
||||||
|
<DialogPortal container={rootElement}>{content}</DialogPortal>
|
||||||
|
) : (
|
||||||
|
content
|
||||||
|
)}
|
||||||
</DialogRoot>
|
</DialogRoot>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
|||||||
|
|
||||||
import * as PlatformMod from "../src/Platform";
|
import * as PlatformMod from "../src/Platform";
|
||||||
import {
|
import {
|
||||||
|
BackgroundStyle,
|
||||||
|
configurationForIntent,
|
||||||
getRoomIdentifierFromUrl,
|
getRoomIdentifierFromUrl,
|
||||||
computeUrlParams,
|
computeUrlParams,
|
||||||
HeaderStyle,
|
HeaderStyle,
|
||||||
getUrlParams,
|
getUrlParams,
|
||||||
|
componentProperties,
|
||||||
|
UserIntent,
|
||||||
} from "../src/UrlParams";
|
} from "../src/UrlParams";
|
||||||
import { mockConfig } from "./utils/test";
|
import { mockConfig } from "./utils/test";
|
||||||
|
|
||||||
@@ -335,6 +339,50 @@ describe("UrlParams", () => {
|
|||||||
callIntent: "audio",
|
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", () => {
|
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.
|
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 { useLocation } from "react-router-dom";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import {
|
import {
|
||||||
@@ -59,6 +59,17 @@ export interface UrlProperties {
|
|||||||
// Widget api related params
|
// Widget api related params
|
||||||
widgetId: string | null;
|
widgetId: string | null;
|
||||||
parentUrl: 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
|
* 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
|
* parses the path and resolves alias with respect to the default server name, however
|
||||||
@@ -343,6 +354,136 @@ export const getUrlParams = (
|
|||||||
return params;
|
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.
|
* Gets the app parameters for the current URL.
|
||||||
* @param search The URL search string
|
* @param search The URL search string
|
||||||
@@ -372,82 +513,12 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
|||||||
const intent = !isWidget
|
const intent = !isWidget
|
||||||
? UserIntent.Unknown
|
? UserIntent.Unknown
|
||||||
: (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown);
|
: (parser.getEnumParam("intent", UserIntent) ?? UserIntent.Unknown);
|
||||||
// Here we only use constants and `platform` to determine the intent preset.
|
const intentPreset = configurationForIntent(intent);
|
||||||
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 properties: UrlProperties = {
|
const properties: UrlProperties = {
|
||||||
widgetId,
|
widgetId,
|
||||||
parentUrl,
|
parentUrl,
|
||||||
|
isWidget,
|
||||||
// NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl:
|
// 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
|
// 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.
|
// 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.
|
* Supplies the parameters Element Call should run with.
|
||||||
* @returns The app parameters for the current URL
|
*
|
||||||
|
* 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();
|
const { search, hash } = useLocation();
|
||||||
return useMemo(() => getUrlParams(search, hash), [search, hash]);
|
return useMemo(() => getUrlParams(search, hash), [search, hash]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
afterAll,
|
afterAll,
|
||||||
} from "vitest";
|
} from "vitest";
|
||||||
import posthog, { type CaptureResult } from "posthog-js";
|
import posthog, { type CaptureResult } from "posthog-js";
|
||||||
|
import { type MatrixClient } from "matrix-js-sdk";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Anonymity,
|
Anonymity,
|
||||||
@@ -22,75 +23,122 @@ import {
|
|||||||
PosthogAnalytics,
|
PosthogAnalytics,
|
||||||
} from "./PosthogAnalytics";
|
} from "./PosthogAnalytics";
|
||||||
import { mockConfig } from "../utils/test";
|
import { mockConfig } from "../utils/test";
|
||||||
|
import { analyticsConfigFromEnvironment } from "../initializer";
|
||||||
|
import { optInAnalytics } from "../settings/settings";
|
||||||
|
|
||||||
describe("PosthogAnalytics", () => {
|
describe("PosthogAnalytics", () => {
|
||||||
describe("embedded package", () => {
|
describe("enablement", () => {
|
||||||
beforeAll(() => {
|
|
||||||
vi.stubEnv("VITE_PACKAGE", "embedded");
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockConfig({});
|
|
||||||
window.location.hash = "#";
|
|
||||||
PosthogAnalytics.resetInstance();
|
PosthogAnalytics.resetInstance();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
it("stays off until it is configured", () => {
|
||||||
vi.unstubAllEnvs();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not create instance without config value or URL params", () => {
|
|
||||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores config value and does not create instance", () => {
|
it("stays off when configured without credentials", () => {
|
||||||
mockConfig({
|
PosthogAnalytics.configure({ matrixBackend: "jssdk" });
|
||||||
posthog: {
|
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||||
api_host: "https://api.example.com.localhost",
|
});
|
||||||
api_key: "api_key",
|
|
||||||
},
|
it("stays off when given only a key", () => {
|
||||||
|
PosthogAnalytics.configure({
|
||||||
|
matrixBackend: "jssdk",
|
||||||
|
apiKey: "api_key",
|
||||||
});
|
});
|
||||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses URL params if both set", () => {
|
it("turns on when given both a key and a host", () => {
|
||||||
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
|
PosthogAnalytics.configure({
|
||||||
|
matrixBackend: "jssdk",
|
||||||
|
apiKey: "api_key",
|
||||||
|
apiHost: "https://api.example.com.localhost",
|
||||||
|
});
|
||||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
|
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("full package", () => {
|
// Which of the URL and config.json the credentials come from is a deliberate
|
||||||
beforeAll(() => {
|
// policy: an embedder is responsible for its own users' telemetry, so it must
|
||||||
vi.stubEnv("VITE_PACKAGE", "full");
|
// not pick up the deployment's, and vice versa.
|
||||||
});
|
describe("analyticsConfigFromEnvironment", () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockConfig({});
|
mockConfig({});
|
||||||
window.location.hash = "#";
|
window.location.hash = "#";
|
||||||
PosthogAnalytics.resetInstance();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
vi.unstubAllEnvs();
|
vi.unstubAllEnvs();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not create instance without config value", () => {
|
const urlCredentials = `posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=url_key`;
|
||||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
const configCredentials = {
|
||||||
});
|
posthog: {
|
||||||
|
api_host: "https://config.example.com.localhost",
|
||||||
|
api_key: "config_key",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
it("ignores URL params and does not create instance", () => {
|
describe("embedded package", () => {
|
||||||
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
|
beforeAll(() => {
|
||||||
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
|
vi.stubEnv("VITE_PACKAGE", "embedded");
|
||||||
});
|
|
||||||
|
|
||||||
it("creates instance with config value", () => {
|
|
||||||
mockConfig({
|
|
||||||
posthog: {
|
|
||||||
api_host: "https://api.example.com.localhost",
|
|
||||||
api_key: "api_key",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
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
|
// posthog-js bumps renaming/removing the hook. The filter logic itself is
|
||||||
// covered by the applyPrivacyFilters block above.
|
// covered by the applyPrivacyFilters block above.
|
||||||
describe("posthog.init wiring", () => {
|
describe("posthog.init wiring", () => {
|
||||||
beforeAll(() => {
|
|
||||||
vi.stubEnv("VITE_PACKAGE", "full");
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockConfig({
|
|
||||||
posthog: {
|
|
||||||
api_host: "https://api.example.com.localhost",
|
|
||||||
api_key: "api_key",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
PosthogAnalytics.resetInstance();
|
PosthogAnalytics.resetInstance();
|
||||||
});
|
PosthogAnalytics.configure({
|
||||||
|
matrixBackend: "jssdk",
|
||||||
afterAll(() => {
|
apiKey: "api_key",
|
||||||
vi.unstubAllEnvs();
|
apiHost: "https://api.example.com.localhost",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes events through the privacy filter via before_send", () => {
|
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 MatrixClient } from "matrix-js-sdk";
|
||||||
import { type Subscription } from "rxjs";
|
import { type Subscription } from "rxjs";
|
||||||
|
|
||||||
import { widget } from "../widget";
|
|
||||||
import {
|
import {
|
||||||
CallEndedTracker,
|
CallEndedTracker,
|
||||||
CallStartedTracker,
|
CallStartedTracker,
|
||||||
@@ -29,8 +28,6 @@ import {
|
|||||||
CallConnectDurationTracker,
|
CallConnectDurationTracker,
|
||||||
CallReconnectingTracker,
|
CallReconnectingTracker,
|
||||||
} from "./PosthogEvents";
|
} from "./PosthogEvents";
|
||||||
import { Config } from "../config/Config";
|
|
||||||
import { getUrlParams } from "../UrlParams";
|
|
||||||
import { optInAnalytics } from "../settings/settings";
|
import { optInAnalytics } from "../settings/settings";
|
||||||
|
|
||||||
/* Posthog analytics tracking.
|
/* Posthog analytics tracking.
|
||||||
@@ -140,6 +137,27 @@ interface PlatformProperties {
|
|||||||
cryptoVersion?: string;
|
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 {
|
export class PosthogAnalytics {
|
||||||
/* Wrapper for Posthog analytics.
|
/* Wrapper for Posthog analytics.
|
||||||
* 3 modes of anonymity are supported, governed by this.anonymity
|
* 3 modes of anonymity are supported, governed by this.anonymity
|
||||||
@@ -167,13 +185,32 @@ export class PosthogAnalytics {
|
|||||||
private registrationType: RegistrationType = RegistrationType.Guest;
|
private registrationType: RegistrationType = RegistrationType.Guest;
|
||||||
private optInListener: Subscription | null = null;
|
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 {
|
public static hasInstance(): boolean {
|
||||||
return Boolean(this.internalInstance);
|
return Boolean(this.internalInstance);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static get instance(): PosthogAnalytics {
|
public static get instance(): PosthogAnalytics {
|
||||||
if (!this.internalInstance) {
|
if (!this.internalInstance) {
|
||||||
this.internalInstance = new PosthogAnalytics(posthog);
|
this.internalInstance = new PosthogAnalytics(
|
||||||
|
posthog,
|
||||||
|
PosthogAnalytics.analyticsConfig,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return this.internalInstance;
|
return this.internalInstance;
|
||||||
}
|
}
|
||||||
@@ -181,20 +218,14 @@ export class PosthogAnalytics {
|
|||||||
public static resetInstance(): void {
|
public static resetInstance(): void {
|
||||||
// Reset the singleton instance
|
// Reset the singleton instance
|
||||||
this.internalInstance = null;
|
this.internalInstance = null;
|
||||||
|
this.analyticsConfig = analyticsDisabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
private constructor(private readonly posthog: PostHog) {
|
private constructor(
|
||||||
let apiKey: string | undefined;
|
private readonly posthog: PostHog,
|
||||||
let apiHost: string | undefined;
|
private readonly config: AnalyticsConfig,
|
||||||
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
|
const { apiKey, apiHost } = config;
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiKey && apiHost) {
|
if (apiKey && apiHost) {
|
||||||
const beforeSend = (event: CaptureResult | null): CaptureResult | null =>
|
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";
|
const appVersion = import.meta.env.VITE_APP_VERSION || "dev";
|
||||||
return {
|
return {
|
||||||
appVersion,
|
appVersion,
|
||||||
matrixBackend: widget ? "embedded" : "jssdk",
|
matrixBackend: this.config.matrixBackend,
|
||||||
callBackend: "livekit",
|
callBackend: "livekit",
|
||||||
cryptoVersion: widget
|
// Undefined when Element Call has no crypto of its own, which is the case
|
||||||
? undefined
|
// whenever a host is doing the encrypting for it.
|
||||||
: window.matrixclient?.getCrypto()?.getVersion(),
|
cryptoVersion: window.matrixclient?.getCrypto()?.getVersion(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,8 +314,8 @@ export class PosthogAnalytics {
|
|||||||
// different devices to send the same ID.
|
// different devices to send the same ID.
|
||||||
let analyticsID = await this.getAnalyticsId();
|
let analyticsID = await this.getAnalyticsId();
|
||||||
try {
|
try {
|
||||||
if (!analyticsID && !widget) {
|
if (!analyticsID && this.config.matrixBackend !== "embedded") {
|
||||||
// only try setting up a new analytics ID in the standalone app.
|
// 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.
|
// 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
|
// 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> {
|
private async getAnalyticsId(): Promise<string | null> {
|
||||||
const client: MatrixClient = window.matrixclient;
|
const client: MatrixClient = window.matrixclient;
|
||||||
if (widget) {
|
if (this.config.matrixBackend === "embedded") {
|
||||||
return getUrlParams().posthogUserId;
|
return this.config.hostAnalyticsId ?? null;
|
||||||
} else {
|
} else {
|
||||||
const accountData = await client.getAccountDataFromServer(
|
const accountData = await client.getAccountDataFromServer(
|
||||||
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
|
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
|
||||||
@@ -324,7 +355,7 @@ export class PosthogAnalytics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async setAccountAnalyticsId(analyticsID: string): Promise<void> {
|
private async setAccountAnalyticsId(analyticsID: string): Promise<void> {
|
||||||
if (!widget) {
|
if (this.config.matrixBackend !== "embedded") {
|
||||||
const client = window.matrixclient;
|
const client = window.matrixclient;
|
||||||
|
|
||||||
// the analytics ID only needs to be set in the standalone version.
|
// 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.
|
// These properties will be subsequently passed in every event.
|
||||||
//
|
//
|
||||||
// This only needs to be done once per page lifetime. Note that getPlatformProperties
|
// This only needs to be done once per page lifetime. Note that getPlatformProperties
|
||||||
this.platformSuperProperties = PosthogAnalytics.getPlatformProperties();
|
this.platformSuperProperties = this.getPlatformProperties();
|
||||||
this.registerSuperProperties({
|
this.registerSuperProperties({
|
||||||
...this.platformSuperProperties,
|
...this.platformSuperProperties,
|
||||||
registrationType:
|
registrationType:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
|
|||||||
import { initClient } from "../utils/matrix";
|
import { initClient } from "../utils/matrix";
|
||||||
import { type Session } from "../ClientContext";
|
import { type Session } from "../ClientContext";
|
||||||
import { Config } from "../config/Config";
|
import { Config } from "../config/Config";
|
||||||
import { widget } from "../widget";
|
import { useUrlParams } from "../UrlParams";
|
||||||
|
|
||||||
export const useInteractiveRegistration = (
|
export const useInteractiveRegistration = (
|
||||||
oldClient?: MatrixClient,
|
oldClient?: MatrixClient,
|
||||||
@@ -32,6 +32,7 @@ export const useInteractiveRegistration = (
|
|||||||
passwordlessUser: boolean,
|
passwordlessUser: boolean,
|
||||||
) => Promise<[MatrixClient, Session]>;
|
) => Promise<[MatrixClient, Session]>;
|
||||||
} => {
|
} => {
|
||||||
|
const { isWidget } = useUrlParams();
|
||||||
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
|
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
@@ -47,7 +48,7 @@ export const useInteractiveRegistration = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (widget) return;
|
if (isWidget) return;
|
||||||
// An empty registerRequest is used to get the privacy policy and recaptcha key.
|
// An empty registerRequest is used to get the privacy policy and recaptcha key.
|
||||||
authClient.current!.registerRequest({}).catch((error) => {
|
authClient.current!.registerRequest({}).catch((error) => {
|
||||||
setPrivacyPolicyUrl(
|
setPrivacyPolicyUrl(
|
||||||
@@ -55,7 +56,7 @@ export const useInteractiveRegistration = (
|
|||||||
);
|
);
|
||||||
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
|
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
|
||||||
});
|
});
|
||||||
}, []);
|
}, [isWidget]);
|
||||||
|
|
||||||
const register = useCallback(
|
const register = useCallback(
|
||||||
async (
|
async (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useClient } from "../ClientContext";
|
|||||||
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
|
||||||
import { generateRandomName } from "../auth/generateRandomName";
|
import { generateRandomName } from "../auth/generateRandomName";
|
||||||
import { useRecaptcha } from "../auth/useRecaptcha";
|
import { useRecaptcha } from "../auth/useRecaptcha";
|
||||||
import { widget } from "../widget";
|
import { useUrlParams } from "../UrlParams";
|
||||||
|
|
||||||
interface UseRegisterPasswordlessUserType {
|
interface UseRegisterPasswordlessUserType {
|
||||||
privacyPolicyUrl?: string;
|
privacyPolicyUrl?: string;
|
||||||
@@ -22,6 +22,7 @@ interface UseRegisterPasswordlessUserType {
|
|||||||
|
|
||||||
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
||||||
const { setClient } = useClient();
|
const { setClient } = useClient();
|
||||||
|
const { isWidget } = useUrlParams();
|
||||||
const { privacyPolicyUrl, recaptchaKey, register } =
|
const { privacyPolicyUrl, recaptchaKey, register } =
|
||||||
useInteractiveRegistration();
|
useInteractiveRegistration();
|
||||||
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
|
||||||
@@ -31,7 +32,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
|
|||||||
if (!setClient) {
|
if (!setClient) {
|
||||||
throw new Error("No client context");
|
throw new Error("No client context");
|
||||||
}
|
}
|
||||||
if (widget) {
|
if (isWidget) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Registration was skipped: We should never try to register password-less user in embedded mode.",
|
"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;
|
throw e;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[execute, reset, register, setClient],
|
[execute, reset, register, setClient, isWidget],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
|
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;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 420px) {
|
@container element-call (max-width: 420px) {
|
||||||
.reactionPopupMenu {
|
.reactionPopupMenu {
|
||||||
--reaction-button-padding: 8px;
|
--reaction-button-padding: 8px;
|
||||||
--reaction-button-fontsize: 16px;
|
--reaction-button-fontsize: 16px;
|
||||||
@@ -19,7 +19,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
div.reactionPopupMenuRoot.reactionPopupMenuModal {
|
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;
|
width: fit-content;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +35,7 @@ div.reactionPopupMenuRoot {
|
|||||||
|
|
||||||
.reactionPopupMenuRoot > div {
|
.reactionPopupMenuRoot > div {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
max-width: 100vw;
|
max-width: 100cqw;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.reactionPopupMenuRoot.reactionPopupMenuModal > div > div {
|
div.reactionPopupMenuRoot.reactionPopupMenuModal > div > div {
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*First hide the logo*/
|
/*First hide the logo*/
|
||||||
@media (max-width: 750px) {
|
@container element-call (max-width: 750px) {
|
||||||
.logo {
|
.logo {
|
||||||
display: none;
|
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.
|
With the logo hidden >500px is enough space to show overflow, buttons, layout.
|
||||||
Once we exceed 500 we hide everything except the buttons.
|
Once we exceed 500 we hide everything except the buttons.
|
||||||
*/
|
*/
|
||||||
@media (max-width: 500px) {
|
@container element-call (max-width: 500px) {
|
||||||
.footer {
|
.footer {
|
||||||
grid-template-areas: "buttons buttons buttons";
|
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 {
|
.footer {
|
||||||
padding-block: var(--cpd-space-8x)
|
padding-block: var(--cpd-space-8x)
|
||||||
calc(env(safe-area-inset-bottom) + 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 {
|
.footer {
|
||||||
padding-block: var(--cpd-space-4x)
|
padding-block: var(--cpd-space-4x)
|
||||||
calc(env(safe-area-inset-bottom) + 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 {
|
.shareScreen {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* PIP custom css */
|
/* PIP custom css */
|
||||||
@media (max-height: 400px) {
|
@container element-call (max-height: 400px) {
|
||||||
.shareScreen {
|
.shareScreen {
|
||||||
display: flex;
|
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 {
|
.raiseHand {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 800px) {
|
@container element-call (min-width: 800px) {
|
||||||
.buttons {
|
.buttons {
|
||||||
gap: var(--cpd-space-4x);
|
gap: var(--cpd-space-4x);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ const reactionData = {
|
|||||||
reactions$: new BehaviorSubject({}),
|
reactions$: new BehaviorSubject({}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mediaDevices = new MediaDevices(globalScope);
|
const mediaDevices = new MediaDevices(globalScope, {
|
||||||
|
controlledAudioDevices: false,
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A wrapper component that is used for:
|
* 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 { SpotlightTileViewModel } from "../state/TileViewModel";
|
||||||
import type { DeviceLabel } from "../state/MediaDevices";
|
import type { DeviceLabel } from "../state/MediaDevices";
|
||||||
import { createCallFooterViewModel } from "./CallFooterViewModel";
|
import { createCallFooterViewModel } from "./CallFooterViewModel";
|
||||||
|
import { HeaderStyle } from "../UrlParams";
|
||||||
|
|
||||||
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
|
||||||
vi.mock("../Platform", () => ({
|
vi.mock("../Platform", () => ({
|
||||||
@@ -105,6 +106,7 @@ describe("createCallFooterViewModel", () => {
|
|||||||
mockMuteStates(),
|
mockMuteStates(),
|
||||||
twoMicsAndOneCamMediaDevices,
|
twoMicsAndOneCamMediaDevices,
|
||||||
/* reactionIdentifier */ undefined,
|
/* reactionIdentifier */ undefined,
|
||||||
|
{ showControls: true, header: HeaderStyle.Standard },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(vm.audioOptions$.value).toEqual([]);
|
expect(vm.audioOptions$.value).toEqual([]);
|
||||||
@@ -126,6 +128,7 @@ describe("createCallFooterViewModel", () => {
|
|||||||
mockMuteStates(),
|
mockMuteStates(),
|
||||||
twoMicsAndOneCamMediaDevices,
|
twoMicsAndOneCamMediaDevices,
|
||||||
/* reactionIdentifier */ undefined,
|
/* reactionIdentifier */ undefined,
|
||||||
|
{ showControls: true, header: HeaderStyle.Standard },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(vm.audioOptions$?.value).toEqual([
|
expect(vm.audioOptions$?.value).toEqual([
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { type Behavior, constant } from "../state/Behavior";
|
|||||||
import type { ObservableScope } from "../state/ObservableScope";
|
import type { ObservableScope } from "../state/ObservableScope";
|
||||||
import { type MuteStates } from "../state/MuteStates";
|
import { type MuteStates } from "../state/MuteStates";
|
||||||
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
|
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
|
||||||
import { getUrlParams, HeaderStyle } from "../UrlParams";
|
import { HeaderStyle } from "../UrlParams";
|
||||||
import { platform } from "../Platform";
|
import { platform } from "../Platform";
|
||||||
import { type FooterSnapshot } from "./CallFooter";
|
import { type FooterSnapshot } from "./CallFooter";
|
||||||
|
|
||||||
@@ -138,6 +138,8 @@ function buildDeviceBehaviors(
|
|||||||
* @param mediaDevices - Available and selected input devices.
|
* @param mediaDevices - Available and selected input devices.
|
||||||
* @param reactionIdentifier - The local user's reaction identifier string, or
|
* @param reactionIdentifier - The local user's reaction identifier string, or
|
||||||
* undefined when reactions are not supported (hides the reaction button).
|
* 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(
|
export function createCallFooterViewModel(
|
||||||
scope: ObservableScope,
|
scope: ObservableScope,
|
||||||
@@ -145,8 +147,9 @@ export function createCallFooterViewModel(
|
|||||||
muteStates: MuteStates,
|
muteStates: MuteStates,
|
||||||
mediaDevices: MediaDevices,
|
mediaDevices: MediaDevices,
|
||||||
reactionIdentifier: string | undefined,
|
reactionIdentifier: string | undefined,
|
||||||
|
options: { showControls: boolean; header: HeaderStyle },
|
||||||
): ViewModel<FooterSnapshot> {
|
): ViewModel<FooterSnapshot> {
|
||||||
const { showControls, header: headerStyle } = getUrlParams();
|
const { showControls, header: headerStyle } = options;
|
||||||
const showLogo = headerStyle === HeaderStyle.Standard;
|
const showLogo = headerStyle === HeaderStyle.Standard;
|
||||||
|
|
||||||
const isPip$ = scope.behavior(
|
const isPip$ = scope.behavior(
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import { MediaDevicesContext } from "../MediaDevicesContext";
|
|||||||
import { MediaDevices } from "../state/MediaDevices";
|
import { MediaDevices } from "../state/MediaDevices";
|
||||||
import { globalScope } from "../state/ObservableScope";
|
import { globalScope } from "../state/ObservableScope";
|
||||||
|
|
||||||
const mediaDevices = new MediaDevices(globalScope);
|
const mediaDevices = new MediaDevices(globalScope, {
|
||||||
|
controlledAudioDevices: false,
|
||||||
|
});
|
||||||
|
|
||||||
const meta = {
|
const meta = {
|
||||||
component: MediaMuteAndSwitchButton,
|
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 { describe, expect, it, vi, afterEach } from "vitest";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
import { validateConfig } from "./Config";
|
import { Config, validateConfig } from "./Config";
|
||||||
import { MatrixRTCMode } from "./ConfigOptions";
|
import { DEFAULT_CONFIG, MatrixRTCMode } from "./ConfigOptions";
|
||||||
|
|
||||||
describe("validateConfig", () => {
|
describe("validateConfig", () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -52,3 +52,52 @@ describe("validateConfig", () => {
|
|||||||
expect(result.ssla).toBe("https://example.invalid/ssla");
|
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;
|
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> {
|
public static async init(): Promise<void> {
|
||||||
if (!Config.internalInstance?.initPromise) {
|
if (!Config.internalInstance?.initPromise) {
|
||||||
const internalInstance = new Config();
|
const internalInstance = new Config();
|
||||||
@@ -50,17 +58,35 @@ export class Config {
|
|||||||
|
|
||||||
Config.internalInstance.initPromise = downloadConfig(fetchTarget).then(
|
Config.internalInstance.initPromise = downloadConfig(fetchTarget).then(
|
||||||
(config) => {
|
(config) => {
|
||||||
internalInstance.config = merge(
|
internalInstance.config = resolveConfig(config);
|
||||||
{},
|
|
||||||
DEFAULT_CONFIG,
|
|
||||||
validateConfig(config),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Config.internalInstance.initPromise;
|
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
|
* This is a alternative initializer that does not load anything
|
||||||
* from a hosted config file but instead just initializes the config using the
|
* 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`)
|
* It is supposed to only be used in tests. (It is executed in `vite.setup.js`)
|
||||||
*/
|
*/
|
||||||
public static initDefault(): void {
|
public static initDefault(): void {
|
||||||
Config.internalInstance = new Config();
|
Config.initWith({});
|
||||||
Config.internalInstance.config = { ...DEFAULT_CONFIG };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convenience accessors
|
// Convenience accessors
|
||||||
@@ -94,6 +119,15 @@ export class Config {
|
|||||||
private initPromise?: Promise<void>;
|
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 {
|
export function validateConfig(config: ConfigOptions): ConfigOptions {
|
||||||
const mode = config.matrix_rtc_mode;
|
const mode = config.matrix_rtc_mode;
|
||||||
if (mode !== undefined && !VALID_MATRIX_RTC_MODES.has(mode)) {
|
if (mode !== undefined && !VALID_MATRIX_RTC_MODES.has(mode)) {
|
||||||
|
|||||||
@@ -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,
|
setLocalStorageItemReactive,
|
||||||
useLocalStorage,
|
useLocalStorage,
|
||||||
} from "../useLocalStorage";
|
} from "../useLocalStorage";
|
||||||
import { getUrlParams } from "../UrlParams";
|
import { getUrlParams, useUrlParams } from "../UrlParams";
|
||||||
import { E2eeType } from "./e2eeType";
|
import { E2eeType } from "./e2eeType";
|
||||||
import { useClient } from "../ClientContext";
|
import { useClient } from "../ClientContext";
|
||||||
|
|
||||||
@@ -57,19 +57,31 @@ const useRoomSharedKey = (
|
|||||||
return [setInitialValue ?? roomSharedKey, setRoomSharedKey];
|
return [setInitialValue ?? roomSharedKey, setRoomSharedKey];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getKeyForRoom(roomId: string): string | null {
|
/**
|
||||||
const { roomId: urlRoomId, password } = getUrlParams();
|
* The shared key for a room, preferring one supplied in the parameters Element
|
||||||
if (roomId !== urlRoomId)
|
* 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(
|
logger.warn(
|
||||||
"requested key for a roomId which is not the current call room id (from the URL)",
|
"requested key for a roomId which is not the current call room id (from the URL)",
|
||||||
roomId,
|
roomId,
|
||||||
urlRoomId,
|
paramsRoomId,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
password ?? localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId))
|
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 Unencrypted = { kind: E2eeType.NONE };
|
||||||
export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
|
export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
|
||||||
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
|
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
|
||||||
@@ -77,10 +89,15 @@ export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
|
|||||||
|
|
||||||
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
|
||||||
const { client } = useClient();
|
const { client } = useClient();
|
||||||
|
const { roomId: paramsRoomId, password } = useUrlParams();
|
||||||
|
|
||||||
const [storedPassword] = useRoomSharedKey(
|
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),
|
getRoomSharedKeyLocalStorageKey(roomId),
|
||||||
getKeyForRoom(roomId) ?? undefined,
|
keyForRoom(roomId, paramsRoomId, password) ?? undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const room = client?.getRoom(roomId);
|
const room = client?.getRoom(roomId);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
block-size: 140px;
|
block-size: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@container element-call (max-width: 600px) {
|
||||||
/* Give the PiP a portrait aspect ratio */
|
/* Give the PiP a portrait aspect ratio */
|
||||||
.pip[data-size="sm"] {
|
.pip[data-size="sm"] {
|
||||||
inline-size: 88px;
|
inline-size: 88px;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
var(--content-inset-left);
|
var(--content-inset-left);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 600px) {
|
@container element-call (min-width: 600px) {
|
||||||
.pip {
|
.pip {
|
||||||
inline-size: 180px;
|
inline-size: 180px;
|
||||||
block-size: 135px;
|
block-size: 135px;
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { ErrorPage, LoadingPage } from "../FullScreenView";
|
|||||||
import { UnauthenticatedView } from "./UnauthenticatedView";
|
import { UnauthenticatedView } from "./UnauthenticatedView";
|
||||||
import { RegisteredView } from "./RegisteredView";
|
import { RegisteredView } from "./RegisteredView";
|
||||||
import { usePageTitle } from "../usePageTitle";
|
import { usePageTitle } from "../usePageTitle";
|
||||||
import { widget } from "../widget.ts";
|
|
||||||
|
|
||||||
export const HomePage: FC = () => {
|
export const HomePage: FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -24,7 +23,7 @@ export const HomePage: FC = () => {
|
|||||||
if (!clientState) {
|
if (!clientState) {
|
||||||
return <LoadingPage />;
|
return <LoadingPage />;
|
||||||
} else if (clientState.state === "error") {
|
} else if (clientState.state === "error") {
|
||||||
return <ErrorPage widget={widget} error={clientState.error} />;
|
return <ErrorPage error={clientState.error} />;
|
||||||
} else {
|
} else {
|
||||||
return clientState.authenticated ? (
|
return clientState.authenticated ? (
|
||||||
<RegisteredView client={clientState.authenticated.client} />
|
<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.
|
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("./base.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: var(--cpd-color-bg-canvas-default);
|
background-color: var(--cpd-color-bg-canvas-default);
|
||||||
@@ -74,39 +21,16 @@ body {
|
|||||||
-webkit-tap-highlight-color: transparent;
|
-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
|
/* This prohibits the view to scroll for pages smaller than 122px in width
|
||||||
we use this for mobile pip webviews */
|
we use this for mobile pip webviews. Element Call adds this class to whatever
|
||||||
.no-scroll-body {
|
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;
|
position: fixed;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* We use this to not render the page at all until we know the theme.*/
|
|
||||||
.no-theme {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
@@ -123,104 +47,7 @@ body,
|
|||||||
isolation: isolate;
|
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] {
|
#root > [data-overlay-container] {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
+46
-21
@@ -6,12 +6,11 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import i18n, {
|
import {
|
||||||
type BackendModule,
|
type BackendModule,
|
||||||
type ReadCallback,
|
type ReadCallback,
|
||||||
type ResourceKey,
|
type ResourceKey,
|
||||||
} from "i18next";
|
} from "i18next";
|
||||||
import { initReactI18next } from "react-i18next";
|
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
import * as Sentry from "@sentry/react";
|
import * as Sentry from "@sentry/react";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
@@ -31,10 +30,14 @@ import {
|
|||||||
import { getUrlParams } from "./UrlParams";
|
import { getUrlParams } from "./UrlParams";
|
||||||
import { Config } from "./config/Config";
|
import { Config } from "./config/Config";
|
||||||
import { seedSettingsFromConfig } from "./settings/settings";
|
import { seedSettingsFromConfig } from "./settings/settings";
|
||||||
import { platform } from "./Platform";
|
|
||||||
import { isFailure } from "./utils/fetch";
|
import { isFailure } from "./utils/fetch";
|
||||||
import { initializeWidget } from "./widget";
|
import { initializeWidget, type WidgetHelpers } from "./widget";
|
||||||
import { enableExtendedLivekitLogs } from "./settings/settings.ts";
|
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:
|
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
|
||||||
// {
|
// {
|
||||||
@@ -53,17 +56,7 @@ const getLocaleUrl = (
|
|||||||
): string | undefined => locales[`../locales/${language}/${namespace}.json`];
|
): string | undefined => locales[`../locales/${language}/${namespace}.json`];
|
||||||
|
|
||||||
const supportedLngs = [
|
const supportedLngs = [
|
||||||
...new Set(
|
...new Set(Object.keys(locales).map(languageOfLocalePath)),
|
||||||
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;
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// A backend that fetches the locale files from the URLs generated by the glob above
|
// A backend that fetches the locale files from the URLs generated by the glob above
|
||||||
@@ -98,6 +91,36 @@ const Backend = {
|
|||||||
},
|
},
|
||||||
} satisfies BackendModule;
|
} 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 {
|
enum LoadState {
|
||||||
None,
|
None,
|
||||||
Loading,
|
Loading,
|
||||||
@@ -121,8 +144,8 @@ export class Initializer {
|
|||||||
return !!Initializer.internalInstance?.isInitialized;
|
return !!Initializer.internalInstance?.isInitialized;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async initBeforeReact(): Promise<void> {
|
public static async initBeforeReact(): Promise<WidgetHelpers | null> {
|
||||||
initializeWidget();
|
const widget = initializeWidget();
|
||||||
|
|
||||||
const polyfills: Promise<unknown>[] = [];
|
const polyfills: Promise<unknown>[] = [];
|
||||||
if (shouldPolyfillSegmenter()) {
|
if (shouldPolyfillSegmenter()) {
|
||||||
@@ -148,10 +171,12 @@ export class Initializer {
|
|||||||
document.documentElement.lang = lng;
|
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
|
await i18n
|
||||||
.use(Backend)
|
.use(Backend)
|
||||||
.use(languageDetector)
|
.use(languageDetector)
|
||||||
.use(initReactI18next)
|
|
||||||
.init({
|
.init({
|
||||||
fallbackLng: "en",
|
fallbackLng: "en",
|
||||||
defaultNS: "app",
|
defaultNS: "app",
|
||||||
@@ -193,9 +218,6 @@ export class Initializer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the platform to the DOM, so CSS can query it
|
|
||||||
document.body.setAttribute("data-platform", platform);
|
|
||||||
|
|
||||||
// livekit logging configuration
|
// livekit logging configuration
|
||||||
setLKLogExtension((level, msg, context) => {
|
setLKLogExtension((level, msg, context) => {
|
||||||
// we pass a synthetic logger name of "livekit" to the rageshake to make it easier to read
|
// we pass a synthetic logger name of "livekit" to the rageshake to make it easier to read
|
||||||
@@ -207,6 +229,8 @@ export class Initializer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.setLKLogLevel = setLKLogLevel;
|
window.setLKLogLevel = setLKLogLevel;
|
||||||
|
|
||||||
|
return widget;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static init(): Promise<void> | null {
|
public static init(): Promise<void> | null {
|
||||||
@@ -239,6 +263,7 @@ export class Initializer {
|
|||||||
Config.init().then(
|
Config.init().then(
|
||||||
() => {
|
() => {
|
||||||
seedSettingsFromConfig(Config.get().media_quality);
|
seedSettingsFromConfig(Config.get().media_quality);
|
||||||
|
PosthogAnalytics.configure(analyticsConfigFromEnvironment());
|
||||||
this.loadStates.config = LoadState.Loaded;
|
this.loadStates.config = LoadState.Loaded;
|
||||||
this.initStep(resolve);
|
this.initStep(resolve);
|
||||||
},
|
},
|
||||||
|
|||||||
+12
-2
@@ -21,6 +21,7 @@ import { init as initRageshake } from "./settings/rageshake";
|
|||||||
import { Initializer } from "./initializer";
|
import { Initializer } from "./initializer";
|
||||||
import { AppViewModel } from "./state/AppViewModel";
|
import { AppViewModel } from "./state/AppViewModel";
|
||||||
import { globalScope } from "./state/ObservableScope";
|
import { globalScope } from "./state/ObservableScope";
|
||||||
|
import { getUrlParams } from "./UrlParams";
|
||||||
|
|
||||||
initRageshake().catch((e) => {
|
initRageshake().catch((e) => {
|
||||||
logger.error("Failed to initialize rageshake", e);
|
logger.error("Failed to initialize rageshake", e);
|
||||||
@@ -48,10 +49,19 @@ if (fatalError !== null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Initializer.initBeforeReact()
|
Initializer.initBeforeReact()
|
||||||
.then(() => {
|
.then((widget) => {
|
||||||
|
const { controlledAudioDevices, callIntent } = getUrlParams();
|
||||||
root.render(
|
root.render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App vm={new AppViewModel(globalScope)} />
|
<App
|
||||||
|
vm={
|
||||||
|
new AppViewModel(globalScope, {
|
||||||
|
controlledAudioDevices,
|
||||||
|
callIntent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
widget={widget}
|
||||||
|
/>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
margin-bottom: 44px;
|
margin-bottom: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 800px) {
|
@container element-call (min-width: 800px) {
|
||||||
.logo {
|
.logo {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { type FC, type FormEventHandler, useCallback, useState } from "react";
|
|||||||
import { type MatrixClient } from "matrix-js-sdk";
|
import { type MatrixClient } from "matrix-js-sdk";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { Button, Heading, Text } from "@vector-im/compound-web";
|
import { Button, Heading, Text } from "@vector-im/compound-web";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
|
||||||
|
|
||||||
import styles from "./CallEndedView.module.css";
|
import styles from "./CallEndedView.module.css";
|
||||||
import feedbackStyle from "../input/FeedbackInput.module.css";
|
import feedbackStyle from "../input/FeedbackInput.module.css";
|
||||||
@@ -19,8 +17,9 @@ import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
|
|||||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||||
import { FieldRow, InputField } from "../input/Input";
|
import { FieldRow, InputField } from "../input/Input";
|
||||||
import { StarRatingInput } from "../input/StarRatingInput";
|
import { StarRatingInput } from "../input/StarRatingInput";
|
||||||
import { Link } from "../button/Link";
|
|
||||||
import { LinkButton } from "../button";
|
import { LinkButton } from "../button";
|
||||||
|
import { LeaveToHomeLink } from "../button/LeaveToHomeLink";
|
||||||
|
import { useLeaveToHome } from "../LeaveToHomeContext";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
@@ -38,7 +37,7 @@ export const CallEndedView: FC<Props> = ({
|
|||||||
endedCallId,
|
endedCallId,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const leaveToHome = useLeaveToHome();
|
||||||
|
|
||||||
const { displayName } = useProfile(client);
|
const { displayName } = useProfile(client);
|
||||||
const [surveySubmitted, setSurveySubmitted] = useState(false);
|
const [surveySubmitted, setSurveySubmitted] = useState(false);
|
||||||
@@ -68,14 +67,12 @@ export const CallEndedView: FC<Props> = ({
|
|||||||
setSurveySubmitted(true);
|
setSurveySubmitted(true);
|
||||||
} else if (!confineToRoom) {
|
} else if (!confineToRoom) {
|
||||||
// if the user already has an account immediately go back to the home screen
|
// if the user already has an account immediately go back to the home screen
|
||||||
navigate("/")?.catch((error) => {
|
leaveToHome?.();
|
||||||
logger.error("Failed to navigate to /", error);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
},
|
},
|
||||||
[endedCallId, navigate, isPasswordlessUser, confineToRoom, starRating],
|
[endedCallId, leaveToHome, isPasswordlessUser, confineToRoom, starRating],
|
||||||
);
|
);
|
||||||
|
|
||||||
const createAccountDialog = isPasswordlessUser && (
|
const createAccountDialog = isPasswordlessUser && (
|
||||||
@@ -87,6 +84,8 @@ export const CallEndedView: FC<Props> = ({
|
|||||||
calls
|
calls
|
||||||
</p>
|
</p>
|
||||||
</Trans>
|
</Trans>
|
||||||
|
{/* Only guests of the standalone app are ever passwordless, so this
|
||||||
|
route is always the standalone app's own. */}
|
||||||
<LinkButton className={styles.callEndedButton} to="/register">
|
<LinkButton className={styles.callEndedButton} to="/register">
|
||||||
{t("call_ended_view.create_account_button")}
|
{t("call_ended_view.create_account_button")}
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
@@ -157,7 +156,10 @@ export const CallEndedView: FC<Props> = ({
|
|||||||
</main>
|
</main>
|
||||||
{!confineToRoom && (
|
{!confineToRoom && (
|
||||||
<Text className={styles.footer}>
|
<Text className={styles.footer}>
|
||||||
<Link to="/"> {t("call_ended_view.not_now_button")} </Link>
|
<LeaveToHomeLink>
|
||||||
|
{" "}
|
||||||
|
{t("call_ended_view.not_now_button")}{" "}
|
||||||
|
</LeaveToHomeLink>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// TODO-MULTI-SFU: Restore or discard these tests. The role of GroupCallView has
|
// TODO-MULTI-SFU: Restore or discard these tests. The role of CallView has
|
||||||
// changed (it no longer manages the connection to the same extent), so they may
|
// changed (it no longer manages the connection to the same extent), so they may
|
||||||
// need extra work to adapt.
|
// need extra work to adapt.
|
||||||
|
|
||||||
@@ -36,7 +36,8 @@ import userEvent, {
|
|||||||
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
import { type ITransport } from "matrix-widget-api";
|
import { Subject } from "rxjs";
|
||||||
|
import { Room as LivekitRoom } from "livekit-client";
|
||||||
|
|
||||||
import { prefetchSounds } from "../soundUtils";
|
import { prefetchSounds } from "../soundUtils";
|
||||||
import { useAudioContext } from "../useAudioContext";
|
import { useAudioContext } from "../useAudioContext";
|
||||||
@@ -50,15 +51,19 @@ import {
|
|||||||
mockRtcMembership,
|
mockRtcMembership,
|
||||||
MockRTCSession,
|
MockRTCSession,
|
||||||
} from "../utils/test";
|
} from "../utils/test";
|
||||||
import { GroupCallView } from "./GroupCallView";
|
import { CallView } from "./CallView";
|
||||||
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
|
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
|
||||||
import { ElementWidgetActions, type WidgetHelpers } from "../widget";
|
import {
|
||||||
import { LazyEventEmitter } from "../LazyEventEmitter";
|
type HostBridge,
|
||||||
|
HostBridgeProvider,
|
||||||
|
type HostRequest,
|
||||||
|
nullHostBridge,
|
||||||
|
} from "../HostBridge";
|
||||||
|
import { type JoinCallData } from "../widget";
|
||||||
import { MatrixRTCTransportMissingError } from "../utils/errors";
|
import { MatrixRTCTransportMissingError } from "../utils/errors";
|
||||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||||
import { MediaDevicesContext } from "../MediaDevicesContext";
|
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||||
import { constant } from "../state/Behavior";
|
import { constant } from "../state/Behavior";
|
||||||
import { type MuteStates } from "../state/MuteStates.ts";
|
|
||||||
|
|
||||||
vi.mock("../soundUtils");
|
vi.mock("../soundUtils");
|
||||||
vi.mock("../useAudioContext");
|
vi.mock("../useAudioContext");
|
||||||
@@ -117,7 +122,7 @@ beforeEach(() => {
|
|||||||
playSoundLooping: vi.fn(),
|
playSoundLooping: vi.fn(),
|
||||||
soundDuration: {},
|
soundDuration: {},
|
||||||
});
|
});
|
||||||
// A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here.
|
// A trivial implementation of Active call to ensure we are testing CallView exclusively here.
|
||||||
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(
|
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(
|
||||||
({ onLeft: onLeave }) => {
|
({ onLeft: onLeave }) => {
|
||||||
return (
|
return (
|
||||||
@@ -133,11 +138,13 @@ beforeEach(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function createGroupCallView(
|
function createCallView(
|
||||||
widget: WidgetHelpers | null,
|
hostBridge: HostBridge,
|
||||||
joined = true,
|
joined = true,
|
||||||
options: {
|
options: {
|
||||||
withErrorBoundary?: boolean;
|
withErrorBoundary?: boolean;
|
||||||
|
/** Wait for the host to say when to join, rather than joining at once. */
|
||||||
|
preload?: boolean;
|
||||||
} = {},
|
} = {},
|
||||||
): {
|
): {
|
||||||
rtcSession: MatrixRTCSession;
|
rtcSession: MatrixRTCSession;
|
||||||
@@ -170,44 +177,34 @@ function createGroupCallView(
|
|||||||
constant([localRtcMember]),
|
constant([localRtcMember]),
|
||||||
);
|
);
|
||||||
rtcSession.joined = joined;
|
rtcSession.joined = joined;
|
||||||
const muteState = {
|
const callView = (
|
||||||
audio: { enabled: false },
|
<CallView
|
||||||
video: { enabled: false },
|
|
||||||
// TODO-MULTI-SFU: This cast isn't valid, it's likely the cause of some current test failures
|
|
||||||
} as unknown as MuteStates;
|
|
||||||
const groupCallView = (
|
|
||||||
<GroupCallView
|
|
||||||
client={client}
|
client={client}
|
||||||
isPasswordlessUser={false}
|
isPasswordlessUser={false}
|
||||||
confineToRoom={false}
|
confineToRoom={false}
|
||||||
preload={false}
|
preload={options.preload ?? false}
|
||||||
skipLobby={false}
|
// Straight into the (mocked) call, past the lobby
|
||||||
|
skipLobby
|
||||||
rtcSession={rtcSession.asMockedSession()}
|
rtcSession={rtcSession.asMockedSession()}
|
||||||
muteStates={muteState}
|
|
||||||
widget={widget}
|
|
||||||
// TODO-MULTI-SFU: Make joined and setJoined work
|
|
||||||
joined={true}
|
|
||||||
setJoined={function (value: boolean): void {}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
const { getByText } = render(
|
const { getByText } = render(
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<TooltipProvider>
|
<HostBridgeProvider value={hostBridge}>
|
||||||
<MediaDevicesContext value={mockMediaDevices({})}>
|
<TooltipProvider>
|
||||||
<ProcessorProvider>
|
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||||
{options.withErrorBoundary ? (
|
<ProcessorProvider>
|
||||||
<GroupCallErrorBoundary
|
{options.withErrorBoundary ? (
|
||||||
recoveryActionHandler={vi.fn()}
|
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
|
||||||
widget={null}
|
{callView}
|
||||||
>
|
</GroupCallErrorBoundary>
|
||||||
{groupCallView}
|
) : (
|
||||||
</GroupCallErrorBoundary>
|
callView
|
||||||
) : (
|
)}
|
||||||
groupCallView
|
</ProcessorProvider>
|
||||||
)}
|
</MediaDevicesContext>
|
||||||
</ProcessorProvider>
|
</TooltipProvider>
|
||||||
</MediaDevicesContext>
|
</HostBridgeProvider>
|
||||||
</TooltipProvider>
|
|
||||||
</BrowserRouter>,
|
</BrowserRouter>,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -216,9 +213,9 @@ function createGroupCallView(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async () => {
|
test.skip("CallView plays a leave sound asynchronously in SPA mode", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { getByText, rtcSession } = createGroupCallView(null);
|
const { getByText, rtcSession } = createCallView(nullHostBridge);
|
||||||
const leaveButton = getByText("Leave");
|
const leaveButton = getByText("Leave");
|
||||||
await user.click(leaveButton);
|
await user.click(leaveButton);
|
||||||
expect(playSound).toHaveBeenCalledWith("left");
|
expect(playSound).toHaveBeenCalledWith("left");
|
||||||
@@ -233,14 +230,9 @@ test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async
|
|||||||
await waitFor(() => expect(leaveRTCSession).toHaveResolved());
|
await waitFor(() => expect(leaveRTCSession).toHaveResolved());
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip("GroupCallView plays a leave sound synchronously in widget mode", async () => {
|
test.skip("CallView plays a leave sound synchronously in widget mode", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const widget = {
|
const hostBridge: HostBridge = { ...nullHostBridge, close: vi.fn() };
|
||||||
api: {
|
|
||||||
setAlwaysOnScreen: async () => Promise.resolve(true),
|
|
||||||
} as Partial<WidgetHelpers["api"]>,
|
|
||||||
lazyActions: new LazyEventEmitter(),
|
|
||||||
};
|
|
||||||
let resolvePlaySound: () => void;
|
let resolvePlaySound: () => void;
|
||||||
playSound = vi
|
playSound = vi
|
||||||
.fn()
|
.fn()
|
||||||
@@ -253,9 +245,7 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
|
|||||||
soundDuration: {},
|
soundDuration: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { getByText, rtcSession } = createGroupCallView(
|
const { getByText, rtcSession } = createCallView(hostBridge);
|
||||||
widget as WidgetHelpers,
|
|
||||||
);
|
|
||||||
const leaveButton = getByText("Leave");
|
const leaveButton = getByText("Leave");
|
||||||
await user.click(leaveButton);
|
await user.click(leaveButton);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
@@ -272,28 +262,13 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
|
|||||||
expect(leaveRTCSession).toHaveBeenCalledOnce();
|
expect(leaveRTCSession).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Should close widget when all other left and play a sound", async () => {
|
test("Should ask the host to close when all other left and play a sound", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let widgetClosedCalled = false;
|
const close = vi.fn().mockResolvedValue(undefined);
|
||||||
const { promise: widgetClosedPromise, resolve: widgetClosedResolver } =
|
const hostBridge: HostBridge = {
|
||||||
Promise.withResolvers<void>();
|
...nullHostBridge,
|
||||||
const widgetSendMock = vi.fn().mockImplementation((action: string) => {
|
setAlwaysOnScreen: vi.fn().mockResolvedValue(undefined),
|
||||||
if (action === ElementWidgetActions.Close) {
|
close,
|
||||||
widgetClosedCalled = true;
|
|
||||||
widgetClosedResolver();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
|
|
||||||
const widget = {
|
|
||||||
api: {
|
|
||||||
setAlwaysOnScreen: vi.fn().mockResolvedValue(true),
|
|
||||||
transport: {
|
|
||||||
send: widgetSendMock,
|
|
||||||
reply: vi.fn().mockResolvedValue(undefined),
|
|
||||||
stop: widgetStopMock,
|
|
||||||
} as unknown as ITransport,
|
|
||||||
} as Partial<WidgetHelpers["api"]>,
|
|
||||||
lazyActions: new LazyEventEmitter(),
|
|
||||||
};
|
};
|
||||||
const resolvePlaySound = Promise.withResolvers<void>();
|
const resolvePlaySound = Promise.withResolvers<void>();
|
||||||
playSound = vi.fn().mockReturnValue(resolvePlaySound.promise);
|
playSound = vi.fn().mockReturnValue(resolvePlaySound.promise);
|
||||||
@@ -303,53 +278,61 @@ test("Should close widget when all other left and play a sound", async () => {
|
|||||||
soundDuration: {},
|
soundDuration: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { getByText } = createGroupCallView(widget as WidgetHelpers);
|
const { getByText } = createCallView(hostBridge);
|
||||||
const leaveButton = getByText("SimulateOtherLeft");
|
const leaveButton = getByText("SimulateOtherLeft");
|
||||||
await user.click(leaveButton);
|
await user.click(leaveButton);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
expect(widgetClosedCalled).toBeFalsy();
|
expect(close).not.toHaveBeenCalled();
|
||||||
resolvePlaySound.resolve();
|
resolvePlaySound.resolve();
|
||||||
|
|
||||||
expect(playSound).toHaveBeenCalledWith("left", 0);
|
expect(playSound).toHaveBeenCalledWith("left", 0);
|
||||||
await widgetClosedPromise;
|
await waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||||
await flushPromises();
|
|
||||||
expect(widgetClosedCalled).toBeTruthy();
|
|
||||||
expect(widgetStopMock).toHaveBeenCalledOnce();
|
|
||||||
}, 80000);
|
}, 80000);
|
||||||
|
|
||||||
test("Should not close widget when auto leave due to error", async () => {
|
test("Waits for the host to say when to join, when preloaded", async () => {
|
||||||
|
// Nothing to match device names against; the host names none anyway
|
||||||
|
vi.spyOn(LivekitRoom, "getLocalDevices").mockResolvedValue([]);
|
||||||
|
const join$ = new Subject<HostRequest<JoinCallData>>();
|
||||||
|
const hostBridge: HostBridge = { ...nullHostBridge, join$ };
|
||||||
|
|
||||||
|
createCallView(hostBridge, false, { preload: true });
|
||||||
|
await flushPromises();
|
||||||
|
// Past the lobby, but not in the call: the host has not asked yet
|
||||||
|
expect(screen.queryByText("Leave")).toBeNull();
|
||||||
|
|
||||||
|
const reply = vi.fn();
|
||||||
|
act(() =>
|
||||||
|
join$.next({ data: { audioInput: null, videoInput: null }, reply }),
|
||||||
|
);
|
||||||
|
// Then in the call, and the host told so
|
||||||
|
await waitFor(() => expect(reply).toHaveBeenCalledOnce());
|
||||||
|
expect(screen.getByText("Leave")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Should not ask the host to close when auto leave due to error", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
|
|
||||||
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
|
const close = vi.fn().mockResolvedValue(undefined);
|
||||||
const widgetSendMock = vi.fn().mockResolvedValue(undefined);
|
const setAlwaysOnScreen = vi.fn().mockResolvedValue(undefined);
|
||||||
const widget = {
|
const hostBridge: HostBridge = {
|
||||||
api: {
|
...nullHostBridge,
|
||||||
setAlwaysOnScreen: vi.fn().mockResolvedValue(true),
|
setAlwaysOnScreen,
|
||||||
transport: {
|
close,
|
||||||
send: widgetSendMock,
|
|
||||||
reply: vi.fn().mockResolvedValue(undefined),
|
|
||||||
stop: widgetStopMock,
|
|
||||||
} as unknown as ITransport,
|
|
||||||
} as Partial<WidgetHelpers["api"]>,
|
|
||||||
lazyActions: new LazyEventEmitter(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const alwaysOnScreenSpy = vi.spyOn(widget.api, "setAlwaysOnScreen");
|
const { getByText } = createCallView(hostBridge);
|
||||||
|
|
||||||
const { getByText } = createGroupCallView(widget as WidgetHelpers);
|
|
||||||
const leaveButton = getByText("SimulateErrorLeft");
|
const leaveButton = getByText("SimulateErrorLeft");
|
||||||
await user.click(leaveButton);
|
await user.click(leaveButton);
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
|
|
||||||
// When onLeft is called, we first set always on screen to false
|
// When onLeft is called, we first set always on screen to false
|
||||||
await waitFor(() => expect(alwaysOnScreenSpy).toHaveBeenCalledWith(false));
|
await waitFor(() => expect(setAlwaysOnScreen).toHaveBeenCalledWith(false));
|
||||||
await flushPromises();
|
await flushPromises();
|
||||||
// But then we do not close the widget automatically
|
// But then we do not ask to be closed automatically
|
||||||
expect(widgetStopMock).not.toHaveBeenCalledOnce();
|
expect(close).not.toHaveBeenCalled();
|
||||||
expect(widgetSendMock).not.toHaveBeenCalledOnce();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip("GroupCallView leaves the session when an error occurs", async () => {
|
test.skip("CallView leaves the session when an error occurs", async () => {
|
||||||
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(() => {
|
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(() => {
|
||||||
const [error, setError] = useState<Error | null>(null);
|
const [error, setError] = useState<Error | null>(null);
|
||||||
if (error !== null) throw error;
|
if (error !== null) throw error;
|
||||||
@@ -360,7 +343,7 @@ test.skip("GroupCallView leaves the session when an error occurs", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { rtcSession } = createGroupCallView(null);
|
const { rtcSession } = createCallView(nullHostBridge);
|
||||||
await user.click(screen.getByRole("button", { name: "Panic!" }));
|
await user.click(screen.getByRole("button", { name: "Panic!" }));
|
||||||
screen.getByText("Something went wrong");
|
screen.getByText("Something went wrong");
|
||||||
expect(leaveRTCSession).toHaveBeenCalledWith(
|
expect(leaveRTCSession).toHaveBeenCalledWith(
|
||||||
@@ -370,14 +353,14 @@ test.skip("GroupCallView leaves the session when an error occurs", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip("GroupCallView shows errors that occur during joining", async () => {
|
test.skip("CallView shows errors that occur during joining", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
// This should not mock this error that deep. it should only mock the CallViewModel.
|
// This should not mock this error that deep. it should only mock the CallViewModel.
|
||||||
enterRTCSession.mockRejectedValue(new MatrixRTCTransportMissingError(""));
|
enterRTCSession.mockRejectedValue(new MatrixRTCTransportMissingError(""));
|
||||||
onTestFinished(() => {
|
onTestFinished(() => {
|
||||||
enterRTCSession.mockReset();
|
enterRTCSession.mockReset();
|
||||||
});
|
});
|
||||||
createGroupCallView(null, false);
|
createCallView(nullHostBridge, false);
|
||||||
await user.click(screen.getByRole("button", { name: "Join call" }));
|
await user.click(screen.getByRole("button", { name: "Join call" }));
|
||||||
screen.getByText("Call is not supported");
|
screen.getByText("Call is not supported");
|
||||||
});
|
});
|
||||||
@@ -396,7 +379,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent
|
|||||||
{ cause: stickyError },
|
{ cause: stickyError },
|
||||||
);
|
);
|
||||||
|
|
||||||
const { rtcSession } = createGroupCallView(null, true, {
|
const { rtcSession } = createCallView(nullHostBridge, true, {
|
||||||
withErrorBoundary: true,
|
withErrorBoundary: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -408,7 +391,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("falls back to ConnectionLostError for unrecognised membership manager errors", async () => {
|
test("falls back to ConnectionLostError for unrecognised membership manager errors", async () => {
|
||||||
const { rtcSession } = createGroupCallView(null, true, {
|
const { rtcSession } = createCallView(nullHostBridge, true, {
|
||||||
withErrorBoundary: true,
|
withErrorBoundary: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -424,7 +407,7 @@ test("falls back to ConnectionLostError for unrecognised membership manager erro
|
|||||||
|
|
||||||
test("user can reconnect after a membership manager error", async () => {
|
test("user can reconnect after a membership manager error", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { rtcSession } = createGroupCallView(null, true);
|
const { rtcSession } = createCallView(nullHostBridge, true);
|
||||||
await act(() =>
|
await act(() =>
|
||||||
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined),
|
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined),
|
||||||
);
|
);
|
||||||
@@ -28,14 +28,8 @@ import {
|
|||||||
MatrixRTCSessionEvent,
|
MatrixRTCSessionEvent,
|
||||||
type MatrixRTCSession,
|
type MatrixRTCSession,
|
||||||
} from "matrix-js-sdk/lib/matrixrtc";
|
} from "matrix-js-sdk/lib/matrixrtc";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import type { IWidgetApiRequest } from "matrix-widget-api";
|
import { type JoinCallData } from "../widget";
|
||||||
import {
|
|
||||||
ElementWidgetActions,
|
|
||||||
type JoinCallData,
|
|
||||||
type WidgetHelpers,
|
|
||||||
} from "../widget";
|
|
||||||
import { LobbyView } from "./LobbyView";
|
import { LobbyView } from "./LobbyView";
|
||||||
import { type MatrixInfo } from "./VideoPreview";
|
import { type MatrixInfo } from "./VideoPreview";
|
||||||
import { CallEndedView } from "./CallEndedView";
|
import { CallEndedView } from "./CallEndedView";
|
||||||
@@ -54,12 +48,7 @@ import { useRoomAvatar } from "./useRoomAvatar";
|
|||||||
import { useRoomName } from "./useRoomName";
|
import { useRoomName } from "./useRoomName";
|
||||||
import { useJoinRule } from "./useJoinRule";
|
import { useJoinRule } from "./useJoinRule";
|
||||||
import { InviteModal } from "./InviteModal";
|
import { InviteModal } from "./InviteModal";
|
||||||
import {
|
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams";
|
||||||
getUrlParams,
|
|
||||||
HeaderStyle,
|
|
||||||
type UrlParams,
|
|
||||||
useUrlParams,
|
|
||||||
} from "../UrlParams";
|
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
import { useAudioContext } from "../useAudioContext";
|
import { useAudioContext } from "../useAudioContext";
|
||||||
import {
|
import {
|
||||||
@@ -67,7 +56,6 @@ import {
|
|||||||
type CallEventSounds,
|
type CallEventSounds,
|
||||||
} from "./CallEventAudioRenderer";
|
} from "./CallEventAudioRenderer";
|
||||||
import { useLatest } from "../useLatest";
|
import { useLatest } from "../useLatest";
|
||||||
import { usePageTitle } from "../usePageTitle";
|
|
||||||
import {
|
import {
|
||||||
ConnectionLostError,
|
ConnectionLostError,
|
||||||
E2EENotSupportedError,
|
E2EENotSupportedError,
|
||||||
@@ -80,6 +68,10 @@ import { useTypedEventEmitter } from "../useEvents";
|
|||||||
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
|
||||||
import { useAppBarTitle } from "../AppBar.tsx";
|
import { useAppBarTitle } from "../AppBar.tsx";
|
||||||
import { useBehavior } from "../useBehavior.ts";
|
import { useBehavior } from "../useBehavior.ts";
|
||||||
|
import { useRootElement } from "../RootElementContext.ts";
|
||||||
|
import { useHostBridge } from "../HostBridge.ts";
|
||||||
|
import { useMuteStates } from "../state/useMuteStates.ts";
|
||||||
|
import { useLeaveToHome } from "../LeaveToHomeContext.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If there already are this many participants in the call, we automatically mute
|
* If there already are this many participants in the call, we automatically mute
|
||||||
@@ -94,19 +86,67 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/** The client to place the call with. */
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
isPasswordlessUser: boolean;
|
/** The call to join. */
|
||||||
confineToRoom: boolean;
|
|
||||||
preload: UrlParams["preload"];
|
|
||||||
skipLobby: UrlParams["skipLobby"];
|
|
||||||
rtcSession: MatrixRTCSession;
|
rtcSession: MatrixRTCSession;
|
||||||
|
/**
|
||||||
|
* Whether the user is signed in as a guest, and so should be offered the
|
||||||
|
* chance to create an account when the call ends.
|
||||||
|
*/
|
||||||
|
isPasswordlessUser: boolean;
|
||||||
|
/** Whether to keep the user in this call rather than letting them navigate. */
|
||||||
|
confineToRoom: boolean;
|
||||||
|
/** Whether to wait for the host to ask us to join. */
|
||||||
|
preload: UrlParams["preload"];
|
||||||
|
/** Whether to enter the call directly, without showing the lobby first. */
|
||||||
|
skipLobby: UrlParams["skipLobby"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A call, from start to finish.
|
||||||
|
*
|
||||||
|
* This owns the whole lifecycle of being in a call: the lobby, where the user
|
||||||
|
* checks their camera and microphone before joining; the call itself; and the
|
||||||
|
* screen shown once it has ended. Not every call has every stage — the lobby
|
||||||
|
* is skipped when the user is put straight into the call, or when the host
|
||||||
|
* wants to say when to join; and after the call there may be a post-call
|
||||||
|
* screen, a return to the lobby, or nothing, depending on whether the host
|
||||||
|
* decides what comes next. The view decides which stages apply from the
|
||||||
|
* parameters it was started with and from what the host bridge says.
|
||||||
|
*
|
||||||
|
* It owns nothing about how Element Call came to be showing a call: no
|
||||||
|
* routing, no authentication, no resolving of room aliases. Those belong to
|
||||||
|
* whatever is hosting it — the standalone app's own shell, or an application
|
||||||
|
* embedding Element Call as a component. Both render this.
|
||||||
|
*/
|
||||||
|
export const CallView: FC<Props> = (props): ReactNode => {
|
||||||
|
// Whether the user is in the call is the call's own business, not its host's.
|
||||||
|
// Held here rather than below so that it survives the mute state being
|
||||||
|
// rebuilt.
|
||||||
|
const [joined, setJoined] = useState(false);
|
||||||
|
const muteStates = useMuteStates();
|
||||||
|
|
||||||
|
if (muteStates === null) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LoadedCallView
|
||||||
|
{...props}
|
||||||
|
joined={joined}
|
||||||
|
setJoined={setJoined}
|
||||||
|
muteStates={muteStates}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LoadedProps extends Props {
|
||||||
joined: boolean;
|
joined: boolean;
|
||||||
setJoined: (value: boolean) => void;
|
setJoined: (value: boolean) => void;
|
||||||
muteStates: MuteStates;
|
muteStates: MuteStates;
|
||||||
widget: WidgetHelpers | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GroupCallView: FC<Props> = ({
|
/** {@link CallView}, once it has the mute state everything below needs. */
|
||||||
|
const LoadedCallView: FC<LoadedProps> = ({
|
||||||
client,
|
client,
|
||||||
isPasswordlessUser,
|
isPasswordlessUser,
|
||||||
confineToRoom,
|
confineToRoom,
|
||||||
@@ -116,13 +156,19 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
joined,
|
joined,
|
||||||
setJoined,
|
setJoined,
|
||||||
muteStates,
|
muteStates,
|
||||||
widget,
|
|
||||||
}) => {
|
}) => {
|
||||||
// Used to thread through any errors that occur outside the error boundary
|
// Used to thread through any errors that occur outside the error boundary
|
||||||
const [externalError, setExternalError] = useState<ElementCallError | null>(
|
const [externalError, setExternalError] = useState<ElementCallError | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
const memberships = useMatrixRTCSessionMemberships(rtcSession);
|
||||||
|
const rootElement = useRootElement();
|
||||||
|
const hostBridge = useHostBridge();
|
||||||
|
// A host that can close us is a host that decides when we stop existing, so
|
||||||
|
// we neither show our own post-call screens nor assume we have time to
|
||||||
|
// finish what we are doing. (Whose account the user's is, by contrast, is
|
||||||
|
// stated outright: see `HostBridge.supportsProfileChanges`.)
|
||||||
|
const hostControlsLifetime = hostBridge.close !== undefined;
|
||||||
|
|
||||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||||
const leaveSoundContext = useLatest(
|
const leaveSoundContext = useLatest(
|
||||||
@@ -140,9 +186,9 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logger.info("[Lifecycle] GroupCallView Component mounted");
|
logger.info("[Lifecycle] CallView Component mounted");
|
||||||
return (): void => {
|
return (): void => {
|
||||||
logger.info("[Lifecycle] GroupCallView Component unmounted");
|
logger.info("[Lifecycle] CallView Component unmounted");
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -150,11 +196,11 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
// viewport sizes smaller than 122px width. (It is actually this exact number: 122px
|
// viewport sizes smaller than 122px width. (It is actually this exact number: 122px
|
||||||
// tested on different devices...)
|
// tested on different devices...)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.classList.add("no-scroll-body");
|
rootElement.classList.add("no-scroll-body");
|
||||||
return (): void => {
|
return (): void => {
|
||||||
document.body.classList.remove("no-scroll-body");
|
rootElement.classList.remove("no-scroll-body");
|
||||||
};
|
};
|
||||||
}, []);
|
}, [rootElement]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.rtcSession = rtcSession;
|
window.rtcSession = rtcSession;
|
||||||
@@ -209,7 +255,6 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
|
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
|
||||||
}, [passwordFromUrl, room.roomId]);
|
}, [passwordFromUrl, room.roomId]);
|
||||||
|
|
||||||
usePageTitle(roomName);
|
|
||||||
useAppBarTitle(roomName);
|
useAppBarTitle(roomName);
|
||||||
|
|
||||||
const matrixInfo = useMemo((): MatrixInfo => {
|
const matrixInfo = useMemo((): MatrixInfo => {
|
||||||
@@ -297,28 +342,26 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (skipLobby) {
|
if (skipLobby) {
|
||||||
if (widget && preload) {
|
// `preload` is only ever set when we have a host to be preloaded by.
|
||||||
|
if (preload) {
|
||||||
// In preload mode without lobby we wait for a join action before entering
|
// In preload mode without lobby we wait for a join action before entering
|
||||||
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
const subscription = hostBridge.join$.subscribe(({ data, reply }) => {
|
||||||
(async (): Promise<void> => {
|
(async (): Promise<void> => {
|
||||||
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
|
await defaultDeviceSetup(data);
|
||||||
setJoined(true);
|
setJoined(true);
|
||||||
widget.api.transport.reply(ev.detail, {});
|
reply();
|
||||||
})().catch((e) => {
|
})().catch((e) => {
|
||||||
logger.error("Error joining RTC session on preload", e);
|
logger.error("Error joining RTC session on preload", e);
|
||||||
});
|
});
|
||||||
};
|
});
|
||||||
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
|
return (): void => subscription.unsubscribe();
|
||||||
return (): void => {
|
|
||||||
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
// No lobby and no preload: we enter the rtc session right away
|
// No lobby and no preload: we enter the rtc session right away
|
||||||
setJoined(true);
|
setJoined(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
widget,
|
hostBridge,
|
||||||
rtcSession,
|
rtcSession,
|
||||||
preload,
|
preload,
|
||||||
skipLobby,
|
skipLobby,
|
||||||
@@ -331,7 +374,7 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
// TODO refactor this + "joined" to just one callState
|
// TODO refactor this + "joined" to just one callState
|
||||||
const [left, setLeft] = useState(false);
|
const [left, setLeft] = useState(false);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const leaveToHome = useLeaveToHome();
|
||||||
|
|
||||||
// TODO split this into leave and onDisconnect
|
// TODO split this into leave and onDisconnect
|
||||||
const onLeft = useCallback(
|
const onLeft = useCallback(
|
||||||
@@ -344,7 +387,7 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
// When "allOthersLeft", the leaveSoundEffect$ in CallEventAudioRenderer
|
// When "allOthersLeft", the leaveSoundEffect$ in CallEventAudioRenderer
|
||||||
// already plays the "left" sound when the remote participant's media
|
// already plays the "left" sound when the remote participant's media
|
||||||
// disappears. We play it here silenced (volumeOverwrite = 0) so we have the right duration in the audioPromise.
|
// disappears. We play it here silenced (volumeOverwrite = 0) so we have the right duration in the audioPromise.
|
||||||
// (used to destory the widget)
|
// (which is what delays asking the host to close us)
|
||||||
audioPromise = leaveSoundContext.current?.playSound("left", 0);
|
audioPromise = leaveSoundContext.current?.playSound("left", 0);
|
||||||
break;
|
break;
|
||||||
case "timeout":
|
case "timeout":
|
||||||
@@ -359,12 +402,12 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
setLeft(true);
|
setLeft(true);
|
||||||
|
|
||||||
// We need to wait until the callEnded event is tracked on PostHog,
|
// We need to wait until the callEnded event is tracked on PostHog,
|
||||||
// otherwise the iframe may get killed first.
|
// otherwise we may be torn down first.
|
||||||
const posthogRequest = new Promise((resolve) => {
|
const posthogRequest = new Promise((resolve) => {
|
||||||
// To increase the likelihood of the PostHog event being sent out in
|
// To increase the likelihood of the PostHog event being sent out
|
||||||
// widget mode before the iframe is killed, we ask it to skip the
|
// before the host disposes of us, we ask it to skip the usual
|
||||||
// usual queuing/batching of requests.
|
// queuing/batching of requests.
|
||||||
const sendInstantly = widget !== null;
|
const sendInstantly = hostControlsLifetime;
|
||||||
PosthogAnalytics.instance.eventCallEnded.track(
|
PosthogAnalytics.instance.eventCallEnded.track(
|
||||||
room.roomId,
|
room.roomId,
|
||||||
rtcSession.memberships.length,
|
rtcSession.memberships.length,
|
||||||
@@ -372,8 +415,8 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
rtcSession,
|
rtcSession,
|
||||||
);
|
);
|
||||||
// Unfortunately the PostHog library provides no way to await the
|
// Unfortunately the PostHog library provides no way to await the
|
||||||
// tracking of an event, but we don't really want it to hold up the
|
// tracking of an event, but we don't really want it to hold up our
|
||||||
// closing of the widget that long anyway, so giving it 10 ms will do.
|
// disposal that long anyway, so giving it 10 ms will do.
|
||||||
window.setTimeout(resolve, 10);
|
window.setTimeout(resolve, 10);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -390,27 +433,21 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
!confineToRoom &&
|
!confineToRoom &&
|
||||||
!PosthogAnalytics.instance.isEnabled()
|
!PosthogAnalytics.instance.isEnabled()
|
||||||
)
|
)
|
||||||
void navigate("/");
|
leaveToHome?.();
|
||||||
|
|
||||||
if (widget) {
|
// After this point the host could dispose of us at any moment!
|
||||||
// After this point the iframe could die at any moment!
|
try {
|
||||||
|
await hostBridge.setAlwaysOnScreen(false);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Failed to set `alwaysOnScreen` to false", e);
|
||||||
|
}
|
||||||
|
// On a normal user hangup we can shut down and ask to be closed. But
|
||||||
|
// if an error occurs we should stay open until the user reads it.
|
||||||
|
if (reason != "error" && !returnToLobby) {
|
||||||
try {
|
try {
|
||||||
await widget.api.setAlwaysOnScreen(false);
|
await hostBridge.close?.();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error(
|
logger.error("Failed to ask the host to close Element Call", e);
|
||||||
"Failed to set call widget `alwaysOnScreen` to false",
|
|
||||||
e,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// On a normal user hangup we can shut down and close the widget. But if an
|
|
||||||
// error occurs we should keep the widget open until the user reads it.
|
|
||||||
if (reason != "error" && !getUrlParams().returnToLobby) {
|
|
||||||
try {
|
|
||||||
await widget.api.transport.send(ElementWidgetActions.Close, {});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("Failed to send close action", e);
|
|
||||||
}
|
|
||||||
widget.api.transport.stop();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -418,22 +455,24 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
[
|
[
|
||||||
setJoined,
|
setJoined,
|
||||||
leaveSoundContext,
|
leaveSoundContext,
|
||||||
widget,
|
hostBridge,
|
||||||
|
hostControlsLifetime,
|
||||||
room.roomId,
|
room.roomId,
|
||||||
rtcSession,
|
rtcSession,
|
||||||
isPasswordlessUser,
|
isPasswordlessUser,
|
||||||
confineToRoom,
|
confineToRoom,
|
||||||
navigate,
|
returnToLobby,
|
||||||
|
leaveToHome,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (widget && joined)
|
if (joined)
|
||||||
// set widget to sticky once joined.
|
// ask to be kept on screen once joined.
|
||||||
widget.api.setAlwaysOnScreen(true).catch((e) => {
|
hostBridge.setAlwaysOnScreen(true).catch((e) => {
|
||||||
logger.error("Error calling setAlwaysOnScreen(true)", e);
|
logger.error("Error calling setAlwaysOnScreen(true)", e);
|
||||||
});
|
});
|
||||||
}, [widget, joined, rtcSession]);
|
}, [hostBridge, joined, rtcSession]);
|
||||||
|
|
||||||
const joinRule = useJoinRule(room);
|
const joinRule = useJoinRule(room);
|
||||||
|
|
||||||
@@ -503,19 +542,16 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
} else if (left && widget === null) {
|
} else if (left && !hostControlsLifetime) {
|
||||||
// Left in SPA mode:
|
// Left, and it is up to us what to show next:
|
||||||
|
|
||||||
// The call ended view is shown for two reasons: prompting guests to create
|
// The call ended view is shown for two reasons: prompting guests to create
|
||||||
// an account, and prompting users that have opted into analytics to provide
|
// an account, and prompting users that have opted into analytics to provide
|
||||||
// feedback. We don't show a feedback prompt to widget users however (at
|
// feedback. We don't show a feedback prompt when a host owns our lifetime
|
||||||
// least for now), because we don't yet have designs that would allow widget
|
// however (at least for now), because we don't yet have designs that would
|
||||||
// users to dismiss the feedback prompt and close the call window without
|
// allow those users to dismiss the feedback prompt and close the call
|
||||||
// submitting anything.
|
// window without submitting anything.
|
||||||
if (
|
if (isPasswordlessUser || PosthogAnalytics.instance.isEnabled()) {
|
||||||
isPasswordlessUser ||
|
|
||||||
(PosthogAnalytics.instance.isEnabled() && widget === null)
|
|
||||||
) {
|
|
||||||
body = (
|
body = (
|
||||||
<CallEndedView
|
<CallEndedView
|
||||||
endedCallId={rtcSession.room.roomId}
|
endedCallId={rtcSession.room.roomId}
|
||||||
@@ -531,8 +567,8 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
// LobbyView again which would open capture devices again.
|
// LobbyView again which would open capture devices again.
|
||||||
body = null;
|
body = null;
|
||||||
}
|
}
|
||||||
} else if (left && widget !== null) {
|
} else if (left && hostControlsLifetime) {
|
||||||
// Left in widget mode:
|
// Left, and the host decides what happens next:
|
||||||
body = returnToLobby ? lobbyView : null;
|
body = returnToLobby ? lobbyView : null;
|
||||||
} else if (preload || skipLobby) {
|
} else if (preload || skipLobby) {
|
||||||
// The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called.
|
// The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called.
|
||||||
@@ -543,7 +579,6 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GroupCallErrorBoundary
|
<GroupCallErrorBoundary
|
||||||
widget={widget}
|
|
||||||
recoveryActionHandler={async (action) => {
|
recoveryActionHandler={async (action) => {
|
||||||
setExternalError(null);
|
setExternalError(null);
|
||||||
if (action == "reconnect") {
|
if (action == "reconnect") {
|
||||||
@@ -555,9 +590,10 @@ export const GroupCallView: FC<Props> = ({
|
|||||||
}}
|
}}
|
||||||
onError={(_error) => {
|
onError={(_error) => {
|
||||||
if (rtcSession.isJoined()) onLeft("error");
|
if (rtcSession.isJoined()) onLeft("error");
|
||||||
// If there is an error we need to be able to close the widget. This is done in `onLeft` as well
|
// If there is an error we need to be dismissible again. This is done in
|
||||||
// We need it here explicitly in case rtcSession.isJoined is false.
|
// `onLeft` as well; we need it here explicitly in case
|
||||||
void widget?.api.setAlwaysOnScreen(false);
|
// rtcSession.isJoined is false.
|
||||||
|
void hostBridge.setAlwaysOnScreen(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{body}
|
{body}
|
||||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, onTestFinished, test, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import {
|
import {
|
||||||
type FC,
|
type FC,
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { ConnectionError } from "livekit-client";
|
import { ConnectionError } from "livekit-client";
|
||||||
import { MatrixError } from "matrix-js-sdk";
|
import { MatrixError } from "matrix-js-sdk";
|
||||||
@@ -36,7 +36,14 @@ import {
|
|||||||
UnknownCallError,
|
UnknownCallError,
|
||||||
} from "../utils/errors.ts";
|
} from "../utils/errors.ts";
|
||||||
import { mockConfig } from "../utils/test.ts";
|
import { mockConfig } from "../utils/test.ts";
|
||||||
import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts";
|
import {
|
||||||
|
type HostBridge,
|
||||||
|
HostBridgeProvider,
|
||||||
|
nullHostBridge,
|
||||||
|
} from "../HostBridge.ts";
|
||||||
|
|
||||||
|
// Somewhere to go home to, so that the error pages offer the way
|
||||||
|
const leaveToHome = vi.fn();
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
{
|
{
|
||||||
@@ -75,15 +82,14 @@ test.each([
|
|||||||
|
|
||||||
const onErrorMock = vi.fn();
|
const onErrorMock = vi.fn();
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(
|
||||||
<BrowserRouter>
|
<LeaveToHomeProvider value={leaveToHome}>
|
||||||
<GroupCallErrorBoundary
|
<GroupCallErrorBoundary
|
||||||
onError={onErrorMock}
|
onError={onErrorMock}
|
||||||
recoveryActionHandler={vi.fn()}
|
recoveryActionHandler={vi.fn()}
|
||||||
widget={null}
|
|
||||||
>
|
>
|
||||||
<TestComponent />
|
<TestComponent />
|
||||||
</GroupCallErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
</BrowserRouter>,
|
</LeaveToHomeProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await screen.findByText(expectedTitle);
|
await screen.findByText(expectedTitle);
|
||||||
@@ -103,16 +109,19 @@ test("should render the error page with link back to home", async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onErrorMock = vi.fn();
|
const onErrorMock = vi.fn();
|
||||||
|
// From the home page itself the home button reloads instead of navigating,
|
||||||
|
// so be somewhere else for this test
|
||||||
|
window.history.pushState({}, "", "/room/somewhere");
|
||||||
|
onTestFinished(() => window.history.pushState({}, "", "/"));
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(
|
||||||
<BrowserRouter>
|
<LeaveToHomeProvider value={leaveToHome}>
|
||||||
<GroupCallErrorBoundary
|
<GroupCallErrorBoundary
|
||||||
onError={onErrorMock}
|
onError={onErrorMock}
|
||||||
recoveryActionHandler={vi.fn()}
|
recoveryActionHandler={vi.fn()}
|
||||||
widget={null}
|
|
||||||
>
|
>
|
||||||
<TestComponent />
|
<TestComponent />
|
||||||
</GroupCallErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
</BrowserRouter>,
|
</LeaveToHomeProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await screen.findByText("Call is not supported");
|
await screen.findByText("Call is not supported");
|
||||||
@@ -121,7 +130,11 @@ test("should render the error page with link back to home", async () => {
|
|||||||
screen.getByText(/Error Code: MISSING_MATRIX_RTC_TRANSPORT/i),
|
screen.getByText(/Error Code: MISSING_MATRIX_RTC_TRANSPORT/i),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
await screen.findByRole("button", { name: "Return to home screen" });
|
// The way home is whatever the shell supplied, not a route of the call's own
|
||||||
|
await userEvent
|
||||||
|
.setup()
|
||||||
|
.click(screen.getByRole("button", { name: "Return to home screen" }));
|
||||||
|
expect(leaveToHome).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
expect(onErrorMock).toHaveBeenCalledOnce();
|
expect(onErrorMock).toHaveBeenCalledOnce();
|
||||||
expect(onErrorMock).toHaveBeenCalledWith(error);
|
expect(onErrorMock).toHaveBeenCalledWith(error);
|
||||||
@@ -153,14 +166,11 @@ test("ConnectionLostError: Action handling should reset error state", async () =
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<LeaveToHomeProvider value={leaveToHome}>
|
||||||
<GroupCallErrorBoundary
|
<GroupCallErrorBoundary recoveryActionHandler={reconnectCallback}>
|
||||||
recoveryActionHandler={reconnectCallback}
|
|
||||||
widget={null}
|
|
||||||
>
|
|
||||||
<TestComponent fail={failState} />
|
<TestComponent fail={failState} />
|
||||||
</GroupCallErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
</BrowserRouter>
|
</LeaveToHomeProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -195,15 +205,14 @@ describe("Rageshake button", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<BrowserRouter>
|
<LeaveToHomeProvider value={leaveToHome}>
|
||||||
<GroupCallErrorBoundary
|
<GroupCallErrorBoundary
|
||||||
onError={vi.fn()}
|
onError={vi.fn()}
|
||||||
recoveryActionHandler={vi.fn()}
|
recoveryActionHandler={vi.fn()}
|
||||||
widget={null}
|
|
||||||
>
|
>
|
||||||
<TestComponent />
|
<TestComponent />
|
||||||
</GroupCallErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
</BrowserRouter>,
|
</LeaveToHomeProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,30 +233,28 @@ describe("Rageshake button", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should have a close button in widget mode", async () => {
|
test("should have a close button when the host can dismiss us", async () => {
|
||||||
const error = new MatrixRTCTransportMissingError("example.com");
|
const error = new MatrixRTCTransportMissingError("example.com");
|
||||||
const TestComponent = (): ReactNode => {
|
const TestComponent = (): ReactNode => {
|
||||||
throw error;
|
throw error;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockWidget = {
|
const close = vi.fn().mockResolvedValue(undefined);
|
||||||
api: {
|
const hostBridge: HostBridge = { ...nullHostBridge, close };
|
||||||
transport: { send: vi.fn().mockResolvedValue(undefined), stop: vi.fn() },
|
|
||||||
},
|
|
||||||
} as unknown as WidgetHelpers;
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onErrorMock = vi.fn();
|
const onErrorMock = vi.fn();
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(
|
||||||
<BrowserRouter>
|
<LeaveToHomeProvider value={leaveToHome}>
|
||||||
<GroupCallErrorBoundary
|
<HostBridgeProvider value={hostBridge}>
|
||||||
widget={mockWidget}
|
<GroupCallErrorBoundary
|
||||||
onError={onErrorMock}
|
onError={onErrorMock}
|
||||||
recoveryActionHandler={vi.fn()}
|
recoveryActionHandler={vi.fn()}
|
||||||
>
|
>
|
||||||
<TestComponent />
|
<TestComponent />
|
||||||
</GroupCallErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
</BrowserRouter>,
|
</HostBridgeProvider>
|
||||||
|
</LeaveToHomeProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await screen.findByText("Call is not supported");
|
await screen.findByText("Call is not supported");
|
||||||
@@ -258,11 +265,7 @@ test("should have a close button in widget mode", async () => {
|
|||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: "Close" }));
|
await user.click(screen.getByRole("button", { name: "Close" }));
|
||||||
|
|
||||||
expect(mockWidget.api.transport.send).toHaveBeenCalledWith(
|
expect(close).toHaveBeenCalled();
|
||||||
ElementWidgetActions.Close,
|
|
||||||
expect.anything(),
|
|
||||||
);
|
|
||||||
expect(mockWidget.api.transport.stop).toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show technical details when error has a matrixError cause", async () => {
|
test("should show technical details when error has a matrixError cause", async () => {
|
||||||
@@ -281,15 +284,11 @@ test("should show technical details when error has a matrixError cause", async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<BrowserRouter>
|
<LeaveToHomeProvider value={leaveToHome}>
|
||||||
<GroupCallErrorBoundary
|
<GroupCallErrorBoundary onError={vi.fn()} recoveryActionHandler={vi.fn()}>
|
||||||
onError={vi.fn()}
|
|
||||||
recoveryActionHandler={vi.fn()}
|
|
||||||
widget={null}
|
|
||||||
>
|
|
||||||
<TestComponent />
|
<TestComponent />
|
||||||
</GroupCallErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
</BrowserRouter>,
|
</LeaveToHomeProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await screen.findByText("Something went wrong");
|
await screen.findByText("Something went wrong");
|
||||||
@@ -314,15 +313,11 @@ test("should not show technical details when error has no matrix error cause", a
|
|||||||
};
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<BrowserRouter>
|
<LeaveToHomeProvider value={leaveToHome}>
|
||||||
<GroupCallErrorBoundary
|
<GroupCallErrorBoundary onError={vi.fn()} recoveryActionHandler={vi.fn()}>
|
||||||
onError={vi.fn()}
|
|
||||||
recoveryActionHandler={vi.fn()}
|
|
||||||
widget={null}
|
|
||||||
>
|
|
||||||
<TestComponent />
|
<TestComponent />
|
||||||
</GroupCallErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
</BrowserRouter>,
|
</LeaveToHomeProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await screen.findByText("Connection lost");
|
await screen.findByText("Connection lost");
|
||||||
@@ -372,15 +367,14 @@ describe("LiveKit ConnectionError variants", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(
|
||||||
<BrowserRouter>
|
<LeaveToHomeProvider value={leaveToHome}>
|
||||||
<GroupCallErrorBoundary
|
<GroupCallErrorBoundary
|
||||||
onError={vi.fn()}
|
onError={vi.fn()}
|
||||||
recoveryActionHandler={vi.fn()}
|
recoveryActionHandler={vi.fn()}
|
||||||
widget={null}
|
|
||||||
>
|
>
|
||||||
<TestComponent />
|
<TestComponent />
|
||||||
</GroupCallErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
</BrowserRouter>,
|
</LeaveToHomeProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check title
|
// Check title
|
||||||
@@ -402,15 +396,14 @@ describe("LiveKit ConnectionError variants", () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { asFragment } = render(
|
const { asFragment } = render(
|
||||||
<BrowserRouter>
|
<LeaveToHomeProvider value={leaveToHome}>
|
||||||
<GroupCallErrorBoundary
|
<GroupCallErrorBoundary
|
||||||
onError={vi.fn()}
|
onError={vi.fn()}
|
||||||
recoveryActionHandler={vi.fn()}
|
recoveryActionHandler={vi.fn()}
|
||||||
widget={null}
|
|
||||||
>
|
>
|
||||||
<TestComponent />
|
<TestComponent />
|
||||||
</GroupCallErrorBoundary>
|
</GroupCallErrorBoundary>
|
||||||
</BrowserRouter>,
|
</LeaveToHomeProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
await screen.findByText("Connection timeout");
|
await screen.findByText("Connection timeout");
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ import {
|
|||||||
} from "../utils/errors.ts";
|
} from "../utils/errors.ts";
|
||||||
import { FullScreenView } from "../FullScreenView.tsx";
|
import { FullScreenView } from "../FullScreenView.tsx";
|
||||||
import { ErrorView } from "../ErrorView.tsx";
|
import { ErrorView } from "../ErrorView.tsx";
|
||||||
import { type WidgetHelpers } from "../widget.ts";
|
|
||||||
import styles from "../ErrorView.module.css";
|
import styles from "../ErrorView.module.css";
|
||||||
|
|
||||||
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
|
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
|
||||||
@@ -47,13 +46,11 @@ interface ErrorPageProps {
|
|||||||
error: ElementCallError;
|
error: ElementCallError;
|
||||||
recoveryActionHandler: RecoveryActionHandler;
|
recoveryActionHandler: RecoveryActionHandler;
|
||||||
resetError: () => void;
|
resetError: () => void;
|
||||||
widget: WidgetHelpers | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ErrorPage: FC<ErrorPageProps> = ({
|
const ErrorPage: FC<ErrorPageProps> = ({
|
||||||
error,
|
error,
|
||||||
recoveryActionHandler,
|
recoveryActionHandler,
|
||||||
widget,
|
|
||||||
}: ErrorPageProps): ReactElement => {
|
}: ErrorPageProps): ReactElement => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
logger.error("Error boundary caught:", error);
|
logger.error("Error boundary caught:", error);
|
||||||
@@ -89,7 +86,6 @@ const ErrorPage: FC<ErrorPageProps> = ({
|
|||||||
Icon={icon}
|
Icon={icon}
|
||||||
title={error.localisedTitle}
|
title={error.localisedTitle}
|
||||||
rageshake={error.code == ErrorCode.UNKNOWN_ERROR}
|
rageshake={error.code == ErrorCode.UNKNOWN_ERROR}
|
||||||
widget={widget}
|
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
{error.localisedMessageKey ? (
|
{error.localisedMessageKey ? (
|
||||||
@@ -148,14 +144,12 @@ interface BoundaryProps {
|
|||||||
children: ReactNode | (() => ReactNode);
|
children: ReactNode | (() => ReactNode);
|
||||||
recoveryActionHandler: RecoveryActionHandler;
|
recoveryActionHandler: RecoveryActionHandler;
|
||||||
onError?: (error: unknown) => void;
|
onError?: (error: unknown) => void;
|
||||||
widget: WidgetHelpers | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GroupCallErrorBoundary = ({
|
export const GroupCallErrorBoundary = ({
|
||||||
recoveryActionHandler,
|
recoveryActionHandler,
|
||||||
onError,
|
onError,
|
||||||
children,
|
children,
|
||||||
widget,
|
|
||||||
}: BoundaryProps): ReactElement => {
|
}: BoundaryProps): ReactElement => {
|
||||||
const fallbackRenderer: FallbackRender = useCallback(
|
const fallbackRenderer: FallbackRender = useCallback(
|
||||||
({ error, resetError }): ReactElement => {
|
({ error, resetError }): ReactElement => {
|
||||||
@@ -165,7 +159,6 @@ export const GroupCallErrorBoundary = ({
|
|||||||
: new UnknownCallError(error instanceof Error ? error : new Error());
|
: new UnknownCallError(error instanceof Error ? error : new Error());
|
||||||
return (
|
return (
|
||||||
<ErrorPage
|
<ErrorPage
|
||||||
widget={widget ?? null}
|
|
||||||
error={callError}
|
error={callError}
|
||||||
resetError={resetError}
|
resetError={resetError}
|
||||||
recoveryActionHandler={async (action: CallErrorRecoveryAction) => {
|
recoveryActionHandler={async (action: CallErrorRecoveryAction) => {
|
||||||
@@ -175,7 +168,7 @@ export const GroupCallErrorBoundary = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[recoveryActionHandler, widget],
|
[recoveryActionHandler],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ spotlight tile is maximised and displaying video, apply a gradient background. *
|
|||||||
background: none;
|
background: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 320px) {
|
@container element-call (max-width: 320px) {
|
||||||
.invite {
|
.invite {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
type MockedFunction,
|
type MockedFunction,
|
||||||
vi,
|
vi,
|
||||||
} from "vitest";
|
} from "vitest";
|
||||||
import { render, type RenderResult } from "@testing-library/react";
|
import { act, render, type RenderResult } from "@testing-library/react";
|
||||||
import { type LocalParticipant } from "livekit-client";
|
import { type LocalParticipant } from "livekit-client";
|
||||||
import { BehaviorSubject, of } from "rxjs";
|
import { BehaviorSubject, of } from "rxjs";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
@@ -51,6 +51,7 @@ import { AppBar } from "../AppBar";
|
|||||||
import { type MatrixInfo } from "./VideoPreview";
|
import { type MatrixInfo } from "./VideoPreview";
|
||||||
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
|
||||||
import { initializeWidget } from "../widget";
|
import { initializeWidget } from "../widget";
|
||||||
|
import { RootElementProvider } from "../RootElementContext";
|
||||||
|
|
||||||
initializeWidget();
|
initializeWidget();
|
||||||
vi.hoisted(
|
vi.hoisted(
|
||||||
@@ -263,4 +264,71 @@ describe("ActiveCall", () => {
|
|||||||
// Rendering at all proves ActiveCall created all of its view models
|
// Rendering at all proves ActiveCall created all of its view models
|
||||||
expect(await findByTestId("incall_leave")).toBeVisible();
|
expect(await findByTestId("incall_leave")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lays the call out for the size of its root element", async () => {
|
||||||
|
// jsdom has no layout and no ResizeObserver: the root reports whatever
|
||||||
|
// size we say, and the observer notifies whenever we tell it to
|
||||||
|
let size = { width: 1000, height: 800 };
|
||||||
|
const root = document.createElement("div");
|
||||||
|
Object.defineProperty(root, "clientWidth", { get: () => size.width });
|
||||||
|
Object.defineProperty(root, "clientHeight", { get: () => size.height });
|
||||||
|
document.body.appendChild(root);
|
||||||
|
|
||||||
|
const observers: (() => void)[] = [];
|
||||||
|
const originalResizeObserver = window.ResizeObserver;
|
||||||
|
window.ResizeObserver = class {
|
||||||
|
public constructor(private readonly callback: ResizeObserverCallback) {}
|
||||||
|
public observe(): void {
|
||||||
|
observers.push(() => this.callback([], this as ResizeObserver));
|
||||||
|
}
|
||||||
|
public unobserve(): void {}
|
||||||
|
public disconnect(): void {}
|
||||||
|
} as unknown as typeof ResizeObserver;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mediaDevices = mockMediaDevices({});
|
||||||
|
const { rtcSession, matrixRoom } = getBasicRTCSession([local, alice]);
|
||||||
|
const { findByTestId, container } = render(
|
||||||
|
<BrowserRouter>
|
||||||
|
<RootElementProvider value={root}>
|
||||||
|
<MediaDevicesContext value={mediaDevices}>
|
||||||
|
<ProcessorProvider>
|
||||||
|
<TooltipProvider>
|
||||||
|
<RoomContext value={mockLivekitRoom({ localParticipant })}>
|
||||||
|
<ActiveCall
|
||||||
|
client={matrixRoom.client}
|
||||||
|
rtcSession={rtcSession.asMockedSession()}
|
||||||
|
matrixRoom={matrixRoom}
|
||||||
|
muteStates={mockMuteStates()}
|
||||||
|
matrixInfo={matrixInfo}
|
||||||
|
onShareClick={null}
|
||||||
|
e2eeSystem={{ kind: E2eeType.NONE }}
|
||||||
|
onLeft={(): void => {}}
|
||||||
|
/>
|
||||||
|
</RoomContext>
|
||||||
|
</TooltipProvider>
|
||||||
|
</ProcessorProvider>
|
||||||
|
</MediaDevicesContext>
|
||||||
|
</RootElementProvider>
|
||||||
|
</BrowserRouter>,
|
||||||
|
{ container: root },
|
||||||
|
);
|
||||||
|
await findByTestId("incall_leave");
|
||||||
|
const call = container.querySelector("[data-layout]")!;
|
||||||
|
expect(call.getAttribute("data-layout")).not.toBe("pip");
|
||||||
|
|
||||||
|
// The host shrinks the container to a corner of its page. The window has
|
||||||
|
// not changed at all — what matters is the element we were given.
|
||||||
|
size = { width: 300, height: 300 };
|
||||||
|
act(() => observers.forEach((notify) => notify()));
|
||||||
|
expect(call.getAttribute("data-layout")).toBe("pip");
|
||||||
|
|
||||||
|
size = { width: 1000, height: 800 };
|
||||||
|
act(() => observers.forEach((notify) => notify()));
|
||||||
|
expect(call.getAttribute("data-layout")).not.toBe("pip");
|
||||||
|
} finally {
|
||||||
|
window.ResizeObserver = originalResizeObserver;
|
||||||
|
root.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+37
-6
@@ -28,7 +28,9 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
|
||||||
import { HeaderStyle, useUrlParams } from "../UrlParams";
|
import { HeaderStyle, useUrlParams } from "../UrlParams";
|
||||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
|
||||||
import { widget } from "../widget";
|
import { useHostBridge } from "../HostBridge.ts";
|
||||||
|
import { useRootElement } from "../RootElementContext";
|
||||||
|
import { observeElementSize$ } from "../utils/elementSize";
|
||||||
import styles from "./InCallView.module.css";
|
import styles from "./InCallView.module.css";
|
||||||
import { GridTile } from "../tile/GridTile";
|
import { GridTile } from "../tile/GridTile";
|
||||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||||
@@ -41,6 +43,7 @@ import { type MatrixInfo } from "./VideoPreview";
|
|||||||
import { InviteButton } from "../button/InviteButton";
|
import { InviteButton } from "../button/InviteButton";
|
||||||
import {
|
import {
|
||||||
type CallViewModel,
|
type CallViewModel,
|
||||||
|
callViewModelOptionsFromParams,
|
||||||
createCallViewModel$,
|
createCallViewModel$,
|
||||||
} from "../state/CallViewModel/CallViewModel.ts";
|
} from "../state/CallViewModel/CallViewModel.ts";
|
||||||
import { Grid, type TileProps } from "../grid/Grid";
|
import { Grid, type TileProps } from "../grid/Grid";
|
||||||
@@ -116,8 +119,12 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
useState<ViewModel<DeveloperSettingsSnapshot> | null>(null);
|
useState<ViewModel<DeveloperSettingsSnapshot> | null>(null);
|
||||||
|
|
||||||
const urlParams = useUrlParams();
|
const urlParams = useUrlParams();
|
||||||
|
const hostBridge = useHostBridge();
|
||||||
const mediaDevices = useMediaDevices();
|
const mediaDevices = useMediaDevices();
|
||||||
const trackProcessorState$ = useTrackProcessorObservable$();
|
const trackProcessorState$ = useTrackProcessorObservable$();
|
||||||
|
// The element we have to draw the call in: the page, or the container a host
|
||||||
|
// gave us. Its size, not the window's, decides how the call is laid out.
|
||||||
|
const rootElement = useRootElement();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
rootLogger.info("START CALL VIEW SCOPE");
|
rootLogger.info("START CALL VIEW SCOPE");
|
||||||
const scope = new ObservableScope();
|
const scope = new ObservableScope();
|
||||||
@@ -132,12 +139,15 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
mediaDevices,
|
mediaDevices,
|
||||||
props.muteStates,
|
props.muteStates,
|
||||||
{
|
{
|
||||||
|
...callViewModelOptionsFromParams(urlParams),
|
||||||
encryptionSystem: props.e2eeSystem,
|
encryptionSystem: props.e2eeSystem,
|
||||||
|
hostBridge,
|
||||||
autoLeaveWhenOthersLeft,
|
autoLeaveWhenOthersLeft,
|
||||||
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
|
||||||
// We merely sample the current mode here, so the user would need to
|
// We merely sample the current mode here, so the user would need to
|
||||||
// manually rejoin to switch to a different one.
|
// manually rejoin to switch to a different one.
|
||||||
matrixRTCMode: matrixRTCModeSetting.value$.value,
|
matrixRTCMode: matrixRTCModeSetting.value$.value,
|
||||||
|
windowSize$: scope.behavior(observeElementSize$(rootElement)),
|
||||||
},
|
},
|
||||||
reactionsReader.raisedHands$,
|
reactionsReader.raisedHands$,
|
||||||
reactionsReader.reactions$,
|
reactionsReader.reactions$,
|
||||||
@@ -159,9 +169,11 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
props.e2eeSystem,
|
props.e2eeSystem,
|
||||||
props.onLeft,
|
props.onLeft,
|
||||||
urlParams,
|
urlParams,
|
||||||
|
hostBridge,
|
||||||
mediaDevices,
|
mediaDevices,
|
||||||
trackProcessorState$,
|
trackProcessorState$,
|
||||||
props.client,
|
props.client,
|
||||||
|
rootElement,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -174,6 +186,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
|
|||||||
props.muteStates,
|
props.muteStates,
|
||||||
mediaDevices,
|
mediaDevices,
|
||||||
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
|
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
|
||||||
|
{ showControls: urlParams.showControls, header: urlParams.header },
|
||||||
);
|
);
|
||||||
setFooterVm(footerVm);
|
setFooterVm(footerVm);
|
||||||
setDeveloperSettingsVm(createDeveloperSettingsTabViewModel(scope, vm));
|
setDeveloperSettingsVm(createDeveloperSettingsTabViewModel(scope, vm));
|
||||||
@@ -234,6 +247,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const logger = rootLogger.getChild("[InCallView]");
|
const logger = rootLogger.getChild("[InCallView]");
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const hostBridge = useHostBridge();
|
||||||
const { sendReaction, toggleRaisedHand } = useReactionsSender();
|
const { sendReaction, toggleRaisedHand } = useReactionsSender();
|
||||||
|
|
||||||
useWakeLock();
|
useWakeLock();
|
||||||
@@ -253,6 +267,20 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
// Merge the refs so they can attach to the same element
|
// Merge the refs so they can attach to the same element
|
||||||
const containerRef = useMergedRefs(containerRef1, containerRef2);
|
const containerRef = useMergedRefs(containerRef1, containerRef2);
|
||||||
|
|
||||||
|
// The fixed grid is positioned against Element Call's root, so offsets
|
||||||
|
// handed to it have to be measured from there rather than from the
|
||||||
|
// viewport. Standalone the two are the same, the root being the page; for a
|
||||||
|
// component the root sits wherever the host put it, and measuring from the
|
||||||
|
// viewport would push the grid down by that much again. Taken at the same
|
||||||
|
// moment as `bounds`, so that the two agree however the host has scrolled.
|
||||||
|
const rootElement = useRootElement();
|
||||||
|
const rootTop = useMemo(
|
||||||
|
() => rootElement.getBoundingClientRect().top,
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[rootElement, bounds],
|
||||||
|
);
|
||||||
|
const topWithinRoot = bounds.top - rootTop;
|
||||||
|
|
||||||
const { showControls, header: headerStyle } = useUrlParams();
|
const { showControls, header: headerStyle } = useUrlParams();
|
||||||
|
|
||||||
const muteAllAudio = useBehavior(muteAllAudio$);
|
const muteAllAudio = useBehavior(muteAllAudio$);
|
||||||
@@ -318,14 +346,14 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
|
|
||||||
const openProfile = useMemo(
|
const openProfile = useMemo(
|
||||||
() =>
|
() =>
|
||||||
// Profile settings are unavailable in widget mode
|
// The profile is only ours to edit when the account is ours
|
||||||
widget === null
|
hostBridge.supportsProfileChanges
|
||||||
? (): void => {
|
? (): void => {
|
||||||
setSettingsTab("profile");
|
setSettingsTab("profile");
|
||||||
setSettingsOpen(true);
|
setSettingsOpen(true);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
[setSettingsTab, setSettingsOpen],
|
[setSettingsTab, setSettingsOpen, hostBridge],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [headerRef, headerBounds] = useMeasure();
|
const [headerRef, headerBounds] = useMeasure();
|
||||||
@@ -550,7 +578,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
className={styles.fixedGrid}
|
className={styles.fixedGrid}
|
||||||
style={{
|
style={{
|
||||||
// If not edge-to-edge, consume the header insets right here.
|
// If not edge-to-edge, consume the header insets right here.
|
||||||
insetBlockStart: edgeToEdge ? 0 : bounds.top + headerBounds.height,
|
insetBlockStart: edgeToEdge ? 0 : topWithinRoot + headerBounds.height,
|
||||||
height: edgeToEdge ? "100%" : gridBounds.height,
|
height: edgeToEdge ? "100%" : gridBounds.height,
|
||||||
// If edge-to-edge, compute new safe area insets that account for the
|
// If edge-to-edge, compute new safe area insets that account for the
|
||||||
// header and footer, passing them down to the tiles.
|
// header and footer, passing them down to the tiles.
|
||||||
@@ -561,7 +589,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
// itself. Otherwise account for the safe area and header size
|
// itself. Otherwise account for the safe area and header size
|
||||||
// as part of the InCallView.
|
// as part of the InCallView.
|
||||||
headerStyle === HeaderStyle.AppBar
|
headerStyle === HeaderStyle.AppBar
|
||||||
? `${bounds.top}px`
|
? `${topWithinRoot}px`
|
||||||
: `calc(env(safe-area-inset-top) + ${headerBounds.height}px)`
|
: `calc(env(safe-area-inset-top) + ${headerBounds.height}px)`
|
||||||
: undefined,
|
: undefined,
|
||||||
"--call-view-safe-area-inset-bottom":
|
"--call-view-safe-area-inset-bottom":
|
||||||
@@ -633,6 +661,9 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
[styles.overflowing]: overflowing,
|
[styles.overflowing]: overflowing,
|
||||||
})}
|
})}
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
// Which layout the call has settled on, for tests and for anyone
|
||||||
|
// wondering why the call looks the way it does at the size it was given
|
||||||
|
data-layout={layout.type}
|
||||||
onPointerUp={onViewPointerUp}
|
onPointerUp={onViewPointerUp}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerOut={onPointerOut}
|
onPointerOut={onPointerOut}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/*
|
||||||
|
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, vi } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
|
import { type MatrixClient, type RoomSummary } from "matrix-js-sdk";
|
||||||
|
|
||||||
|
import { KnockLobbyView } from "./KnockLobbyView";
|
||||||
|
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
|
||||||
|
import { MediaDevicesContext } from "../MediaDevicesContext";
|
||||||
|
import { type ProcessorState } from "../livekit/TrackProcessorContext";
|
||||||
|
import { mockMediaDevices } from "../utils/test";
|
||||||
|
|
||||||
|
vi.mock("@livekit/components-react", () => ({
|
||||||
|
usePreviewTracks: (): unknown[] => [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../livekit/TrackProcessorContext", () => ({
|
||||||
|
useTrackProcessor: (): ProcessorState => ({
|
||||||
|
supported: false,
|
||||||
|
processor: undefined,
|
||||||
|
}),
|
||||||
|
useTrackProcessorSync: (): void => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-use-measure", () => ({
|
||||||
|
default: (): [() => void, object] => [(): void => {}, {}],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../settings/SettingsModal", () => ({
|
||||||
|
SettingsModal: (): null => null,
|
||||||
|
defaultSettingsTab: "general",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const client = {
|
||||||
|
getUserId: () => "@user:example.org",
|
||||||
|
getDeviceId: () => "DEVICE",
|
||||||
|
} as Partial<MatrixClient> as MatrixClient;
|
||||||
|
|
||||||
|
// What peeking at a room we are not in tells us about it
|
||||||
|
const roomSummary = {
|
||||||
|
room_id: "!room:example.org",
|
||||||
|
name: "Knock Room",
|
||||||
|
"im.nheko.summary.encryption": "m.megolm.v1.aes-sha2",
|
||||||
|
} as Partial<RoomSummary> as RoomSummary;
|
||||||
|
|
||||||
|
function renderKnockLobby(knock: (() => void) | null): void {
|
||||||
|
render(
|
||||||
|
<LeaveToHomeProvider value={vi.fn()}>
|
||||||
|
<MediaDevicesContext value={mockMediaDevices({})}>
|
||||||
|
<TooltipProvider>
|
||||||
|
<KnockLobbyView
|
||||||
|
client={client}
|
||||||
|
roomSummary={roomSummary}
|
||||||
|
profile={{ displayName: "Test User", avatarUrl: "" }}
|
||||||
|
knock={knock}
|
||||||
|
confineToRoom={false}
|
||||||
|
hideHeader={false}
|
||||||
|
/>
|
||||||
|
</TooltipProvider>
|
||||||
|
</MediaDevicesContext>
|
||||||
|
</LeaveToHomeProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("KnockLobbyView", () => {
|
||||||
|
it("offers to ask to join, with what it knows of the room", async () => {
|
||||||
|
const knock = vi.fn();
|
||||||
|
renderKnockLobby(knock);
|
||||||
|
|
||||||
|
// The mute state arrives asynchronously, and the lobby with it
|
||||||
|
const button = await screen.findByTestId("lobby_joinCall");
|
||||||
|
expect(button).toHaveTextContent("Request to join call");
|
||||||
|
expect(button).toBeEnabled();
|
||||||
|
expect(screen.getByText("Knock Room")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.setup().click(button);
|
||||||
|
expect(knock).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits once it has asked", async () => {
|
||||||
|
renderKnockLobby(null);
|
||||||
|
|
||||||
|
const button = await screen.findByTestId("lobby_joinCall");
|
||||||
|
expect(button).toHaveTextContent("Request sent!");
|
||||||
|
// Compound's button keeps focusable, saying so through ARIA instead
|
||||||
|
expect(button).toHaveAttribute("aria-disabled", "true");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2026 Element Creations Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type FC, type JSX, type ReactNode } from "react";
|
||||||
|
import { type MatrixClient, type RoomSummary } from "matrix-js-sdk";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
|
import { LobbyView } from "./LobbyView";
|
||||||
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
|
import { useMuteStates } from "../state/useMuteStates";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
client: MatrixClient;
|
||||||
|
/** What we know about the room from peeking at it. */
|
||||||
|
roomSummary: RoomSummary;
|
||||||
|
/** The user's own name and avatar, to show in their own tile. */
|
||||||
|
profile: { displayName: string; avatarUrl: string };
|
||||||
|
/**
|
||||||
|
* Asks to be let in, if the room allows it. Absent once we have asked and
|
||||||
|
* are waiting for an answer.
|
||||||
|
*/
|
||||||
|
knock: (() => void) | null;
|
||||||
|
confineToRoom: boolean;
|
||||||
|
hideHeader: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lobby shown while the user is outside a room they want to call in —
|
||||||
|
* either able to ask to join, or waiting for someone to answer.
|
||||||
|
*
|
||||||
|
* This belongs to the app shell rather than to the call: it exists precisely
|
||||||
|
* because there is no call to be in yet. It keeps its own mute state, which is
|
||||||
|
* why it is a component rather than part of the page — so that the call's mute
|
||||||
|
* state and this one are never alive at the same time, reporting over each
|
||||||
|
* other to the host.
|
||||||
|
*/
|
||||||
|
export const KnockLobbyView: FC<Props> = ({
|
||||||
|
client,
|
||||||
|
roomSummary,
|
||||||
|
profile,
|
||||||
|
knock,
|
||||||
|
confineToRoom,
|
||||||
|
hideHeader,
|
||||||
|
}): ReactNode => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const muteStates = useMuteStates();
|
||||||
|
|
||||||
|
if (muteStates === null) return null;
|
||||||
|
|
||||||
|
const waitingForInvite = knock === null;
|
||||||
|
const enterLabel: string | JSX.Element = waitingForInvite ? (
|
||||||
|
<>
|
||||||
|
{t("lobby.waiting_for_invite")}
|
||||||
|
<CheckIcon />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("lobby.ask_to_join")
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LobbyView
|
||||||
|
client={client}
|
||||||
|
matrixInfo={{
|
||||||
|
userId: client.getUserId() ?? "",
|
||||||
|
displayName: profile.displayName,
|
||||||
|
avatarUrl: profile.avatarUrl,
|
||||||
|
roomAlias: null,
|
||||||
|
roomId: roomSummary.room_id,
|
||||||
|
roomName: roomSummary.name ?? "",
|
||||||
|
roomAvatar: roomSummary.avatar_url ?? null,
|
||||||
|
e2eeSystem: {
|
||||||
|
kind: roomSummary["im.nheko.summary.encryption"]
|
||||||
|
? E2eeType.PER_PARTICIPANT
|
||||||
|
: E2eeType.NONE,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onEnter={(): void => knock?.()}
|
||||||
|
enterLabel={enterLabel}
|
||||||
|
waitingForInvite={waitingForInvite}
|
||||||
|
confineToRoom={confineToRoom}
|
||||||
|
hideHeader={hideHeader}
|
||||||
|
participantCount={null}
|
||||||
|
muteStates={muteStates}
|
||||||
|
onShareClick={null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -28,13 +28,13 @@ Please see LICENSE in the repository root for full details.
|
|||||||
color: var(--cpd-color-theme-primary) !important;
|
color: var(--cpd-color-theme-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 500px) {
|
@container element-call (max-width: 500px) {
|
||||||
.join {
|
.join {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-height: 650px) {
|
@container element-call (min-height: 650px) {
|
||||||
.content {
|
.content {
|
||||||
gap: var(--cpd-space-10x);
|
gap: var(--cpd-space-10x);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { render } from "@testing-library/react";
|
import { render } from "@testing-library/react";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
import { type MatrixClient } from "matrix-js-sdk";
|
import { type MatrixClient } from "matrix-js-sdk";
|
||||||
import { axe } from "vitest-axe";
|
import { axe } from "vitest-axe";
|
||||||
@@ -26,6 +26,9 @@ import lobbyStyles from "./LobbyView.module.css";
|
|||||||
import headerStyles from "../Header.module.css";
|
import headerStyles from "../Header.module.css";
|
||||||
import { AppBar } from "../AppBar";
|
import { AppBar } from "../AppBar";
|
||||||
|
|
||||||
|
// Somewhere to go home to, so that the lobby offers the way back
|
||||||
|
const leaveToHome = vi.fn();
|
||||||
|
|
||||||
vi.mock("@livekit/components-react", () => ({
|
vi.mock("@livekit/components-react", () => ({
|
||||||
usePreviewTracks: (): unknown[] => [],
|
usePreviewTracks: (): unknown[] => [],
|
||||||
}));
|
}));
|
||||||
@@ -93,14 +96,14 @@ function renderLobbyView(
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
return render(
|
return render(
|
||||||
<BrowserRouter>
|
<LeaveToHomeProvider value={leaveToHome}>
|
||||||
<MediaDevicesContext value={mediaDevices}>
|
<MediaDevicesContext value={mediaDevices}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
{withAppBar && <AppBar>{lobbyView}</AppBar>}
|
{withAppBar && <AppBar>{lobbyView}</AppBar>}
|
||||||
{!withAppBar && lobbyView}
|
{!withAppBar && lobbyView}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</MediaDevicesContext>
|
</MediaDevicesContext>
|
||||||
</BrowserRouter>,
|
</LeaveToHomeProvider>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-16
@@ -25,7 +25,6 @@ import {
|
|||||||
Track,
|
Track,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import { useObservableEagerState } from "observable-hooks";
|
import { useObservableEagerState } from "observable-hooks";
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import inCallStyles from "./InCallView.module.css";
|
import inCallStyles from "./InCallView.module.css";
|
||||||
import styles from "./LobbyView.module.css";
|
import styles from "./LobbyView.module.css";
|
||||||
@@ -34,9 +33,10 @@ import { type MatrixInfo, VideoPreview } from "./VideoPreview";
|
|||||||
import { type MuteStates } from "../state/MuteStates";
|
import { type MuteStates } from "../state/MuteStates";
|
||||||
import { InviteButton } from "../button/InviteButton";
|
import { InviteButton } from "../button/InviteButton";
|
||||||
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
|
||||||
import { useMediaQuery } from "../useMediaQuery";
|
import { useRootSizeMatches } from "../useRootSize";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
import { Link } from "../button/Link";
|
import { LeaveToHomeLink } from "../button/LeaveToHomeLink";
|
||||||
|
import { useLeaveToHome } from "../LeaveToHomeContext";
|
||||||
import { useMediaDevices } from "../MediaDevicesContext";
|
import { useMediaDevices } from "../MediaDevicesContext";
|
||||||
import { ObservableScope } from "../state/ObservableScope";
|
import { ObservableScope } from "../state/ObservableScope";
|
||||||
import { useInitial } from "../useInitial";
|
import { useInitial } from "../useInitial";
|
||||||
@@ -44,7 +44,6 @@ import {
|
|||||||
useTrackProcessor,
|
useTrackProcessor,
|
||||||
useTrackProcessorSync,
|
useTrackProcessorSync,
|
||||||
} from "../livekit/TrackProcessorContext";
|
} from "../livekit/TrackProcessorContext";
|
||||||
import { usePageTitle } from "../usePageTitle";
|
|
||||||
import { getValue } from "../utils/observable";
|
import { getValue } from "../utils/observable";
|
||||||
import { useBehavior } from "../useBehavior";
|
import { useBehavior } from "../useBehavior";
|
||||||
import { CallFooter, type FooterSnapshot } from "../components/CallFooter";
|
import { CallFooter, type FooterSnapshot } from "../components/CallFooter";
|
||||||
@@ -87,7 +86,6 @@ export const LobbyView: FC<Props> = ({
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
usePageTitle(matrixInfo.roomName);
|
|
||||||
useAppBarPrimaryButtonIconKind("back");
|
useAppBarPrimaryButtonIconKind("back");
|
||||||
const audioEnabled = useBehavior(muteStates.audio.enabled$);
|
const audioEnabled = useBehavior(muteStates.audio.enabled$);
|
||||||
const videoEnabled = useBehavior(muteStates.video.enabled$);
|
const videoEnabled = useBehavior(muteStates.video.enabled$);
|
||||||
@@ -111,19 +109,19 @@ export const LobbyView: FC<Props> = ({
|
|||||||
[setSettingsModalOpen],
|
[setSettingsModalOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const navigate = useNavigate();
|
// Leaving the lobby means going back to wherever the user came from, if
|
||||||
const onLeaveClick = useCallback(() => {
|
// there is such a place
|
||||||
navigate("/")?.catch((error) => {
|
const leaveToHome = useLeaveToHome();
|
||||||
logger.error("Failed to navigate to /", error);
|
const hangup =
|
||||||
});
|
confineToRoom || leaveToHome === null ? undefined : leaveToHome;
|
||||||
}, [navigate]);
|
|
||||||
const hangup = confineToRoom ? undefined : onLeaveClick;
|
|
||||||
|
|
||||||
const recentsButtonInFooter = useMediaQuery("(max-height: 500px)");
|
const recentsButtonInFooter = useRootSizeMatches(
|
||||||
|
({ height }) => height <= 500,
|
||||||
|
);
|
||||||
const recentsButton = !confineToRoom && (
|
const recentsButton = !confineToRoom && (
|
||||||
<Link className={styles.recents} to="/">
|
<LeaveToHomeLink className={styles.recents}>
|
||||||
{t("lobby.leave_button")}
|
{t("lobby.leave_button")}
|
||||||
</Link>
|
</LeaveToHomeLink>
|
||||||
);
|
);
|
||||||
|
|
||||||
const devices = useMediaDevices();
|
const devices = useMediaDevices();
|
||||||
@@ -209,7 +207,7 @@ export const LobbyView: FC<Props> = ({
|
|||||||
return (): void => {
|
return (): void => {
|
||||||
footerScope.end();
|
footerScope.end();
|
||||||
};
|
};
|
||||||
}, [devices, hangup, hideHeader, muteStates, onLeaveClick, openSettings]);
|
}, [devices, hangup, hideHeader, muteStates, openSettings]);
|
||||||
|
|
||||||
// TODO: Unify this component with InCallView, so we can get slick joining
|
// TODO: Unify this component with InCallView, so we can get slick joining
|
||||||
// animations and don't have to feel bad about reusing its CSS
|
// animations and don't have to feel bad about reusing its CSS
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
display: inline;
|
display: inline;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
width: 100vw;
|
/* Percentages, not viewport units: the containing block is the element
|
||||||
height: 100vh;
|
Element Call treats as its root, which is the page in the standalone app but
|
||||||
|
the container a host gave us when embedded. */
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
@@ -16,7 +19,7 @@
|
|||||||
animation-name: reaction-up;
|
animation-name: reaction-up;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
position: relative;
|
position: relative;
|
||||||
top: 80vh;
|
top: 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes reaction-up {
|
@keyframes reaction-up {
|
||||||
@@ -24,7 +27,7 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
translate: 0 0;
|
translate: 0 0;
|
||||||
scale: 200%;
|
scale: 200%;
|
||||||
top: 80vh;
|
top: 80%;
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
@@ -48,7 +51,7 @@
|
|||||||
.reaction {
|
.reaction {
|
||||||
font-size: 48pt;
|
font-size: 48pt;
|
||||||
animation-name: reaction-up-reduced;
|
animation-name: reaction-up-reduced;
|
||||||
top: calc(-50vh + (48pt / 2));
|
top: calc(-50% + (48pt / 2));
|
||||||
left: calc(50vw - (48pt / 2)) !important;
|
left: calc(50% - (48pt / 2)) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-98
@@ -6,41 +6,27 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import { type FC, useEffect, useState, type ReactNode, useRef } from "react";
|
||||||
type FC,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
type ReactNode,
|
|
||||||
useRef,
|
|
||||||
type JSX,
|
|
||||||
} from "react";
|
|
||||||
import { type MatrixError } from "matrix-js-sdk";
|
import { type MatrixError } from "matrix-js-sdk";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import {
|
import { UnknownSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
CheckIcon,
|
|
||||||
UnknownSolidIcon,
|
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
|
||||||
|
|
||||||
import { useClientLegacy } from "../ClientContext";
|
import { useClientLegacy } from "../ClientContext";
|
||||||
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
|
import { ErrorPage, FullScreenView, LoadingPage } from "../FullScreenView";
|
||||||
import { RoomAuthView } from "./RoomAuthView";
|
import { RoomAuthView } from "./RoomAuthView";
|
||||||
import { GroupCallView } from "./GroupCallView";
|
import { CallView } from "./CallView";
|
||||||
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
|
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
|
||||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
|
||||||
import { HomePage } from "../home/HomePage";
|
import { HomePage } from "../home/HomePage";
|
||||||
import { widget } from "../widget";
|
|
||||||
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
|
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
|
||||||
import { LobbyView } from "./LobbyView";
|
import { KnockLobbyView } from "./KnockLobbyView";
|
||||||
import { E2eeType } from "../e2ee/e2eeType";
|
|
||||||
import { useProfile } from "../profile/useProfile";
|
import { useProfile } from "../profile/useProfile";
|
||||||
import { useOptInAnalytics } from "../settings/settings";
|
import { useOptInAnalytics } from "../settings/settings";
|
||||||
import { Link } from "../button/Link";
|
import { Link } from "../button/Link";
|
||||||
import { ErrorView } from "../ErrorView";
|
import { ErrorView } from "../ErrorView";
|
||||||
import { useMediaDevices } from "../MediaDevicesContext";
|
import { usePageTitle } from "../usePageTitle";
|
||||||
import { MuteStates } from "../state/MuteStates";
|
import { useRoomName } from "./useRoomName";
|
||||||
import { ObservableScope } from "../state/ObservableScope";
|
|
||||||
import { calculateInitialMuteState } from "../state/initialMuteState.ts";
|
|
||||||
|
|
||||||
export const RoomPage: FC = (): ReactNode => {
|
export const RoomPage: FC = (): ReactNode => {
|
||||||
const urlParams = useUrlParams();
|
const urlParams = useUrlParams();
|
||||||
@@ -61,31 +47,25 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
const { avatarUrl, displayName: userDisplayName } = useProfile(client);
|
const { avatarUrl, displayName: userDisplayName } = useProfile(client);
|
||||||
|
|
||||||
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
|
const groupCallState = useLoadGroupCall(client, roomIdOrAlias, viaServers);
|
||||||
const [joined, setJoined] = useState(false);
|
|
||||||
|
|
||||||
const devices = useMediaDevices();
|
// The page title is the page's to set, not the call's: a host embedding the
|
||||||
const [muteStates, setMuteStates] = useState<MuteStates | null>(null);
|
// call has a title of its own. So it is set here, for whichever room we have
|
||||||
|
// got as far as knowing about.
|
||||||
useEffect(() => {
|
const roomName = useRoomName(
|
||||||
const scope = new ObservableScope();
|
groupCallState.kind === "loaded" ? groupCallState.rtcSession.room : null,
|
||||||
setMuteStates(
|
);
|
||||||
new MuteStates(
|
usePageTitle(
|
||||||
scope,
|
roomName ??
|
||||||
devices,
|
(groupCallState.kind === "canKnock" ||
|
||||||
calculateInitialMuteState(
|
groupCallState.kind === "waitForInvite"
|
||||||
urlParams.skipLobby,
|
? groupCallState.roomSummary.name
|
||||||
urlParams.callIntent,
|
: undefined),
|
||||||
widget !== null,
|
);
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return (): void => scope.end();
|
|
||||||
}, [devices, urlParams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If we've finished loading, are not already authed and we've been given a display name as
|
// If we've finished loading, are not already authed and we've been given a display name as
|
||||||
// a URL param, automatically register a passwordless user
|
// a URL param, automatically register a passwordless user
|
||||||
if (!loading && !authenticated && displayName && !widget) {
|
if (!loading && !authenticated && displayName && !urlParams.isWidget) {
|
||||||
setIsRegistering(true);
|
setIsRegistering(true);
|
||||||
registerPasswordlessUser(displayName)
|
registerPasswordlessUser(displayName)
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -99,6 +79,7 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
loading,
|
loading,
|
||||||
authenticated,
|
authenticated,
|
||||||
displayName,
|
displayName,
|
||||||
|
urlParams.isWidget,
|
||||||
setIsRegistering,
|
setIsRegistering,
|
||||||
registerPasswordlessUser,
|
registerPasswordlessUser,
|
||||||
]);
|
]);
|
||||||
@@ -121,67 +102,34 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
switch (groupCallState.kind) {
|
switch (groupCallState.kind) {
|
||||||
case "loaded":
|
case "loaded":
|
||||||
return (
|
return (
|
||||||
muteStates && (
|
<CallView
|
||||||
<GroupCallView
|
client={client!}
|
||||||
widget={widget}
|
rtcSession={groupCallState.rtcSession}
|
||||||
client={client!}
|
isPasswordlessUser={passwordlessUser}
|
||||||
rtcSession={groupCallState.rtcSession}
|
confineToRoom={confineToRoom}
|
||||||
joined={joined}
|
preload={preload}
|
||||||
setJoined={setJoined}
|
skipLobby={skipLobby || wasInWaitForInviteState.current}
|
||||||
isPasswordlessUser={passwordlessUser}
|
/>
|
||||||
confineToRoom={confineToRoom}
|
|
||||||
preload={preload}
|
|
||||||
skipLobby={skipLobby || wasInWaitForInviteState.current}
|
|
||||||
muteStates={muteStates}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
case "waitForInvite":
|
case "waitForInvite":
|
||||||
case "canKnock": {
|
case "canKnock": {
|
||||||
wasInWaitForInviteState.current =
|
wasInWaitForInviteState.current =
|
||||||
wasInWaitForInviteState.current ||
|
wasInWaitForInviteState.current ||
|
||||||
groupCallState.kind === "waitForInvite";
|
groupCallState.kind === "waitForInvite";
|
||||||
const knock =
|
|
||||||
groupCallState.kind === "canKnock" ? groupCallState.knock : null;
|
|
||||||
const label: string | JSX.Element =
|
|
||||||
groupCallState.kind === "canKnock" ? (
|
|
||||||
t("lobby.ask_to_join")
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{t("lobby.waiting_for_invite")}
|
|
||||||
<CheckIcon />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
muteStates && (
|
<KnockLobbyView
|
||||||
<LobbyView
|
client={client!}
|
||||||
client={client!}
|
roomSummary={groupCallState.roomSummary}
|
||||||
matrixInfo={{
|
profile={{
|
||||||
userId: client!.getUserId() ?? "",
|
displayName: userDisplayName ?? "",
|
||||||
displayName: userDisplayName ?? "",
|
avatarUrl: avatarUrl ?? "",
|
||||||
avatarUrl: avatarUrl ?? "",
|
}}
|
||||||
roomAlias: null,
|
knock={
|
||||||
roomId: groupCallState.roomSummary.room_id,
|
groupCallState.kind === "canKnock" ? groupCallState.knock : null
|
||||||
roomName: groupCallState.roomSummary.name ?? "",
|
}
|
||||||
roomAvatar: groupCallState.roomSummary.avatar_url ?? null,
|
confineToRoom={confineToRoom}
|
||||||
e2eeSystem: {
|
hideHeader={header !== "standard"}
|
||||||
kind: groupCallState.roomSummary[
|
/>
|
||||||
"im.nheko.summary.encryption"
|
|
||||||
]
|
|
||||||
? E2eeType.PER_PARTICIPANT
|
|
||||||
: E2eeType.NONE,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onEnter={(): void => knock?.()}
|
|
||||||
enterLabel={label}
|
|
||||||
waitingForInvite={groupCallState.kind === "waitForInvite"}
|
|
||||||
confineToRoom={confineToRoom}
|
|
||||||
hideHeader={header !== "standard"}
|
|
||||||
participantCount={null}
|
|
||||||
muteStates={muteStates}
|
|
||||||
onShareClick={null}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "loading":
|
case "loading":
|
||||||
@@ -198,7 +146,6 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
<ErrorView
|
<ErrorView
|
||||||
Icon={UnknownSolidIcon}
|
Icon={UnknownSolidIcon}
|
||||||
title={t("error.call_not_found")}
|
title={t("error.call_not_found")}
|
||||||
widget={widget}
|
|
||||||
>
|
>
|
||||||
<Trans i18nKey="error.call_not_found_description">
|
<Trans i18nKey="error.call_not_found_description">
|
||||||
<p>
|
<p>
|
||||||
@@ -216,7 +163,6 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
<ErrorView
|
<ErrorView
|
||||||
Icon={groupCallState.error.icon}
|
Icon={groupCallState.error.icon}
|
||||||
title={groupCallState.error.message}
|
title={groupCallState.error.message}
|
||||||
widget={widget}
|
|
||||||
>
|
>
|
||||||
<p>{groupCallState.error.messageBody}</p>
|
<p>{groupCallState.error.messageBody}</p>
|
||||||
{groupCallState.error.reason && (
|
{groupCallState.error.reason && (
|
||||||
@@ -230,7 +176,7 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
</FullScreenView>
|
</FullScreenView>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return <ErrorPage widget={widget} error={groupCallState.error} />;
|
return <ErrorPage error={groupCallState.error} />;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return <> </>;
|
return <> </>;
|
||||||
@@ -238,7 +184,7 @@ export const RoomPage: FC = (): ReactNode => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (loading || isRegistering) return <LoadingPage />;
|
if (loading || isRegistering) return <LoadingPage />;
|
||||||
if (error) return <ErrorPage widget={widget} error={error} />;
|
if (error) return <ErrorPage error={error} />;
|
||||||
if (!client) return <RoomAuthView />;
|
if (!client) return <RoomAuthView />;
|
||||||
// TODO: This doesn't belong here, the app routes need to be reworked
|
// TODO: This doesn't belong here, the app routes need to be reworked
|
||||||
if (!roomIdOrAlias) return <HomePage />;
|
if (!roomIdOrAlias) return <HomePage />;
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ Please see LICENSE in the repository root for full details.
|
|||||||
margin-left: var(--content-inset-left);
|
margin-left: var(--content-inset-left);
|
||||||
margin-right: var(--content-inset-right);
|
margin-right: var(--content-inset-right);
|
||||||
min-block-size: 0;
|
min-block-size: 0;
|
||||||
block-size: 50vh;
|
/* Half the height of Element Call's root, not of the viewport: embedded,
|
||||||
|
the two differ (see the note on container units in base.css) */
|
||||||
|
block-size: 50cqh;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border-radius: var(--cpd-space-4x);
|
border-radius: var(--cpd-space-4x);
|
||||||
@@ -71,7 +73,7 @@ video.mirror {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 550px) {
|
@container element-call (max-width: 550px) {
|
||||||
.preview {
|
.preview {
|
||||||
margin-inline: 0;
|
margin-inline: 0;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
|
|||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _leftNav_e4b327"
|
class="_nav_e4b327 _leftNav_e4b327"
|
||||||
>
|
>
|
||||||
<a
|
<button
|
||||||
aria-label="Element Call Home"
|
aria-label="Element Call Home"
|
||||||
class="_headerLogo_e4b327"
|
class="_headerLogo_e4b327"
|
||||||
data-discover="true"
|
type="button"
|
||||||
href="/"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -92,7 +91,7 @@ exports[`ConnectionLostError: Action handling should reset error state 1`] = `
|
|||||||
fill="white"
|
fill="white"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _rightNav_e4b327"
|
class="_nav_e4b327 _rightNav_e4b327"
|
||||||
@@ -169,11 +168,10 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'internal' er
|
|||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _leftNav_e4b327"
|
class="_nav_e4b327 _leftNav_e4b327"
|
||||||
>
|
>
|
||||||
<a
|
<button
|
||||||
aria-label="Element Call Home"
|
aria-label="Element Call Home"
|
||||||
class="_headerLogo_e4b327"
|
class="_headerLogo_e4b327"
|
||||||
data-discover="true"
|
type="button"
|
||||||
href="/"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -250,7 +248,7 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'internal' er
|
|||||||
fill="white"
|
fill="white"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _rightNav_e4b327"
|
class="_nav_e4b327 _rightNav_e4b327"
|
||||||
@@ -326,11 +324,10 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'notAllowed'
|
|||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _leftNav_e4b327"
|
class="_nav_e4b327 _leftNav_e4b327"
|
||||||
>
|
>
|
||||||
<a
|
<button
|
||||||
aria-label="Element Call Home"
|
aria-label="Element Call Home"
|
||||||
class="_headerLogo_e4b327"
|
class="_headerLogo_e4b327"
|
||||||
data-discover="true"
|
type="button"
|
||||||
href="/"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -407,7 +404,7 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'notAllowed'
|
|||||||
fill="white"
|
fill="white"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _rightNav_e4b327"
|
class="_nav_e4b327 _rightNav_e4b327"
|
||||||
@@ -483,11 +480,10 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'serverUnreac
|
|||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _leftNav_e4b327"
|
class="_nav_e4b327 _leftNav_e4b327"
|
||||||
>
|
>
|
||||||
<a
|
<button
|
||||||
aria-label="Element Call Home"
|
aria-label="Element Call Home"
|
||||||
class="_headerLogo_e4b327"
|
class="_headerLogo_e4b327"
|
||||||
data-discover="true"
|
type="button"
|
||||||
href="/"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -564,7 +560,7 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'serverUnreac
|
|||||||
fill="white"
|
fill="white"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _rightNav_e4b327"
|
class="_nav_e4b327 _rightNav_e4b327"
|
||||||
@@ -640,11 +636,10 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'serviceNotFo
|
|||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _leftNav_e4b327"
|
class="_nav_e4b327 _leftNav_e4b327"
|
||||||
>
|
>
|
||||||
<a
|
<button
|
||||||
aria-label="Element Call Home"
|
aria-label="Element Call Home"
|
||||||
class="_headerLogo_e4b327"
|
class="_headerLogo_e4b327"
|
||||||
data-discover="true"
|
type="button"
|
||||||
href="/"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -721,7 +716,7 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'serviceNotFo
|
|||||||
fill="white"
|
fill="white"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _rightNav_e4b327"
|
class="_nav_e4b327 _rightNav_e4b327"
|
||||||
@@ -797,11 +792,10 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'timeout' err
|
|||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _leftNav_e4b327"
|
class="_nav_e4b327 _leftNav_e4b327"
|
||||||
>
|
>
|
||||||
<a
|
<button
|
||||||
aria-label="Element Call Home"
|
aria-label="Element Call Home"
|
||||||
class="_headerLogo_e4b327"
|
class="_headerLogo_e4b327"
|
||||||
data-discover="true"
|
type="button"
|
||||||
href="/"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -878,7 +872,7 @@ exports[`LiveKit ConnectionError variants > should display LiveKit 'timeout' err
|
|||||||
fill="white"
|
fill="white"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _rightNav_e4b327"
|
class="_nav_e4b327 _rightNav_e4b327"
|
||||||
@@ -954,11 +948,10 @@ exports[`LiveKit ConnectionError variants > should link to troubleshoot guide wh
|
|||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _leftNav_e4b327"
|
class="_nav_e4b327 _leftNav_e4b327"
|
||||||
>
|
>
|
||||||
<a
|
<button
|
||||||
aria-label="Element Call Home"
|
aria-label="Element Call Home"
|
||||||
class="_headerLogo_e4b327"
|
class="_headerLogo_e4b327"
|
||||||
data-discover="true"
|
type="button"
|
||||||
href="/"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -1035,7 +1028,7 @@ exports[`LiveKit ConnectionError variants > should link to troubleshoot guide wh
|
|||||||
fill="white"
|
fill="white"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _rightNav_e4b327"
|
class="_nav_e4b327 _rightNav_e4b327"
|
||||||
@@ -1100,7 +1093,7 @@ exports[`LiveKit ConnectionError variants > should link to troubleshoot guide wh
|
|||||||
</DocumentFragment>
|
</DocumentFragment>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`should have a close button in widget mode 1`] = `
|
exports[`should have a close button when the host can dismiss us 1`] = `
|
||||||
<DocumentFragment>
|
<DocumentFragment>
|
||||||
<div
|
<div
|
||||||
class="_page_4be5c0"
|
class="_page_4be5c0"
|
||||||
@@ -1111,11 +1104,10 @@ exports[`should have a close button in widget mode 1`] = `
|
|||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _leftNav_e4b327"
|
class="_nav_e4b327 _leftNav_e4b327"
|
||||||
>
|
>
|
||||||
<a
|
<button
|
||||||
aria-label="Element Call Home"
|
aria-label="Element Call Home"
|
||||||
class="_headerLogo_e4b327"
|
class="_headerLogo_e4b327"
|
||||||
data-discover="true"
|
type="button"
|
||||||
href="/"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -1192,7 +1184,7 @@ exports[`should have a close button in widget mode 1`] = `
|
|||||||
fill="white"
|
fill="white"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _rightNav_e4b327"
|
class="_nav_e4b327 _rightNav_e4b327"
|
||||||
@@ -1265,11 +1257,10 @@ exports[`should render the error page with link back to home 1`] = `
|
|||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _leftNav_e4b327"
|
class="_nav_e4b327 _leftNav_e4b327"
|
||||||
>
|
>
|
||||||
<a
|
<button
|
||||||
aria-label="Element Call Home"
|
aria-label="Element Call Home"
|
||||||
class="_headerLogo_e4b327"
|
class="_headerLogo_e4b327"
|
||||||
data-discover="true"
|
type="button"
|
||||||
href="/"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -1346,7 +1337,7 @@ exports[`should render the error page with link back to home 1`] = `
|
|||||||
fill="white"
|
fill="white"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _rightNav_e4b327"
|
class="_nav_e4b327 _rightNav_e4b327"
|
||||||
@@ -1419,11 +1410,10 @@ exports[`should report correct error for 'Call is not supported' 1`] = `
|
|||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _leftNav_e4b327"
|
class="_nav_e4b327 _leftNav_e4b327"
|
||||||
>
|
>
|
||||||
<a
|
<button
|
||||||
aria-label="Element Call Home"
|
aria-label="Element Call Home"
|
||||||
class="_headerLogo_e4b327"
|
class="_headerLogo_e4b327"
|
||||||
data-discover="true"
|
type="button"
|
||||||
href="/"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -1500,7 +1490,7 @@ exports[`should report correct error for 'Call is not supported' 1`] = `
|
|||||||
fill="white"
|
fill="white"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _rightNav_e4b327"
|
class="_nav_e4b327 _rightNav_e4b327"
|
||||||
@@ -1573,11 +1563,10 @@ exports[`should report correct error for 'Connection lost' 1`] = `
|
|||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _leftNav_e4b327"
|
class="_nav_e4b327 _leftNav_e4b327"
|
||||||
>
|
>
|
||||||
<a
|
<button
|
||||||
aria-label="Element Call Home"
|
aria-label="Element Call Home"
|
||||||
class="_headerLogo_e4b327"
|
class="_headerLogo_e4b327"
|
||||||
data-discover="true"
|
type="button"
|
||||||
href="/"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -1654,7 +1643,7 @@ exports[`should report correct error for 'Connection lost' 1`] = `
|
|||||||
fill="white"
|
fill="white"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _rightNav_e4b327"
|
class="_nav_e4b327 _rightNav_e4b327"
|
||||||
@@ -1731,11 +1720,10 @@ exports[`should report correct error for 'Homeserver does not support Matrix 2.
|
|||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _leftNav_e4b327"
|
class="_nav_e4b327 _leftNav_e4b327"
|
||||||
>
|
>
|
||||||
<a
|
<button
|
||||||
aria-label="Element Call Home"
|
aria-label="Element Call Home"
|
||||||
class="_headerLogo_e4b327"
|
class="_headerLogo_e4b327"
|
||||||
data-discover="true"
|
type="button"
|
||||||
href="/"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -1812,7 +1800,7 @@ exports[`should report correct error for 'Homeserver does not support Matrix 2.
|
|||||||
fill="white"
|
fill="white"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _rightNav_e4b327"
|
class="_nav_e4b327 _rightNav_e4b327"
|
||||||
@@ -1885,11 +1873,10 @@ exports[`should report correct error for 'Incompatible browser' 1`] = `
|
|||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _leftNav_e4b327"
|
class="_nav_e4b327 _leftNav_e4b327"
|
||||||
>
|
>
|
||||||
<a
|
<button
|
||||||
aria-label="Element Call Home"
|
aria-label="Element Call Home"
|
||||||
class="_headerLogo_e4b327"
|
class="_headerLogo_e4b327"
|
||||||
data-discover="true"
|
type="button"
|
||||||
href="/"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -1966,7 +1953,7 @@ exports[`should report correct error for 'Incompatible browser' 1`] = `
|
|||||||
fill="white"
|
fill="white"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _rightNav_e4b327"
|
class="_nav_e4b327 _rightNav_e4b327"
|
||||||
@@ -2034,11 +2021,10 @@ exports[`should report correct error for 'Insufficient capacity' 1`] = `
|
|||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _leftNav_e4b327"
|
class="_nav_e4b327 _leftNav_e4b327"
|
||||||
>
|
>
|
||||||
<a
|
<button
|
||||||
aria-label="Element Call Home"
|
aria-label="Element Call Home"
|
||||||
class="_headerLogo_e4b327"
|
class="_headerLogo_e4b327"
|
||||||
data-discover="true"
|
type="button"
|
||||||
href="/"
|
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -2115,7 +2101,7 @@ exports[`should report correct error for 'Insufficient capacity' 1`] = `
|
|||||||
fill="white"
|
fill="white"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="_nav_e4b327 _rightNav_e4b327"
|
class="_nav_e4b327 _rightNav_e4b327"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ exports[`InCallView > rendering > renders 1`] = `
|
|||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
class="_inRoom_4e7ff8"
|
class="_inRoom_4e7ff8"
|
||||||
|
data-layout="one-on-one-desktop"
|
||||||
>
|
>
|
||||||
<header
|
<header
|
||||||
class="_header_e4b327 _header_4e7ff8"
|
class="_header_e4b327 _header_4e7ff8"
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ exports[`LobbyView > renders with AppBar android 1`] = `
|
|||||||
class="_link_13esb_8"
|
class="_link_13esb_8"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="md"
|
data-size="md"
|
||||||
href="/"
|
href="#"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
Back to recents
|
Back to recents
|
||||||
@@ -320,7 +320,7 @@ exports[`LobbyView > renders with AppBar ios 1`] = `
|
|||||||
class="_link_13esb_8"
|
class="_link_13esb_8"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="md"
|
data-size="md"
|
||||||
href="/"
|
href="#"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
Back to recents
|
Back to recents
|
||||||
@@ -598,7 +598,7 @@ exports[`LobbyView > renders with header and participant count 1`] = `
|
|||||||
class="_link_13esb_8"
|
class="_link_13esb_8"
|
||||||
data-kind="primary"
|
data-kind="primary"
|
||||||
data-size="md"
|
data-size="md"
|
||||||
href="/"
|
href="#"
|
||||||
rel="noreferrer noopener"
|
rel="noreferrer noopener"
|
||||||
>
|
>
|
||||||
Back to recents
|
Back to recents
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
EndCallIcon,
|
EndCallIcon,
|
||||||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||||
|
|
||||||
import { widget } from "../widget";
|
import { useUrlParams } from "../UrlParams";
|
||||||
|
|
||||||
export type GroupCallLoaded = {
|
export type GroupCallLoaded = {
|
||||||
kind: "loaded";
|
kind: "loaded";
|
||||||
@@ -132,6 +132,7 @@ export const useLoadGroupCall = (
|
|||||||
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
|
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
|
||||||
const activeRoom = useRef<Room | undefined>(undefined);
|
const activeRoom = useRef<Room | undefined>(undefined);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { isWidget } = useUrlParams();
|
||||||
|
|
||||||
const bannedError = useCallback(
|
const bannedError = useCallback(
|
||||||
(): CallTerminatedMessage =>
|
(): CallTerminatedMessage =>
|
||||||
@@ -249,7 +250,7 @@ export const useLoadGroupCall = (
|
|||||||
// room already joined so we are done here already.
|
// room already joined so we are done here already.
|
||||||
return room!;
|
return room!;
|
||||||
}
|
}
|
||||||
if (widget)
|
if (isWidget)
|
||||||
// in widget mode we never should reach this point. (getRoom should return the room.)
|
// in widget mode we never should reach this point. (getRoom should return the room.)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Room not found. The widget-api did not pass over the relevant room events/information.",
|
"Room not found. The widget-api did not pass over the relevant room events/information.",
|
||||||
@@ -373,6 +374,7 @@ export const useLoadGroupCall = (
|
|||||||
}, [
|
}, [
|
||||||
bannedError,
|
bannedError,
|
||||||
client,
|
client,
|
||||||
|
isWidget,
|
||||||
knockRejectError,
|
knockRejectError,
|
||||||
removeNoticeError,
|
removeNoticeError,
|
||||||
roomIdOrAlias,
|
roomIdOrAlias,
|
||||||
|
|||||||
+21
-8
@@ -6,14 +6,27 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { type Room, RoomEvent } from "matrix-js-sdk";
|
import { type Room, RoomEvent } from "matrix-js-sdk";
|
||||||
import { useCallback } from "react";
|
import { useCallback, useSyncExternalStore } from "react";
|
||||||
|
|
||||||
import { useTypedEventEmitterState } from "../useEvents";
|
/**
|
||||||
|
* The room's name, kept up to date. Null when there is no room yet, for a
|
||||||
export function useRoomName(room: Room): string {
|
* caller that only sometimes has one.
|
||||||
return useTypedEventEmitterState(
|
*/
|
||||||
room,
|
export function useRoomName(room: Room): string;
|
||||||
RoomEvent.Name,
|
export function useRoomName(room: Room | null): string | null;
|
||||||
useCallback(() => room.name, [room]),
|
export function useRoomName(room: Room | null): string | null {
|
||||||
|
const subscribe = useCallback(
|
||||||
|
(onChange: () => void) => {
|
||||||
|
if (room === null) return (): void => {};
|
||||||
|
room.on(RoomEvent.Name, onChange);
|
||||||
|
return (): void => {
|
||||||
|
room.off(RoomEvent.Name, onChange);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[room],
|
||||||
|
);
|
||||||
|
return useSyncExternalStore(
|
||||||
|
subscribe,
|
||||||
|
useCallback(() => room?.name ?? null, [room]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
pre {
|
.pre {
|
||||||
font-size: var(--font-size-micro);
|
font-size: var(--font-size-micro);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -680,9 +680,9 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
|||||||
<AudioProcessingSettings />
|
<AudioProcessingSettings />
|
||||||
<Separator />
|
<Separator />
|
||||||
<p>{t("developer_mode.environment_variables")}</p>
|
<p>{t("developer_mode.environment_variables")}</p>
|
||||||
<pre>{JSON.stringify(env, null, 2)}</pre>
|
<pre className={styles.pre}>{JSON.stringify(env, null, 2)}</pre>
|
||||||
<p>{t("developer_mode.url_params")}</p>
|
<p>{t("developer_mode.url_params")}</p>
|
||||||
<pre>{JSON.stringify(urlParams, null, 2)}</pre>
|
<pre className={styles.pre}>{JSON.stringify(urlParams, null, 2)}</pre>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { ProfileSettingsTab } from "./ProfileSettingsTab";
|
|||||||
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
|
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
|
||||||
import { iosDeviceMenu$ } from "../state/MediaDevices";
|
import { iosDeviceMenu$ } from "../state/MediaDevices";
|
||||||
import { useMediaDevices } from "../MediaDevicesContext";
|
import { useMediaDevices } from "../MediaDevicesContext";
|
||||||
import { widget } from "../widget";
|
import { useHostBridge } from "../HostBridge";
|
||||||
import {
|
import {
|
||||||
useSetting,
|
useSetting,
|
||||||
soundEffectVolume as soundEffectVolumeSetting,
|
soundEffectVolume as soundEffectVolumeSetting,
|
||||||
@@ -123,6 +123,7 @@ export const SettingsModal: FC<Props> = ({
|
|||||||
// On EC, we decided that it is less confusing for the user if they see those options in the output section
|
// On EC, we decided that it is less confusing for the user if they see those options in the output section
|
||||||
// rather than the input section.
|
// rather than the input section.
|
||||||
const { controlledAudioDevices } = useUrlParams();
|
const { controlledAudioDevices } = useUrlParams();
|
||||||
|
const hostBridge = useHostBridge();
|
||||||
// If we are on iOS we will show a button to open the native audio device picker.
|
// If we are on iOS we will show a button to open the native audio device picker.
|
||||||
const iosDeviceMenu = useBehavior(iosDeviceMenu$);
|
const iosDeviceMenu = useBehavior(iosDeviceMenu$);
|
||||||
|
|
||||||
@@ -234,7 +235,8 @@ export const SettingsModal: FC<Props> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tabs = [audioTab, videoTab];
|
const tabs = [audioTab, videoTab];
|
||||||
if (widget === null) tabs.push(profileTab);
|
// The profile is only ours to edit when the account is ours
|
||||||
|
if (hostBridge.supportsProfileChanges) tabs.push(profileTab);
|
||||||
tabs.push(preferencesTab);
|
tabs.push(preferencesTab);
|
||||||
if (isRageshakeAvailable || import.meta.env.VITE_PACKAGE === "full") {
|
if (isRageshakeAvailable || import.meta.env.VITE_PACKAGE === "full") {
|
||||||
// for full package we want to show the analytics consent checkbox
|
// for full package we want to show the analytics consent checkbox
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user