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
+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)