/* Copyright 2022-2024 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only Please see LICENSE in the repository root for full details. */ import i18n, { type BackendModule, type ReadCallback, type ResourceKey, } from "i18next"; import { initReactI18next } from "react-i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import * as Sentry from "@sentry/react"; import { logger } from "matrix-js-sdk/src/logger"; import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill"; import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill"; import { getUrlParams } from "./UrlParams"; import { Config } from "./config/Config"; import { ElementCallOpenTelemetry } from "./otel/otel"; import { platform } from "./Platform"; // This generates a map of locale names to their URL (based on import.meta.url), which looks like this: // { // "../locales/en-GB/app.json": "/whatever/assets/root/locales/en-aabbcc.json", // ... // } const locales = import.meta.glob("../locales/*/*.json", { query: "?url", import: "default", eager: true, }); const getLocaleUrl = ( language: string, namespace: string, ): string | undefined => locales[`../locales/${language}/${namespace}.json`]; const supportedLngs = [ ...new Set( Object.keys(locales).map((url) => { // The URLs are of the form ../locales/en-GB/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 const Backend = { type: "backend", init(): void {}, read(language: string, namespace: string, callback: ReadCallback): void { (async (): Promise => { const url = getLocaleUrl(language, namespace); if (!url) { throw new Error( `Namespace ${namespace} for locale ${language} not found`, ); } const response = await fetch(url, { credentials: "omit", headers: { Accept: "application/json", }, }); if (!response.ok) { throw Error(`Failed to fetch ${url}`); } return await response.json(); })().then( (data) => callback(null, data), (error) => callback(error, null), ); }, } satisfies BackendModule; enum LoadState { None, Loading, Loaded, } class DependencyLoadStates { public config: LoadState = LoadState.None; public sentry: LoadState = LoadState.None; public openTelemetry: LoadState = LoadState.None; public allDepsAreLoaded(): boolean { return !Object.values(this).some((s) => s !== LoadState.Loaded); } } export class Initializer { private static internalInstance: Initializer; private isInitialized = false; public static isInitialized(): boolean { return Initializer.internalInstance?.isInitialized; } public static async initBeforeReact(): Promise { const polyfills: Promise[] = []; if (shouldPolyfillSegmenter()) { polyfills.push(import("@formatjs/intl-segmenter/polyfill-force")); } if (shouldPolyfillDurationFormat()) { polyfills.push(import("@formatjs/intl-durationformat/polyfill-force")); } await Promise.all(polyfills); //i18n const languageDetector = new LanguageDetector(); languageDetector.addDetector({ name: "urlFragment", // Look for a language code in the URL's fragment lookup: () => getUrlParams().lang ?? undefined, }); await i18n .use(Backend) .use(languageDetector) .use(initReactI18next) .init({ fallbackLng: "en-GB", defaultNS: "app", keySeparator: ".", nsSeparator: false, pluralSeparator: "_", contextSeparator: "|", supportedLngs, interpolation: { escapeValue: false, // React has built-in XSS protections }, detection: { // No localStorage detectors or caching here, since we don't have any way // of letting the user manually select a language order: ["urlFragment", "navigator"], caches: [], }, }); // Custom Themeing if (import.meta.env.VITE_CUSTOM_CSS) { const style = document.createElement("style"); style.textContent = import.meta.env.VITE_CUSTOM_CSS; document.head.appendChild(style); } // Custom fonts const { fonts, fontScale } = getUrlParams(); if (fontScale !== null) { document.documentElement.style.setProperty( "--font-scale", fontScale.toString(), ); } if (fonts.length > 0) { document.documentElement.style.setProperty( "--font-family", fonts.map((f) => `"${f}"`).join(", "), ); } // Add the platform to the DOM, so CSS can query it document.body.setAttribute("data-platform", platform); } public static init(): Promise | null { if (Initializer?.internalInstance?.initPromise) { return null; } Initializer.internalInstance = new Initializer(); Initializer.internalInstance.initPromise = new Promise((resolve) => { // initStep calls itself recursively until everything is initialized in the correct order. // Then the promise gets resolved. Initializer.internalInstance.initStep(resolve); }); return Initializer.internalInstance.initPromise; } private loadStates = new DependencyLoadStates(); private initStep(resolve: (value: void | PromiseLike) => void): void { // config if (this.loadStates.config === LoadState.None) { this.loadStates.config = LoadState.Loading; Config.init().then( () => { this.loadStates.config = LoadState.Loaded; this.initStep(resolve); }, (e) => { logger.error("Failed to load config", e); }, ); } //sentry (only initialize after the config is ready) if ( this.loadStates.sentry === LoadState.None && this.loadStates.config === LoadState.Loaded ) { if (Config.get().sentry?.DSN && Config.get().sentry?.environment) { Sentry.init({ dsn: Config.get().sentry?.DSN, environment: Config.get().sentry?.environment, integrations: [ Sentry.reactRouterV5BrowserTracingIntegration({ history }), ], tracesSampleRate: 1.0, }); } // Sentry is now 'loadeed' (even if we actually skipped starting // it due to to not being configured) this.loadStates.sentry = LoadState.Loaded; } // OpenTelemetry (also only after config loaded) if ( this.loadStates.openTelemetry === LoadState.None && this.loadStates.config === LoadState.Loaded ) { ElementCallOpenTelemetry.globalInit(); this.loadStates.openTelemetry = LoadState.Loaded; } if (this.loadStates.allDepsAreLoaded()) { // resolve if there is no dependency that is not loaded resolve(); this.isInitialized = true; } } private initPromise?: Promise; }