mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
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:
co-authored by
Claude Fable 5.1
parent
f994586eeb
commit
b722cc277e
@@ -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
|
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
|
they are needed. It starts in the browser's language, and follows the host's
|
||||||
own language setting through the `language` prop (`supportedLanguages` lists
|
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
|
The package is not published yet. A host installs it as a git dependency on the
|
||||||
`component` directory of this repository,
|
`component` directory of this repository,
|
||||||
|
|||||||
+16
-15
@@ -94,9 +94,10 @@ interface LogEntry {
|
|||||||
const Pane: FC<{
|
const Pane: FC<{
|
||||||
session: Session;
|
session: Session;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
theme: string | undefined;
|
||||||
language: string | undefined;
|
language: string | undefined;
|
||||||
log: (pane: string, message: string) => void;
|
log: (pane: string, message: string) => void;
|
||||||
}> = ({ session, roomId, language, log }): ReactNode => {
|
}> = ({ session, roomId, theme, language, log }): ReactNode => {
|
||||||
const [mounted, setMounted] = useState(true);
|
const [mounted, setMounted] = useState(true);
|
||||||
|
|
||||||
const bridge = useMemo(
|
const bridge = useMemo(
|
||||||
@@ -139,20 +140,6 @@ const Pane: FC<{
|
|||||||
<button onClick={(): void => setMounted((m) => !m)}>
|
<button onClick={(): void => setMounted((m) => !m)}>
|
||||||
{mounted ? "Unmount" : "Mount"}
|
{mounted ? "Unmount" : "Mount"}
|
||||||
</button>
|
</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
|
<button
|
||||||
onClick={(): void =>
|
onClick={(): void =>
|
||||||
ask(
|
ask(
|
||||||
@@ -178,6 +165,7 @@ const Pane: FC<{
|
|||||||
client={session.client}
|
client={session.client}
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
hostBridge={bridge}
|
hostBridge={bridge}
|
||||||
|
theme={theme}
|
||||||
language={language}
|
language={language}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -235,6 +223,7 @@ export const Harness: FC = (): ReactNode => {
|
|||||||
// The host's language setting, which Element Call follows. Undefined means
|
// The host's language setting, which Element Call follows. Undefined means
|
||||||
// the host has none and Element Call uses the browser's.
|
// the host has none and Element Call uses the browser's.
|
||||||
const [language, setLanguage] = useState<string | undefined>(undefined);
|
const [language, setLanguage] = useState<string | undefined>(undefined);
|
||||||
|
const [theme, setTheme] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const log = useCallback((pane: string, message: string): void => {
|
const log = useCallback((pane: string, message: string): void => {
|
||||||
setEntries((entries) =>
|
setEntries((entries) =>
|
||||||
@@ -333,6 +322,17 @@ export const Harness: FC = (): ReactNode => {
|
|||||||
<button onClick={(): void => setDialogOpen(true)}>
|
<button onClick={(): void => setDialogOpen(true)}>
|
||||||
Open a host dialog
|
Open a host dialog
|
||||||
</button>
|
</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>
|
<label>
|
||||||
Language{" "}
|
Language{" "}
|
||||||
<select
|
<select
|
||||||
@@ -356,6 +356,7 @@ export const Harness: FC = (): ReactNode => {
|
|||||||
key={session.label}
|
key={session.label}
|
||||||
session={session}
|
session={session}
|
||||||
roomId={state.roomId}
|
roomId={state.roomId}
|
||||||
|
theme={theme}
|
||||||
language={language}
|
language={language}
|
||||||
log={log}
|
log={log}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+40
-16
@@ -19,7 +19,7 @@ describe("useComponentHostBridge", () => {
|
|||||||
test("keeps one identity while the host supplies new objects", () => {
|
test("keeps one identity while the host supplies new objects", () => {
|
||||||
const { result, rerender } = renderHook(
|
const { result, rerender } = renderHook(
|
||||||
({ supplied }: { supplied: ElementCallHostBridge }) =>
|
({ supplied }: { supplied: ElementCallHostBridge }) =>
|
||||||
useComponentHostBridge(supplied, undefined),
|
useComponentHostBridge(supplied, undefined, undefined),
|
||||||
{ initialProps: { supplied: {} } },
|
{ initialProps: { supplied: {} } },
|
||||||
);
|
);
|
||||||
const first = result.current;
|
const first = result.current;
|
||||||
@@ -32,7 +32,7 @@ describe("useComponentHostBridge", () => {
|
|||||||
const after = vi.fn().mockResolvedValue(undefined);
|
const after = vi.fn().mockResolvedValue(undefined);
|
||||||
const { result, rerender } = renderHook(
|
const { result, rerender } = renderHook(
|
||||||
({ supplied }: { supplied: ElementCallHostBridge }) =>
|
({ supplied }: { supplied: ElementCallHostBridge }) =>
|
||||||
useComponentHostBridge(supplied, undefined),
|
useComponentHostBridge(supplied, undefined, undefined),
|
||||||
{ initialProps: { supplied: { notifyJoined: before } } },
|
{ initialProps: { supplied: { notifyJoined: before } } },
|
||||||
);
|
);
|
||||||
rerender({ supplied: { notifyJoined: after } });
|
rerender({ supplied: { notifyJoined: after } });
|
||||||
@@ -44,7 +44,7 @@ describe("useComponentHostBridge", () => {
|
|||||||
|
|
||||||
test("is quiet about what the host did not implement", async () => {
|
test("is quiet about what the host did not implement", async () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useComponentHostBridge(undefined, undefined),
|
useComponentHostBridge(undefined, undefined, undefined),
|
||||||
);
|
);
|
||||||
await expect(result.current.contentLoaded()).resolves.toBeUndefined();
|
await expect(result.current.contentLoaded()).resolves.toBeUndefined();
|
||||||
await expect(
|
await expect(
|
||||||
@@ -59,7 +59,7 @@ describe("useComponentHostBridge", () => {
|
|||||||
test("only has a close when the host has one, since that is a signal", () => {
|
test("only has a close when the host has one, since that is a signal", () => {
|
||||||
const { result, rerender } = renderHook(
|
const { result, rerender } = renderHook(
|
||||||
({ supplied }: { supplied: ElementCallHostBridge }) =>
|
({ supplied }: { supplied: ElementCallHostBridge }) =>
|
||||||
useComponentHostBridge(supplied, undefined),
|
useComponentHostBridge(supplied, undefined, undefined),
|
||||||
{ initialProps: { supplied: {} } },
|
{ initialProps: { supplied: {} } },
|
||||||
);
|
);
|
||||||
expect(result.current.close).toBeUndefined();
|
expect(result.current.close).toBeUndefined();
|
||||||
@@ -71,7 +71,7 @@ describe("useComponentHostBridge", () => {
|
|||||||
|
|
||||||
test("never offers profile changes, since the account is the host's", () => {
|
test("never offers profile changes, since the account is the host's", () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useComponentHostBridge(undefined, undefined),
|
useComponentHostBridge(undefined, undefined, undefined),
|
||||||
);
|
);
|
||||||
expect(result.current.supportsProfileChanges).toBe(false);
|
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 () => {
|
test("delivers a request to what is listening and resolves on its reply", async () => {
|
||||||
const ref = createRef<ElementCallHandle>();
|
const ref = createRef<ElementCallHandle>();
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useComponentHostBridge(undefined, ref),
|
useComponentHostBridge(undefined, ref, undefined),
|
||||||
);
|
);
|
||||||
|
|
||||||
const received = vi.fn();
|
const received = vi.fn();
|
||||||
@@ -97,26 +97,50 @@ describe("useComponentHostBridge", () => {
|
|||||||
|
|
||||||
test("refuses a request nothing in Element Call is listening for", async () => {
|
test("refuses a request nothing in Element Call is listening for", async () => {
|
||||||
const ref = createRef<ElementCallHandle>();
|
const ref = createRef<ElementCallHandle>();
|
||||||
renderHook(() => useComponentHostBridge(undefined, ref));
|
renderHook(() => useComponentHostBridge(undefined, ref, undefined));
|
||||||
|
|
||||||
await expect(ref.current!.hangUp()).rejects.toThrow(
|
await expect(ref.current!.hangUp()).rejects.toThrow(
|
||||||
"Nothing in Element Call can hang up right now",
|
"Nothing in Element Call can hang up right now",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("passes the theme name through", async () => {
|
describe("the theme", () => {
|
||||||
const ref = createRef<ElementCallHandle>();
|
test("reaches a subscriber that arrives after it was set", () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useComponentHostBridge(undefined, ref),
|
useComponentHostBridge(undefined, undefined, "light"),
|
||||||
);
|
);
|
||||||
const names: (string | undefined)[] = [];
|
const names: (string | undefined)[] = [];
|
||||||
result.current.themeChange$.subscribe(({ data, reply }) => {
|
result.current.themeChange$.subscribe(({ data }) =>
|
||||||
names.push(data.name);
|
names.push(data.name),
|
||||||
reply();
|
);
|
||||||
});
|
|
||||||
|
|
||||||
await ref.current!.setTheme("light");
|
|
||||||
expect(names).toEqual(["light"]);
|
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
@@ -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.
|
* call `play()` on a video element. This module adapts the one to the other.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { type Ref, useImperativeHandle } from "react";
|
import { type Ref, useEffect, useImperativeHandle } from "react";
|
||||||
import { Subject } from "rxjs";
|
import { ReplaySubject, Subject } from "rxjs";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type DeviceMuteRequest,
|
type DeviceMuteRequest,
|
||||||
@@ -79,8 +79,6 @@ export interface ElementCallHostBridge {
|
|||||||
* when there is no call, say.
|
* when there is no call, say.
|
||||||
*/
|
*/
|
||||||
export interface ElementCallHandle {
|
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
|
* Joins the call, when Element Call was configured to `preload` and is
|
||||||
* waiting to be told to. Says which devices to join with.
|
* waiting to be told to. Says which devices to join with.
|
||||||
@@ -119,16 +117,28 @@ async function request<Data, Reply>(
|
|||||||
export function useComponentHostBridge(
|
export function useComponentHostBridge(
|
||||||
supplied: ElementCallHostBridge | undefined,
|
supplied: ElementCallHostBridge | undefined,
|
||||||
ref: Ref<ElementCallHandle> | undefined,
|
ref: Ref<ElementCallHandle> | undefined,
|
||||||
|
/** The theme the host wants, or undefined to leave it to Element Call. */
|
||||||
|
theme: string | undefined,
|
||||||
): HostBridge {
|
): HostBridge {
|
||||||
const latest = useLatest(supplied ?? {});
|
const latest = useLatest(supplied ?? {});
|
||||||
|
|
||||||
const requests = useInitial(() => ({
|
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>>(),
|
join$: new Subject<HostRequest<JoinCallData>>(),
|
||||||
hangUp$: new Subject<HostRequest<Record<string, never>>>(),
|
hangUp$: new Subject<HostRequest<Record<string, never>>>(),
|
||||||
deviceMute$: new Subject<HostRequest<DeviceMuteRequest, DeviceMuteState>>(),
|
deviceMute$: new Subject<HostRequest<DeviceMuteRequest, DeviceMuteState>>(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (theme !== undefined)
|
||||||
|
requests.themeChange$.next({ data: { name: theme }, reply: () => {} });
|
||||||
|
}, [requests, theme]);
|
||||||
|
|
||||||
const bridge = useInitial(
|
const bridge = useInitial(
|
||||||
(): HostBridge => ({
|
(): HostBridge => ({
|
||||||
setAlwaysOnScreen: async (alwaysOnScreen) => {
|
setAlwaysOnScreen: async (alwaysOnScreen) => {
|
||||||
@@ -175,8 +185,6 @@ export function useComponentHostBridge(
|
|||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
(): ElementCallHandle => ({
|
(): ElementCallHandle => ({
|
||||||
setTheme: async (name) =>
|
|
||||||
await request(requests.themeChange$, "change theme", { name }),
|
|
||||||
join: async (devices) =>
|
join: async (devices) =>
|
||||||
await request(requests.join$, "join a call", devices),
|
await request(requests.join$, "join a call", devices),
|
||||||
hangUp: async () => await request(requests.hangUp$, "hang up", {}),
|
hangUp: async () => await request(requests.hangUp$, "hang up", {}),
|
||||||
|
|||||||
+16
-9
@@ -111,14 +111,15 @@ export {
|
|||||||
* takes the default that {@link ElementCallProps.intent} implies.
|
* takes the default that {@link ElementCallProps.intent} implies.
|
||||||
*
|
*
|
||||||
* This is the behaviour a widget can be configured with through its URL, plus
|
* 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 one fact about the call a host has a say in here, the background. The
|
||||||
* the background. The rest of what a widget's URL carries — who the user is,
|
* rest of what a widget's URL carries — who the user is, how to reach the
|
||||||
* how to reach the homeserver, where to report analytics, the shared secret of
|
* homeserver, where to report analytics, the shared secret of a room that is
|
||||||
* a room that is encrypted with one — a component host supplies by other
|
* encrypted with one — a component host supplies by other routes, or not at
|
||||||
* routes, or not at all.
|
* 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> &
|
export type ElementCallConfiguration = Partial<UrlConfiguration> &
|
||||||
Partial<Pick<UrlProperties, "theme" | "background">>;
|
Partial<Pick<UrlProperties, "background">>;
|
||||||
|
|
||||||
export interface ElementCallProps {
|
export interface ElementCallProps {
|
||||||
/**
|
/**
|
||||||
@@ -154,10 +155,15 @@ export interface ElementCallProps {
|
|||||||
*/
|
*/
|
||||||
hostBridge?: ElementCallHostBridge;
|
hostBridge?: ElementCallHostBridge;
|
||||||
/**
|
/**
|
||||||
* What the host tells Element Call: to change theme, to hang up, to mute.
|
* What the host tells Element Call: to hang up, to mute, to join. Available
|
||||||
* Available once the component has rendered.
|
* once the component has rendered.
|
||||||
*/
|
*/
|
||||||
ref?: Ref<ElementCallHandle>;
|
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
|
* 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`
|
* {@link supportedLanguages}, or something that falls back to one (`de-AT`
|
||||||
@@ -229,9 +235,10 @@ export const ElementCall: FC<ElementCallProps> = ({
|
|||||||
config,
|
config,
|
||||||
hostBridge: suppliedHostBridge,
|
hostBridge: suppliedHostBridge,
|
||||||
ref,
|
ref,
|
||||||
|
theme,
|
||||||
language,
|
language,
|
||||||
}): ReactNode => {
|
}): ReactNode => {
|
||||||
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref);
|
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (language !== undefined)
|
if (language !== undefined)
|
||||||
|
|||||||
Reference in New Issue
Block a user