mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
`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.
299 lines
8.6 KiB
TypeScript
299 lines
8.6 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 {
|
|
type FC,
|
|
type FormEvent,
|
|
type ReactNode,
|
|
useCallback,
|
|
useMemo,
|
|
useState,
|
|
} from "react";
|
|
import { type MatrixClient } from "matrix-js-sdk";
|
|
import { logger } from "matrix-js-sdk/lib/logger";
|
|
|
|
import { ElementCall } 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 last credentials used, so that a reload does not mean typing them again. */
|
|
function loadCredentials(): Credentials {
|
|
try {
|
|
const stored = localStorage.getItem(CREDENTIALS_KEY);
|
|
if (stored !== null)
|
|
return { ...DEFAULT_CREDENTIALS, ...(JSON.parse(stored) as Credentials) };
|
|
} catch (e) {
|
|
logger.warn("Could not read the stored harness credentials", e);
|
|
}
|
|
return DEFAULT_CREDENTIALS;
|
|
}
|
|
|
|
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 embedded Element Call, 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;
|
|
log: (pane: string, message: string) => void;
|
|
}> = ({ session, roomId, log }): ReactNode => {
|
|
const [mounted, setMounted] = useState(true);
|
|
|
|
const bridge = useMemo(
|
|
() =>
|
|
createDevHostBridge(
|
|
(message) => log(session.label, message),
|
|
() => setMounted(false),
|
|
),
|
|
[log, session.label],
|
|
);
|
|
|
|
return (
|
|
<section className={styles.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 => bridge.requestTheme("light")}>
|
|
Light
|
|
</button>
|
|
<button onClick={(): void => bridge.requestTheme("dark")}>Dark</button>
|
|
<button
|
|
onClick={(): void =>
|
|
bridge.requestDeviceMute({ audio_enabled: false })
|
|
}
|
|
>
|
|
Mute
|
|
</button>
|
|
<button onClick={(): void => bridge.requestHangUp()}>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}>
|
|
{mounted && (
|
|
<ElementCall
|
|
client={session.client}
|
|
roomId={roomId}
|
|
hostBridge={bridge}
|
|
/>
|
|
)}
|
|
</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 embedded
|
|
* in a host has to sit underneath this — being unable to is one of the reasons
|
|
* for embedding it rather than putting it in 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 embedding Element Call: 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);
|
|
|
|
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 Element Call embedded 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>
|
|
</header>
|
|
<div className={styles.middle}>
|
|
<HostChrome />
|
|
<main className={styles.panes}>
|
|
{state.sessions.map((session) => (
|
|
<Pane
|
|
key={session.label}
|
|
session={session}
|
|
roomId={state.roomId}
|
|
log={log}
|
|
/>
|
|
))}
|
|
</main>
|
|
</div>
|
|
<section className={styles.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>
|
|
);
|
|
};
|