diff --git a/README.md b/README.md
index b1c462759..b27602906 100644
--- a/README.md
+++ b/README.md
@@ -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,
diff --git a/component/dev/Harness.tsx b/component/dev/Harness.tsx
index 9e40e2c7a..ded9155a9 100644
--- a/component/dev/Harness.tsx
+++ b/component/dev/Harness.tsx
@@ -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<{
setMounted((m) => !m)}>
{mounted ? "Unmount" : "Mount"}
-
- ask("setTheme(light)", async (h) => await h.setTheme("light"))
- }
- >
- Light
-
-
- ask("setTheme(dark)", async (h) => await h.setTheme("dark"))
- }
- >
- Dark
-
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(undefined);
+ const [theme, setTheme] = useState(undefined);
const log = useCallback((pane: string, message: string): void => {
setEntries((entries) =>
@@ -333,6 +322,17 @@ export const Harness: FC = (): ReactNode => {
setDialogOpen(true)}>
Open a host dialog
+
+ Theme{" "}
+ setTheme(e.target.value || undefined)}
+ >
+ Element Call's choice
+ light
+ dark
+
+
Language{" "}
{
key={session.label}
session={session}
roomId={state.roomId}
+ theme={theme}
language={language}
log={log}
/>
diff --git a/component/host.test.ts b/component/host.test.ts
index dd7883675..c73a034ce 100644
--- a/component/host.test.ts
+++ b/component/host.test.ts
@@ -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();
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();
- 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();
+ 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([]);
+ });
});
});
diff --git a/component/host.ts b/component/host.ts
index 67bbaf0ef..701d92102 100644
--- a/component/host.ts
+++ b/component/host.ts
@@ -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;
/**
* 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(
export function useComponentHostBridge(
supplied: ElementCallHostBridge | undefined,
ref: Ref | 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>(),
+ // 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>(1),
join$: new Subject>(),
hangUp$: new Subject>>(),
deviceMute$: new Subject>(),
}));
+ 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", {}),
diff --git a/component/index.tsx b/component/index.tsx
index 90532966f..39bb1d29b 100644
--- a/component/index.tsx
+++ b/component/index.tsx
@@ -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 &
- Partial>;
+ Partial>;
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;
+ /**
+ * 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 = ({
config,
hostBridge: suppliedHostBridge,
ref,
+ theme,
language,
}): ReactNode => {
- const hostBridge = useComponentHostBridge(suppliedHostBridge, ref);
+ const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme);
useEffect(() => {
if (language !== undefined)