M1 — The component + a local dev harness - #4233
Draft
BillCarsonFr wants to merge 12 commits into
Draft
Conversation
Being in a call and deciding to show one are different jobs, but RoomPage did both: routing, authentication, resolving room aliases and knocking, and then the call itself. Only the first set belongs to whatever is hosting Element Call. Add ElementCallView as the seam between them. It takes a client and a call to join, and owns whether the user has joined — which is the call's own business rather than its host's. RoomPage keeps everything about arriving at a call and renders this for the call itself. Nothing else moves yet. muteStates is still passed in, because the standalone shell shares one with the lobby it shows while waiting to be let into a room, and two instances would both report the user's mute state to the host.
Six selectors named `body` directly — the gradient backdrop and the platform font overrides in index.css, and the iOS adjustments in AppBar.module.css and Modal.module.css — so they only applied when Element Call owned the page. A host mounting it into a container would have got an interface decorated correctly and styled incorrectly, with nothing to show that anything was wrong. Mark the root element with data-element-call-root and match on that instead. Scoping this way keeps the selectors more specific than they were, rather than less: widening them to a bare [data-platform=…] would have dropped specificity from (0,1,1) to (0,1,0) and changed which rules win. The platform attribute moves with them, from the initializer's write onto document.body to a layout effect on the root, alongside the theme — so it still lands before anything is painted. No visual change while Element Call owns the page: the root is the body, which now carries the attribute, so every rewritten selector matches the element it always did.
ElementCallView took muteStates as a prop, which would have meant an embedder building one before it could show a call. It could not simply make its own: RoomPage created one on mount whichever branch it went on to render, and two would both report the user's mute state to the host, talking over each other. Move the construction into a useMuteStates hook, and give the lobby shown while waiting to be let into a room its own component. Each lobby now holds mute state only while it is on screen, so there is never a second one, and the component can own the call's. KnockLobbyView also takes the room summary and label handling that RoomPage was assembling on its behalf, leaving the page with arriving at a call rather than being in one.
Adds component/index.tsx as a fourth build target: <ElementCall client roomId /> and an initializeElementCall to await once beforehand. It gives Element Call everything it would otherwise take from the page it is on — the parameters, the host bridge, media devices, translations, a container to confine itself to — and hands it the host's client rather than finding one of its own. React, the Matrix SDK and LiveKit stay external, since the host has them and a second copy of any would not merely be wasteful: React would hold two sets of hooks and the client would run two sync loops. Every subpath has to be listed by name, because the pattern and callback forms of rollupOptions.external are silently ignored here — a lesson worth the comment that records it. Element Call's own navigation runs in a MemoryRouter, so being embedded cannot disturb the host's URL. ClientContext and GroupCallView both navigate, so some router has to be present. The bundle is not yet a reasonable size: library mode base64-inlines assets referenced through import.meta.url, so MediaPipe's vision runtime lands in it whole. Left for its own change, since the fix — loading the background blur transformer lazily — is worth doing for the standalone app too.
The component built and typechecked in the previous commit, but only because nothing had rendered it. Everything Element Call needs that `src/main.tsx` side-loads was missing from it. Its stylesheet: only main.tsx imported index.css, so the library build emitted CSS-module styles with every `--cpd-*` and `--font-size-*` unresolved. Split into base.css, which both the app and the component import, and the rules that are about owning a page, which only the app does. The split is a straight move — comment-stripped and sorted, the old file and the two new ones differ by exactly one line — and that line is the deliberate part: `.no-scroll-body` becomes `body.no-scroll-body`. Element Call adds that class to whatever it treats as its root, and since the root can now be a container, `position: fixed` would have taken that container out of the host's layout. Pinning the page is what it always meant. Its translations: `initializeElementCall` called `i18n.init` with neither resources nor a backend, so every key would have rendered as itself. English is bundled in. The app fetches locale files from URLs its own build emits, which a host serving the library from somewhere else could not resolve, so how a host picks a language is left open. And the types a host needs: `HostBridge` alone is not enough to implement `HostBridge` — `HostRequest`, `DeviceMuteState`, `DeviceMuteRequest` and `JoinCallData` all appear in its signatures, and `ConfigOptions` in `initializeElementCall`'s.
`pnpm dev:component` serves a page that stands in for a host application: it signs in twice against the development backend and shows two calls side by side, in resizable boxes, with furniture of its own around them. Two devices of one account, so a real call happens between the two components and anything Element Call keeps once per process rather than once per call shows itself. The host bridge is driven by hand and reports both directions in a log along the bottom, which is the first exercise the theme, hang-up and device-mute requests have had outside widget mode. Each pane can be unmounted and remounted to see what Element Call leaves behind, and there is a `position: fixed` dialog belonging to the host to see whether it covers the calls. The page uses none of Element Call's design tokens, so anything that looks styled outside a pane came from Element Call reaching out of its container. It reaches Element Call only through the component's public interface, which is how the exports missing from that interface came to light. Three things about the component build the harness turned up on the way, all too small to be worth their own commits: - It copied `public/` into `dist/`, including the developer's own gitignored config.json, into output we would publish. `publicDir: false`, as the embedded build already does. The sdk build has the same leak; untouched. - `pnpm lint:externals` now exists, which the build config already claimed it did. It reads the external list out of that config and fails if the source imports React, the Matrix SDK or LiveKit by a path the list does not name. Since the bundler silently ignores the pattern form of that option, an unnamed subpath is bundled with no warning at all — which is how a host would end up with a second React. - `lint:oxlint` ran over `src playwright`, so nothing in `component/` had ever been linted. Serving a page also meant the shared plugin list could no longer inject the app's HTML entry point unconditionally, so that is now optional — and off for the library build too, which never had an HTML page to inject it into.
The component seeded its parameters with `computeUrlParams()`, which reads `window.location`. Element Call is not the page any more, so what it found there was the host's URL: no `widgetId`, therefore not a widget, therefore no intent, therefore the standalone app's preset. The visible symptom was the Element Call logo in the footer of a call embedded in someone else's application, since `showLogo` follows `header === HeaderStyle.Standard`. The rest of that preset mattered more. A hosted call defaulted to `perParticipantE2EE: false` and `confineToRoom: false` — so unencrypted, and willing to take the user out of the room a host had put them in — and a host could not correct either without naming every parameter itself, nor even name `header`, since the enums were not exported. So the intent presets move out of `computeUrlParams` into `configurationForIntent`, and the component builds its parameters from an intent plus the properties a host has no URL to supply. `intent` becomes a prop, defaulting to joining an existing group call: the lobby first, confined to the room, encrypted per participant, and no Element Call branding in someone else's interface. A host that knows which button the user pressed should say which. One deliberate difference from the widget presets: the background defaults to solid rather than the gradient, which is drawn by a `position: fixed` pseudo-element and would escape the container to cover the host. `UserIntent.Unknown` still means the standalone preset, so the app and widget are unchanged — including a widget that names no intent, which has always been given those defaults.
Opening settings in an embedded call put the dialog in the middle of the host's window, spilling outside the container, and with two calls on a page the second drew over the first's dialog. The container established a stacking context but not a containing block, which are two different things and only the first had been done. `position: fixed` resolves against the viewport unless an ancestor makes itself the containing block, so the modal scrim and dialog in Overlay.module.css — `fixed`, `inset: 0`, centred, because in the standalone app they are meant to cover the page — were positioned and sized against the window. Layout and paint containment makes the container the containing block for those descendants and clips what we paint to our own box. The second-call-on-top symptom goes with it, since the dialog now stays inside the first call's box and there is nothing to overlap. Two sibling components still cannot draw over one another by construction, neither being able to leave its own stacking context, but that only shows if a host overlaps them. Containment clips a box measured in viewport units but cannot resize it, so anything positioned that way is simply put somewhere outside the container and disappears. That applied to the reactions overlay, a `100vw` by `100vh` box, and to the reaction picker, which sits at `82vh` so as to appear near the footer it belongs to. Both are positioned against whatever Element Call treats as its root — `[data-overlay-container]` in the app, which is the size of the page, and the host's container when embedded — so percentages mean the same thing there and the right thing here. The earpiece overlay is `inset: 0` with no viewport units, so containment is enough for it. Three viewport-relative sizes remain, all of which need more than a change of unit: the lobby's video preview is `50vh` on a flex item with an aspect ratio, `--content-inset-*` ramps up to a desktop inset from the window width, and the picker's `max-width` cap is the window's. 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 an embedded component makes.
The widget tests cannot reach what makes a component different, because the iframe used to guarantee it: that Element Call stays inside the space it was given, and that two of them can exist in one page. So the development harness gets driven by Playwright. Three tests. Two components in one page holding a real call between two devices of one account. The settings dialog and the reaction picker staying inside the container the host gave, asserted by bounding box. And the host bridge reporting in both directions, including a host-initiated mute coming back as a report of the new state. The containment test is the one worth having: both of the escapes found by running the harness by hand — the settings dialog centred on the window, and the reaction picker at 82vh landing below the container — would have failed it, and neither was visible to typechecking, linting or the unit tests. Users and rooms are created through the Synapse admin and client-server APIs rather than by driving an interface, and the harness now takes its credentials from its own query string so that a test can say which account and room to use. A host reading its own URL is proper; it was Element Call doing so that was the mistake. Playwright gains a second web server for the harness on port 3001, a Vite dev server whether or not the app itself is served from Docker, since the harness is a development page with nothing to build.
Contributor
|
@toger5 "takes over" this PR. |
`component/package.json` describes the built component as a package (`@element-hq/element-call-component`: entry, stylesheet subpath, types, peer dependencies) so that a host can depend on `github:element-hq/element-call#<ref>&path:/component`. Its `prepare` script builds on install, since nothing is published yet. For that the component build now lands in `component/dist` instead of the repository's `dist`, and `pnpm build:component` also emits the type declarations (`component/tsconfig.build.json`, `build:component:types`), which previously had to be produced by hand. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Important
Features and UI changes require a pre-approved issue.
Every PR must have a linked issue
that a maintainer has reviewed and approved before you started writing code.
PRs that don't meet this requirement will not be reviewed.
See CONTRIBUTING.md for ElementCall decided for this approach.
Content
Motivation and context
Screenshots / GIFs
Tests
Checklist