Make the theme a prop, next to the language

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>
This commit is contained in:
Timo K.
2026-09-08 17:02:24 +02:00
co-authored by Claude Fable 5.1
parent f994586eeb
commit b722cc277e
5 changed files with 89 additions and 48 deletions
+2 -1
View File
@@ -248,7 +248,8 @@ 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).
the tags it accepts). The `theme` prop works the same way for `light` and
`dark`; both can change while a call is running without disturbing it.
The package is not published yet. A host installs it as a git dependency on the
`component` directory of this repository,
+16 -15
View File
@@ -94,9 +94,10 @@ interface LogEntry {
const Pane: FC<{
session: Session;
roomId: string;
theme: string | undefined;
language: string | undefined;
log: (pane: string, message: string) => void;
}> = ({ session, roomId, language, log }): ReactNode => {
}> = ({ session, roomId, theme, language, log }): ReactNode => {
const [mounted, setMounted] = useState(true);
const bridge = useMemo(
@@ -139,20 +140,6 @@ const Pane: FC<{
<button onClick={(): void => setMounted((m) => !m)}>
{mounted ? "Unmount" : "Mount"}
</button>
<button
onClick={(): void =>
ask("setTheme(light)", async (h) => await h.setTheme("light"))
}
>
Light
</button>
<button
onClick={(): void =>
ask("setTheme(dark)", async (h) => await h.setTheme("dark"))
}
>
Dark
</button>
<button
onClick={(): void =>
ask(
@@ -178,6 +165,7 @@ const Pane: FC<{
client={session.client}
roomId={roomId}
hostBridge={bridge}
theme={theme}
language={language}
/>
)}
@@ -235,6 +223,7 @@ export const Harness: FC = (): ReactNode => {
// 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) =>
@@ -333,6 +322,17 @@ export const Harness: FC = (): ReactNode => {
<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&apos;s choice</option>
<option value="light">light</option>
<option value="dark">dark</option>
</select>
</label>
<label>
Language{" "}
<select
@@ -356,6 +356,7 @@ export const Harness: FC = (): ReactNode => {
key={session.label}
session={session}
roomId={state.roomId}
theme={theme}
language={language}
log={log}
/>
+40 -16
View File
@@ -19,7 +19,7 @@ describe("useComponentHostBridge", () => {
test("keeps one identity while the host supplies new objects", () => {
const { result, rerender } = renderHook(
({ supplied }: { supplied: ElementCallHostBridge }) =>
useComponentHostBridge(supplied, undefined),
useComponentHostBridge(supplied, undefined, undefined),
{ initialProps: { supplied: {} } },
);
const first = result.current;
@@ -32,7 +32,7 @@ describe("useComponentHostBridge", () => {
const after = vi.fn().mockResolvedValue(undefined);
const { result, rerender } = renderHook(
({ supplied }: { supplied: ElementCallHostBridge }) =>
useComponentHostBridge(supplied, undefined),
useComponentHostBridge(supplied, undefined, undefined),
{ initialProps: { supplied: { notifyJoined: before } } },
);
rerender({ supplied: { notifyJoined: after } });
@@ -44,7 +44,7 @@ describe("useComponentHostBridge", () => {
test("is quiet about what the host did not implement", async () => {
const { result } = renderHook(() =>
useComponentHostBridge(undefined, undefined),
useComponentHostBridge(undefined, undefined, undefined),
);
await expect(result.current.contentLoaded()).resolves.toBeUndefined();
await expect(
@@ -59,7 +59,7 @@ describe("useComponentHostBridge", () => {
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),
useComponentHostBridge(supplied, undefined, undefined),
{ initialProps: { supplied: {} } },
);
expect(result.current.close).toBeUndefined();
@@ -71,7 +71,7 @@ describe("useComponentHostBridge", () => {
test("never offers profile changes, since the account is the host's", () => {
const { result } = renderHook(() =>
useComponentHostBridge(undefined, undefined),
useComponentHostBridge(undefined, undefined, undefined),
);
expect(result.current.supportsProfileChanges).toBe(false);
});
@@ -80,7 +80,7 @@ describe("useComponentHostBridge", () => {
test("delivers a request to what is listening and resolves on its reply", async () => {
const ref = createRef<ElementCallHandle>();
const { result } = renderHook(() =>
useComponentHostBridge(undefined, ref),
useComponentHostBridge(undefined, ref, undefined),
);
const received = vi.fn();
@@ -97,26 +97,50 @@ describe("useComponentHostBridge", () => {
test("refuses a request nothing in Element Call is listening for", async () => {
const ref = createRef<ElementCallHandle>();
renderHook(() => useComponentHostBridge(undefined, ref));
renderHook(() => useComponentHostBridge(undefined, ref, undefined));
await expect(ref.current!.hangUp()).rejects.toThrow(
"Nothing in Element Call can hang up right now",
);
});
});
test("passes the theme name through", async () => {
const ref = createRef<ElementCallHandle>();
describe("the theme", () => {
test("reaches a subscriber that arrives after it was set", () => {
const { result } = renderHook(() =>
useComponentHostBridge(undefined, ref),
useComponentHostBridge(undefined, undefined, "light"),
);
const names: (string | undefined)[] = [];
result.current.themeChange$.subscribe(({ data, reply }) => {
names.push(data.name);
reply();
});
await ref.current!.setTheme("light");
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([]);
});
});
});
+15 -7
View File
@@ -17,8 +17,8 @@ Please see LICENSE in the repository root for full details.
* call `play()` on a video element. This module adapts the one to the other.
*/
import { type Ref, useImperativeHandle } from "react";
import { Subject } from "rxjs";
import { type Ref, useEffect, useImperativeHandle } from "react";
import { ReplaySubject, Subject } from "rxjs";
import {
type DeviceMuteRequest,
@@ -79,8 +79,6 @@ export interface ElementCallHostBridge {
* when there is no call, say.
*/
export interface ElementCallHandle {
/** Switches Element Call to the named theme, `light` or `dark`. */
setTheme(name: string): Promise<void>;
/**
* Joins the call, when Element Call was configured to `preload` and is
* waiting to be told to. Says which devices to join with.
@@ -119,16 +117,28 @@ async function request<Data, Reply>(
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(() => ({
themeChange$: new Subject<HostRequest<{ name?: string }>>(),
// 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) => {
@@ -175,8 +185,6 @@ export function useComponentHostBridge(
useImperativeHandle(
ref,
(): ElementCallHandle => ({
setTheme: async (name) =>
await request(requests.themeChange$, "change theme", { name }),
join: async (devices) =>
await request(requests.join$, "join a call", devices),
hangUp: async () => await request(requests.hangUp$, "hang up", {}),
+16 -9
View File
@@ -111,14 +111,15 @@ export {
* takes the default that {@link ElementCallProps.intent} implies.
*
* This is the behaviour a widget can be configured with through its URL, plus
* the two facts about the call a host has a say in: the theme to start in and
* 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.
* 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, "theme" | "background">>;
Partial<Pick<UrlProperties, "background">>;
export interface ElementCallProps {
/**
@@ -154,10 +155,15 @@ export interface ElementCallProps {
*/
hostBridge?: ElementCallHostBridge;
/**
* What the host tells Element Call: to change theme, to hang up, to mute.
* Available once the component has rendered.
* 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`
@@ -229,9 +235,10 @@ export const ElementCall: FC<ElementCallProps> = ({
config,
hostBridge: suppliedHostBridge,
ref,
theme,
language,
}): ReactNode => {
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref);
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme);
useEffect(() => {
if (language !== undefined)