Speak every language in the component, not just English

The component bundled English alone: the standalone app fetches its
locale files at runtime from URLs its own build emits, which a host
serving the library from elsewhere could not resolve, so bundling one
language was the self-contained option. Now every locale is a chunk of
its own that the host's bundler loads the first time it is needed, with
English still bundled in so that the fallback never waits.

Element Call starts in the browser's language and follows the host's own
setting through a `language` prop; `supportedLanguages` says what it
accepts. Translations are shared by every Element Call on the page, so
the most recently set language wins for all of them. The harness gets a
language picker, and the app and the component share the parsing of
locale paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Timo K.
2026-09-08 16:03:48 +02:00
co-authored by Claude Fable 5.1
parent b7285064fd
commit f994586eeb
7 changed files with 192 additions and 30 deletions
+6
View File
@@ -244,6 +244,12 @@ what is inside it, with `html`, `body` and `:root` standing for that root (see
`component/build/scopeStylesToRoot.ts`). A host's own page keeps its styles, `component/build/scopeStylesToRoot.ts`). A host's own page keeps its styles,
and Element Call brings its own fonts and design tokens along. and Element Call brings its own fonts and design tokens along.
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 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,
+26 -2
View File
@@ -17,7 +17,11 @@ import {
import { type MatrixClient } from "matrix-js-sdk"; import { type MatrixClient } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger"; import { logger } from "matrix-js-sdk/lib/logger";
import { ElementCall, type ElementCallHandle } from "../index"; import {
ElementCall,
type ElementCallHandle,
supportedLanguages,
} from "../index";
import { createDevHostBridge } from "./DevHostBridge"; import { createDevHostBridge } from "./DevHostBridge";
import { createSession, joinRoom } from "./session"; import { createSession, joinRoom } from "./session";
import styles from "./Harness.module.css"; import styles from "./Harness.module.css";
@@ -90,8 +94,9 @@ interface LogEntry {
const Pane: FC<{ const Pane: FC<{
session: Session; session: Session;
roomId: string; roomId: string;
language: string | undefined;
log: (pane: string, message: string) => void; log: (pane: string, message: string) => void;
}> = ({ session, roomId, log }): ReactNode => { }> = ({ session, roomId, language, log }): ReactNode => {
const [mounted, setMounted] = useState(true); const [mounted, setMounted] = useState(true);
const bridge = useMemo( const bridge = useMemo(
@@ -173,6 +178,7 @@ const Pane: FC<{
client={session.client} client={session.client}
roomId={roomId} roomId={roomId}
hostBridge={bridge} hostBridge={bridge}
language={language}
/> />
)} )}
</div> </div>
@@ -226,6 +232,9 @@ export const Harness: FC = (): ReactNode => {
const [state, setState] = useState<State>({ phase: "credentials" }); const [state, setState] = useState<State>({ phase: "credentials" });
const [entries, setEntries] = useState<LogEntry[]>([]); const [entries, setEntries] = useState<LogEntry[]>([]);
const [dialogOpen, setDialogOpen] = useState(false); 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 log = useCallback((pane: string, message: string): void => { const log = useCallback((pane: string, message: string): void => {
setEntries((entries) => setEntries((entries) =>
@@ -324,6 +333,20 @@ 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>
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> </header>
<div className={styles.middle}> <div className={styles.middle}>
<HostChrome /> <HostChrome />
@@ -333,6 +356,7 @@ export const Harness: FC = (): ReactNode => {
key={session.label} key={session.label}
session={session} session={session}
roomId={state.roomId} roomId={state.roomId}
language={language}
log={log} log={log}
/> />
))} ))}
+46 -16
View File
@@ -50,6 +50,8 @@ import { ErrorBoundary } from "@sentry/react";
import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill"; import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill";
import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill.js"; import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill.js";
import LanguageDetector from "i18next-browser-languagedetector";
import EN from "../locales/en/app.json"; import EN from "../locales/en/app.json";
import { CallView } from "../src/room/CallView"; import { CallView } from "../src/room/CallView";
import { ErrorPage } from "../src/FullScreenView"; import { ErrorPage } from "../src/FullScreenView";
@@ -81,6 +83,10 @@ import {
type ElementCallHostBridge, type ElementCallHostBridge,
useComponentHostBridge, useComponentHostBridge,
} from "./host"; } from "./host";
import { supportedLanguages, translationsBackend } from "./localization";
// The languages Element Call can be shown in
export { supportedLanguages } from "./localization";
// How the host and Element Call talk to each other, and what they say // How the host and Element Call talk to each other, and what they say
export { type ElementCallHandle, type ElementCallHostBridge } from "./host"; export { type ElementCallHandle, type ElementCallHostBridge } from "./host";
@@ -152,6 +158,15 @@ export interface ElementCallProps {
* Available once the component has rendered. * Available once the component has rendered.
*/ */
ref?: Ref<ElementCallHandle>; ref?: Ref<ElementCallHandle>;
/**
* 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`
* to `de`). Left out, the browser's language is used.
*
* Translations are one thing shared by every Element Call on the page, so
* the most recently set language wins for all of them.
*/
language?: string;
} }
/** /**
@@ -171,22 +186,29 @@ export async function initializeElementCall(
await Promise.all(polyfills); await Promise.all(polyfills);
Config.initWith(config); Config.initWith(config);
await i18n.init({ await i18n
fallbackLng: "en", .use(translationsBackend)
defaultNS: "app", .use(new LanguageDetector())
keySeparator: ".", .init({
nsSeparator: false, fallbackLng: "en",
pluralSeparator: "_", defaultNS: "app",
contextSeparator: "|", keySeparator: ".",
lng: "en", nsSeparator: false,
interpolation: { escapeValue: false }, pluralSeparator: "_",
// English only, bundled in. The standalone app fetches its locale files at contextSeparator: "|",
// runtime from URLs its own build emits, which a host serving the library supportedLngs: [...supportedLanguages],
// from elsewhere could not resolve; bundling one language at least keeps interpolation: { escapeValue: false },
// the component self-contained. Letting a host supply the rest, or its own // English is bundled in, so the fallback never has to be loaded; every
// translations, is still to do. // other language arrives from the backend when first asked for.
resources: { en: { app: EN } }, partialBundledLanguages: true,
}); resources: { en: { app: EN } },
detection: {
// The browser's language, until the host says otherwise through the
// `language` prop. Nothing is remembered: the choice is the host's.
order: ["navigator"],
caches: [],
},
});
} }
/** Applies the theme and background to the container, before it is painted. */ /** Applies the theme and background to the container, before it is painted. */
@@ -207,9 +229,17 @@ export const ElementCall: FC<ElementCallProps> = ({
config, config,
hostBridge: suppliedHostBridge, hostBridge: suppliedHostBridge,
ref, ref,
language,
}): ReactNode => { }): ReactNode => {
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref); const hostBridge = useComponentHostBridge(suppliedHostBridge, ref);
useEffect(() => {
if (language !== undefined)
i18n
.changeLanguage(language)
.catch((e) => logger.error(`Could not switch to ${language}`, e));
}, [language]);
// The container is what Element Call decorates and portals into, so nothing // The container is what Element Call decorates and portals into, so nothing
// inside can render until we have it. // inside can render until we have it.
const [container, setContainer] = useState<HTMLDivElement | null>(null); const [container, setContainer] = useState<HTMLDivElement | null>(null);
+46
View File
@@ -0,0 +1,46 @@
/*
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 { describe, expect, test } from "vitest";
import { supportedLanguages, translationsBackend } from "./localization";
const read = async (
language: string,
namespace = "app",
): Promise<Record<string, unknown>> =>
await new Promise((resolve, reject) =>
translationsBackend.read(language, namespace, (error, data) => {
if (error) reject(error);
else resolve(data as Record<string, unknown>);
}),
);
describe("component translations", () => {
test("offer every language in locales/, tagged as its directory is", () => {
expect(supportedLanguages).toContain("en");
expect(supportedLanguages).toContain("de");
expect(supportedLanguages).toContain("zh-Hans");
expect(new Set(supportedLanguages).size).toBe(supportedLanguages.length);
});
test("load a language's translations on demand", async () => {
const de = await read("de");
expect(de).toHaveProperty("action");
expect(de).not.toEqual(await read("en"));
});
test("refuse a language there are no translations for", async () => {
await expect(read("xx")).rejects.toThrow("No app translations for xx");
});
test("refuse a namespace there are no translations for", async () => {
await expect(read("en", "other")).rejects.toThrow(
"No other translations for en",
);
});
});
+55
View File
@@ -0,0 +1,55 @@
/*
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.
*/
/**
* Translations for Element Call as a component.
*
* The standalone app fetches its locale files at runtime from URLs its own
* build emits, which a host serving the library from somewhere else could not
* resolve. The component instead has the bundler split every locale into a
* chunk of its own, loaded the first time its language is asked for; English,
* the fallback, is bundled in so that the first paint never waits for it.
*/
import { type BackendModule, type ResourceKey } from "i18next";
import { languageOfLocalePath } from "../src/utils/i18n";
/** Every locale, as a lazily imported module. */
const translations = import.meta.glob<{ default: ResourceKey }>(
"../locales/*/app.json",
);
/**
* The languages Element Call can be shown in, as BCP 47 tags — `en`, `de`,
* `zh-Hans` and so on. A language that is not one of these falls back to its
* base language where there is one (`de-AT` to `de`), and to English otherwise.
*/
export const supportedLanguages: readonly string[] = [
...new Set(Object.keys(translations).map(languageOfLocalePath)),
];
/** Loads translations on demand. */
export const translationsBackend: BackendModule = {
type: "backend",
init(): void {},
read(language: string, namespace: string, callback): void {
const load = translations[`../locales/${language}/${namespace}.json`];
if (load === undefined) {
callback(new Error(`No ${namespace} translations for ${language}`), null);
return;
}
load().then(
(module) => callback(null, module.default),
(error: unknown) =>
callback(
error instanceof Error ? error : new Error(String(error)),
null,
),
);
},
};
+2 -12
View File
@@ -37,7 +37,7 @@ import {
type AnalyticsConfig, type AnalyticsConfig,
PosthogAnalytics, PosthogAnalytics,
} from "./analytics/PosthogAnalytics.ts"; } from "./analytics/PosthogAnalytics.ts";
import { i18n } from "./utils/i18n.ts"; import { i18n, languageOfLocalePath } from "./utils/i18n.ts";
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this: // This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
// { // {
@@ -56,17 +56,7 @@ const getLocaleUrl = (
): string | undefined => locales[`../locales/${language}/${namespace}.json`]; ): string | undefined => locales[`../locales/${language}/${namespace}.json`];
const supportedLngs = [ const supportedLngs = [
...new Set( ...new Set(Object.keys(locales).map(languageOfLocalePath)),
Object.keys(locales).map((url) => {
// The URLs are of the form ../locales/en/app.json
// This extracts the language code from the URL
const lang = url.match(/\/([^/]+)\/[^/]+\.json$/)?.[1];
if (!lang) {
throw new Error(`Could not parse locale URL ${url}`);
}
return lang;
}),
),
]; ];
// A backend that fetches the locale files from the URLs generated by the glob above // A backend that fetches the locale files from the URLs generated by the glob above
+11
View File
@@ -10,6 +10,17 @@ import i18next, { type i18n as I18nInstance } from "i18next";
// Custom marker function to allow i18next extraction // Custom marker function to allow i18next extraction
export const i18nKey = (key: string): string => key; export const i18nKey = (key: string): string => key;
/**
* The language a locale file under `locales/` is for, from its path as a
* bundler glob reports it: `../locales/zh-Hans/app.json` is for `zh-Hans`.
*/
export function languageOfLocalePath(path: string): string {
const language = path.match(/\/([^/]+)\/[^/]+\.json$/)?.[1];
if (language === undefined)
throw new Error(`Could not parse locale path ${path}`);
return language;
}
/** /**
* Element Call's own i18next instance. * Element Call's own i18next instance.
* *