diff --git a/README.md b/README.md index 273846afc..b1c462759 100644 --- a/README.md +++ b/README.md @@ -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, 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 `component` directory of this repository, diff --git a/component/dev/Harness.tsx b/component/dev/Harness.tsx index ea51191f5..9e40e2c7a 100644 --- a/component/dev/Harness.tsx +++ b/component/dev/Harness.tsx @@ -17,7 +17,11 @@ import { import { type MatrixClient } from "matrix-js-sdk"; 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 { createSession, joinRoom } from "./session"; import styles from "./Harness.module.css"; @@ -90,8 +94,9 @@ interface LogEntry { const Pane: FC<{ session: Session; roomId: string; + language: string | undefined; log: (pane: string, message: string) => void; -}> = ({ session, roomId, log }): ReactNode => { +}> = ({ session, roomId, language, log }): ReactNode => { const [mounted, setMounted] = useState(true); const bridge = useMemo( @@ -173,6 +178,7 @@ const Pane: FC<{ client={session.client} roomId={roomId} hostBridge={bridge} + language={language} /> )} @@ -226,6 +232,9 @@ export const Harness: FC = (): ReactNode => { const [state, setState] = useState({ phase: "credentials" }); const [entries, setEntries] = useState([]); 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(undefined); const log = useCallback((pane: string, message: string): void => { setEntries((entries) => @@ -324,6 +333,20 @@ export const Harness: FC = (): ReactNode => { +
@@ -333,6 +356,7 @@ export const Harness: FC = (): ReactNode => { key={session.label} session={session} roomId={state.roomId} + language={language} log={log} /> ))} diff --git a/component/index.tsx b/component/index.tsx index 41c584830..90532966f 100644 --- a/component/index.tsx +++ b/component/index.tsx @@ -50,6 +50,8 @@ import { ErrorBoundary } from "@sentry/react"; import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill"; 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 { CallView } from "../src/room/CallView"; import { ErrorPage } from "../src/FullScreenView"; @@ -81,6 +83,10 @@ import { type ElementCallHostBridge, useComponentHostBridge, } 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 export { type ElementCallHandle, type ElementCallHostBridge } from "./host"; @@ -152,6 +158,15 @@ export interface ElementCallProps { * Available once the component has rendered. */ ref?: Ref; + /** + * 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); Config.initWith(config); - await i18n.init({ - fallbackLng: "en", - defaultNS: "app", - keySeparator: ".", - nsSeparator: false, - pluralSeparator: "_", - contextSeparator: "|", - lng: "en", - interpolation: { escapeValue: false }, - // English only, bundled in. 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; bundling one language at least keeps - // the component self-contained. Letting a host supply the rest, or its own - // translations, is still to do. - resources: { en: { app: EN } }, - }); + await i18n + .use(translationsBackend) + .use(new LanguageDetector()) + .init({ + fallbackLng: "en", + defaultNS: "app", + keySeparator: ".", + nsSeparator: false, + pluralSeparator: "_", + contextSeparator: "|", + supportedLngs: [...supportedLanguages], + interpolation: { escapeValue: false }, + // English is bundled in, so the fallback never has to be loaded; every + // other language arrives from the backend when first asked for. + 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. */ @@ -207,9 +229,17 @@ export const ElementCall: FC = ({ config, hostBridge: suppliedHostBridge, ref, + language, }): ReactNode => { 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 // inside can render until we have it. const [container, setContainer] = useState(null); diff --git a/component/localization.test.ts b/component/localization.test.ts new file mode 100644 index 000000000..fae1c7065 --- /dev/null +++ b/component/localization.test.ts @@ -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> => + await new Promise((resolve, reject) => + translationsBackend.read(language, namespace, (error, data) => { + if (error) reject(error); + else resolve(data as Record); + }), + ); + +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", + ); + }); +}); diff --git a/component/localization.ts b/component/localization.ts new file mode 100644 index 000000000..b64172818 --- /dev/null +++ b/component/localization.ts @@ -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, + ), + ); + }, +}; diff --git a/src/initializer.tsx b/src/initializer.tsx index 253dcbc41..76a7fcc39 100644 --- a/src/initializer.tsx +++ b/src/initializer.tsx @@ -37,7 +37,7 @@ import { type AnalyticsConfig, PosthogAnalytics, } 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: // { @@ -56,17 +56,7 @@ const getLocaleUrl = ( ): string | undefined => locales[`../locales/${language}/${namespace}.json`]; const supportedLngs = [ - ...new Set( - 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; - }), - ), + ...new Set(Object.keys(locales).map(languageOfLocalePath)), ]; // A backend that fetches the locale files from the URLs generated by the glob above diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts index 65dca697f..0796ab380 100644 --- a/src/utils/i18n.ts +++ b/src/utils/i18n.ts @@ -10,6 +10,17 @@ import i18next, { type i18n as I18nInstance } from "i18next"; // Custom marker function to allow i18next extraction 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. *