mirror of
https://github.com/element-hq/element-call.git
synced 2026-09-10 22:25:41 +00:00
The theme is state — what Element Call should look like right now — and so belongs beside `language` as a prop, not on the imperative handle (where it was a request, `setTheme`, because the internal host bridge speaks the widget API and a widget's host sends theme changes as requests) and not in the configuration (where `config.theme` only ever set the starting theme). The `theme` prop feeds the same channel the rest of Element Call listens to for a host's theme, replayed so that whatever subscribes after the host has set it still hears the current one. Changing it re-themes the container and nothing else; unlike the language, it is per component. `setTheme` and `config.theme` are gone, and the harness gets a theme picker in place of its per-pane buttons. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
381 lines
11 KiB
TypeScript
381 lines
11 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,
|
|
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>
|
|
);
|
|
};
|