merge: upstream Element Call v0.28.0 (cinny #201) #61
@@ -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,
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -226,6 +232,9 @@ export const Harness: FC = (): ReactNode => {
|
||||
const [state, setState] = useState<State>({ phase: "credentials" });
|
||||
const [entries, setEntries] = useState<LogEntry[]>([]);
|
||||
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 => {
|
||||
setEntries((entries) =>
|
||||
@@ -324,6 +333,20 @@ export const Harness: FC = (): ReactNode => {
|
||||
<button onClick={(): void => setDialogOpen(true)}>
|
||||
Open a host dialog
|
||||
</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>
|
||||
<div className={styles.middle}>
|
||||
<HostChrome />
|
||||
@@ -333,6 +356,7 @@ export const Harness: FC = (): ReactNode => {
|
||||
key={session.label}
|
||||
session={session}
|
||||
roomId={state.roomId}
|
||||
language={language}
|
||||
log={log}
|
||||
/>
|
||||
))}
|
||||
|
||||
+46
-16
@@ -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<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);
|
||||
|
||||
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<ElementCallProps> = ({
|
||||
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<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user