Files
element-call-Github/component/dev/DevHostBridge.ts
T
Valere 9f5fa6049f Add a harness for Element Call embedded as a component
`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.
2026-09-03 16:11:39 +02:00

104 lines
3.3 KiB
TypeScript

/*
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 { NEVER, Subject } from "rxjs";
import {
type DeviceMuteRequest,
type DeviceMuteState,
type HostBridge,
type HostRequest,
} from "../index";
/**
* A host bridge that reports everything it is told and can be driven by hand,
* so that the harness can watch both directions of the conversation between
* Element Call and its host.
*/
export interface DevHostBridge extends HostBridge {
/** Tells Element Call the host has changed theme. */
requestTheme(name: string): void;
/** Tells Element Call to leave the call. */
requestHangUp(): void;
/** Asks Element Call to change, and report back, its mute state. */
requestDeviceMute(request: DeviceMuteRequest): void;
}
export function createDevHostBridge(
log: (message: string) => void,
/** What the host does when Element Call asks to be closed. */
onClose: () => void,
): DevHostBridge {
const themeChange$ = new Subject<HostRequest<{ name?: string }>>();
const hangUp$ = new Subject<HostRequest<Record<string, never>>>();
const deviceMute$ = new Subject<
HostRequest<DeviceMuteRequest, DeviceMuteState>
>();
const ask = <Data, Reply>(
subject: Subject<HostRequest<Data, Reply>>,
name: string,
data: Data,
): void => {
// Worth saying out loud: a request nobody is subscribed to is silently
// dropped, and that is exactly the sort of thing the harness is for.
if (!subject.observed) {
log(`← ${name}: nothing is listening`);
return;
}
log(`← ${name}`);
subject.next({
data,
reply: (reply): void =>
log(
`→ ${name} acknowledged${reply === undefined ? "" : `: ${JSON.stringify(reply)}`}`,
),
});
};
/**
* 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();
},
themeChange$,
// The harness does not preload a call, so this is never asked for
join$: NEVER,
hangUp$,
deviceMute$,
supportsReactions: true,
requestTheme: (name): void =>
ask(themeChange$, `themeChange(${name})`, { name }),
requestHangUp: (): void => ask(hangUp$, "hangUp", {}),
requestDeviceMute: (request): void =>
ask(deviceMute$, `deviceMute(${JSON.stringify(request)})`, request),
};
}