mirror of
https://github.com/vector-im/element-call.git
synced 2026-09-10 21:55:19 +00:00
Use a dedicated i18next instance instead of the global singleton
Element Call configured the global i18next singleton. When Element Call runs embedded in a host application rather than as its own page, that singleton belongs to the host, so configuring it would clobber the host's translations. Create Element Call's own instance in utils/i18n.ts, configure it in the initializer, and pass it to components via <I18nextProvider>. Drop .use(initReactI18next) from the initializer: it registers the instance as react-i18next's global default, which is the global we are trying to avoid. Tests and stories keep using it, so that they do not need to wrap every render in a provider. Two modules imported `t` directly from "i18next" and so were bound to the global instance: utils/errors.ts now calls i18n.t() on the instance (reached at call time, since i18next only assigns `t` during init), and QrCode uses useTranslation() like every other component. No functional change.
This commit is contained in:
@@ -7,14 +7,16 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import type { Preview } from "@storybook/react-vite";
|
import type { Preview } from "@storybook/react-vite";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
import i18n from "i18next";
|
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
import EN from "../locales/en/app.json";
|
import EN from "../locales/en/app.json";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
|
import { i18n } from "../src/utils/i18n";
|
||||||
import "../src/index.css";
|
import "../src/index.css";
|
||||||
|
|
||||||
// Bare-minimum i18n config
|
// Bare-minimum i18n config.
|
||||||
|
// Unlike the app, stories register the instance as react-i18next's default
|
||||||
|
// rather than wrapping every story in an <I18nextProvider>.
|
||||||
i18n
|
i18n
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom";
|
|||||||
import * as Sentry from "@sentry/react";
|
import * as Sentry from "@sentry/react";
|
||||||
import { TooltipProvider } from "@vector-im/compound-web";
|
import { TooltipProvider } from "@vector-im/compound-web";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
import { I18nextProvider } from "react-i18next";
|
||||||
|
|
||||||
import { HomePage } from "./home/HomePage";
|
import { HomePage } from "./home/HomePage";
|
||||||
import { LoginPage } from "./auth/LoginPage";
|
import { LoginPage } from "./auth/LoginPage";
|
||||||
@@ -32,6 +33,7 @@ import { type AppViewModel } from "./state/AppViewModel";
|
|||||||
import { MediaDevicesContext } from "./MediaDevicesContext";
|
import { MediaDevicesContext } from "./MediaDevicesContext";
|
||||||
import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams";
|
import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams";
|
||||||
import { AppBar } from "./AppBar";
|
import { AppBar } from "./AppBar";
|
||||||
|
import { i18n } from "./utils/i18n";
|
||||||
|
|
||||||
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
|
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
|
||||||
|
|
||||||
@@ -98,6 +100,7 @@ export const App: FC<Props> = ({ vm }) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<I18nextProvider i18n={i18n}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<BackgroundProvider>
|
<BackgroundProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
@@ -113,5 +116,6 @@ export const App: FC<Props> = ({ vm }) => {
|
|||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</BackgroundProvider>
|
</BackgroundProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
</I18nextProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-1
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { type FC, useEffect, useState } from "react";
|
import { type FC, useEffect, useState } from "react";
|
||||||
import { toDataURL } from "qrcode";
|
import { toDataURL } from "qrcode";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { t } from "i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import styles from "./QrCode.module.css";
|
import styles from "./QrCode.module.css";
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const QrCode: FC<Props> = ({ data, className }) => {
|
export const QrCode: FC<Props> = ({ data, className }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
+5
-3
@@ -6,12 +6,11 @@ Please see LICENSE in the repository root for full details.
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import i18n, {
|
import {
|
||||||
type BackendModule,
|
type BackendModule,
|
||||||
type ReadCallback,
|
type ReadCallback,
|
||||||
type ResourceKey,
|
type ResourceKey,
|
||||||
} from "i18next";
|
} from "i18next";
|
||||||
import { initReactI18next } from "react-i18next";
|
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
import * as Sentry from "@sentry/react";
|
import * as Sentry from "@sentry/react";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
@@ -35,6 +34,7 @@ import { platform } from "./Platform";
|
|||||||
import { isFailure } from "./utils/fetch";
|
import { isFailure } from "./utils/fetch";
|
||||||
import { initializeWidget } from "./widget";
|
import { initializeWidget } from "./widget";
|
||||||
import { enableExtendedLivekitLogs } from "./settings/settings.ts";
|
import { enableExtendedLivekitLogs } from "./settings/settings.ts";
|
||||||
|
import { i18n } 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:
|
||||||
// {
|
// {
|
||||||
@@ -148,10 +148,12 @@ export class Initializer {
|
|||||||
document.documentElement.lang = lng;
|
document.documentElement.lang = lng;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Note: deliberately no `.use(initReactI18next)` — that would register this
|
||||||
|
// instance as react-i18next's global default, which is the very global we
|
||||||
|
// are avoiding. Components receive it through `<I18nextProvider>` instead.
|
||||||
await i18n
|
await i18n
|
||||||
.use(Backend)
|
.use(Backend)
|
||||||
.use(languageDetector)
|
.use(languageDetector)
|
||||||
.use(initReactI18next)
|
|
||||||
.init({
|
.init({
|
||||||
fallbackLng: "en",
|
fallbackLng: "en",
|
||||||
defaultNS: "app",
|
defaultNS: "app",
|
||||||
|
|||||||
+22
-23
@@ -5,10 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { t } from "i18next";
|
|
||||||
import { type ConnectionError } from "livekit-client";
|
import { type ConnectionError } from "livekit-client";
|
||||||
|
|
||||||
import { i18nKey } from "./i18n";
|
import { i18n, i18nKey } from "./i18n";
|
||||||
|
|
||||||
export enum ErrorCode {
|
export enum ErrorCode {
|
||||||
/**
|
/**
|
||||||
@@ -78,10 +77,10 @@ export class MatrixRTCTransportMissingError extends ElementCallError {
|
|||||||
*/
|
*/
|
||||||
public constructor(domain: string) {
|
public constructor(domain: string) {
|
||||||
super(
|
super(
|
||||||
t("error.call_is_not_supported"),
|
i18n.t("error.call_is_not_supported"),
|
||||||
ErrorCode.MISSING_MATRIX_RTC_TRANSPORT,
|
ErrorCode.MISSING_MATRIX_RTC_TRANSPORT,
|
||||||
ErrorCategory.CONFIGURATION_ISSUE,
|
ErrorCategory.CONFIGURATION_ISSUE,
|
||||||
t("error.matrix_rtc_transport_missing", {
|
i18n.t("error.matrix_rtc_transport_missing", {
|
||||||
domain,
|
domain,
|
||||||
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
|
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
|
||||||
errorCode: ErrorCode.MISSING_MATRIX_RTC_TRANSPORT,
|
errorCode: ErrorCode.MISSING_MATRIX_RTC_TRANSPORT,
|
||||||
@@ -97,10 +96,10 @@ export class MatrixRTCTransportMissingError extends ElementCallError {
|
|||||||
export class ConnectionLostError extends ElementCallError {
|
export class ConnectionLostError extends ElementCallError {
|
||||||
public constructor() {
|
public constructor() {
|
||||||
super(
|
super(
|
||||||
t("error.connection_lost"),
|
i18n.t("error.connection_lost"),
|
||||||
ErrorCode.CONNECTION_LOST_ERROR,
|
ErrorCode.CONNECTION_LOST_ERROR,
|
||||||
ErrorCategory.NETWORK_CONNECTIVITY,
|
ErrorCategory.NETWORK_CONNECTIVITY,
|
||||||
t("error.connection_lost_description"),
|
i18n.t("error.connection_lost_description"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,10 +116,10 @@ export class MembershipManagerError extends ElementCallError {
|
|||||||
*/
|
*/
|
||||||
public constructor(error: Error) {
|
public constructor(error: Error) {
|
||||||
super(
|
super(
|
||||||
t("error.membership_manager"),
|
i18n.t("error.membership_manager"),
|
||||||
ErrorCode.INTERNAL_MEMBERSHIP_MANAGER,
|
ErrorCode.INTERNAL_MEMBERSHIP_MANAGER,
|
||||||
ErrorCategory.SYSTEM_FAILURE,
|
ErrorCategory.SYSTEM_FAILURE,
|
||||||
t("error.membership_manager_description"),
|
i18n.t("error.membership_manager_description"),
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -134,10 +133,10 @@ export class MembershipManagerError extends ElementCallError {
|
|||||||
export class StickyEventsRequiredError extends ElementCallError {
|
export class StickyEventsRequiredError extends ElementCallError {
|
||||||
public constructor() {
|
public constructor() {
|
||||||
super(
|
super(
|
||||||
t("error.sticky_events_required"),
|
i18n.t("error.sticky_events_required"),
|
||||||
ErrorCode.STICKY_EVENTS_NOT_SUPPORTED,
|
ErrorCode.STICKY_EVENTS_NOT_SUPPORTED,
|
||||||
ErrorCategory.CONFIGURATION_ISSUE,
|
ErrorCategory.CONFIGURATION_ISSUE,
|
||||||
t("error.sticky_events_required_description"),
|
i18n.t("error.sticky_events_required_description"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,10 +147,10 @@ export class StickyEventsRequiredError extends ElementCallError {
|
|||||||
export class E2EENotSupportedError extends ElementCallError {
|
export class E2EENotSupportedError extends ElementCallError {
|
||||||
public constructor() {
|
public constructor() {
|
||||||
super(
|
super(
|
||||||
t("error.e2ee_unsupported"),
|
i18n.t("error.e2ee_unsupported"),
|
||||||
ErrorCode.E2EE_NOT_SUPPORTED,
|
ErrorCode.E2EE_NOT_SUPPORTED,
|
||||||
ErrorCategory.CLIENT_CONFIGURATION,
|
ErrorCategory.CLIENT_CONFIGURATION,
|
||||||
t("error.e2ee_unsupported_description"),
|
i18n.t("error.e2ee_unsupported_description"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +165,7 @@ export class UnknownCallError extends ElementCallError {
|
|||||||
*/
|
*/
|
||||||
public constructor(error: Error) {
|
public constructor(error: Error) {
|
||||||
super(
|
super(
|
||||||
t("error.generic"),
|
i18n.t("error.generic"),
|
||||||
ErrorCode.UNKNOWN_ERROR,
|
ErrorCode.UNKNOWN_ERROR,
|
||||||
ErrorCategory.UNKNOWN,
|
ErrorCategory.UNKNOWN,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -186,7 +185,7 @@ export class FailToGetOpenIdToken extends ElementCallError {
|
|||||||
*/
|
*/
|
||||||
public constructor(error: Error) {
|
public constructor(error: Error) {
|
||||||
super(
|
super(
|
||||||
t("error.generic"),
|
i18n.t("error.generic"),
|
||||||
ErrorCode.OPEN_ID_ERROR,
|
ErrorCode.OPEN_ID_ERROR,
|
||||||
ErrorCategory.CONFIGURATION_ISSUE,
|
ErrorCategory.CONFIGURATION_ISSUE,
|
||||||
undefined,
|
undefined,
|
||||||
@@ -203,10 +202,10 @@ export class NoMatrix2AuthorizationService extends ElementCallError {
|
|||||||
*/
|
*/
|
||||||
public constructor(error: Error) {
|
public constructor(error: Error) {
|
||||||
super(
|
super(
|
||||||
t("error.generic"),
|
i18n.t("error.generic"),
|
||||||
ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE,
|
ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE,
|
||||||
ErrorCategory.CONFIGURATION_ISSUE,
|
ErrorCategory.CONFIGURATION_ISSUE,
|
||||||
t("error.no_matrix_2_authorization_service"),
|
i18n.t("error.no_matrix_2_authorization_service"),
|
||||||
// Properly set it as a cause for a better reporting on sentry
|
// Properly set it as a cause for a better reporting on sentry
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
@@ -223,7 +222,7 @@ export class FailToStartLivekitConnection extends ElementCallError {
|
|||||||
*/
|
*/
|
||||||
public constructor(e?: string) {
|
public constructor(e?: string) {
|
||||||
super(
|
super(
|
||||||
t("error.failed_to_start_livekit"),
|
i18n.t("error.failed_to_start_livekit"),
|
||||||
ErrorCode.FAILED_TO_START_LIVEKIT,
|
ErrorCode.FAILED_TO_START_LIVEKIT,
|
||||||
ErrorCategory.NETWORK_CONNECTIVITY,
|
ErrorCategory.NETWORK_CONNECTIVITY,
|
||||||
e,
|
e,
|
||||||
@@ -237,10 +236,10 @@ export class FailToStartLivekitConnection extends ElementCallError {
|
|||||||
export class InsufficientCapacityError extends ElementCallError {
|
export class InsufficientCapacityError extends ElementCallError {
|
||||||
public constructor() {
|
public constructor() {
|
||||||
super(
|
super(
|
||||||
t("error.insufficient_capacity"),
|
i18n.t("error.insufficient_capacity"),
|
||||||
ErrorCode.INSUFFICIENT_CAPACITY_ERROR,
|
ErrorCode.INSUFFICIENT_CAPACITY_ERROR,
|
||||||
ErrorCategory.UNKNOWN,
|
ErrorCategory.UNKNOWN,
|
||||||
t("error.insufficient_capacity_description"),
|
i18n.t("error.insufficient_capacity_description"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,10 +251,10 @@ export class InsufficientCapacityError extends ElementCallError {
|
|||||||
export class SFURoomCreationRestrictedError extends ElementCallError {
|
export class SFURoomCreationRestrictedError extends ElementCallError {
|
||||||
public constructor() {
|
public constructor() {
|
||||||
super(
|
super(
|
||||||
t("error.room_creation_restricted"),
|
i18n.t("error.room_creation_restricted"),
|
||||||
ErrorCode.SFU_ERROR,
|
ErrorCode.SFU_ERROR,
|
||||||
ErrorCategory.CONFIGURATION_ISSUE,
|
ErrorCategory.CONFIGURATION_ISSUE,
|
||||||
t("error.room_creation_restricted_description"),
|
i18n.t("error.room_creation_restricted_description"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,7 +265,7 @@ export class SFURoomCreationRestrictedError extends ElementCallError {
|
|||||||
export class PeerConnectionTimeoutError extends ElementCallError {
|
export class PeerConnectionTimeoutError extends ElementCallError {
|
||||||
public constructor() {
|
public constructor() {
|
||||||
super(
|
super(
|
||||||
t("error.peer_connection_timeout"),
|
i18n.t("error.peer_connection_timeout"),
|
||||||
ErrorCode.SFU_ERROR,
|
ErrorCode.SFU_ERROR,
|
||||||
ErrorCategory.NETWORK_CONNECTIVITY,
|
ErrorCategory.NETWORK_CONNECTIVITY,
|
||||||
);
|
);
|
||||||
@@ -283,7 +282,7 @@ export class PeerConnectionTimeoutError extends ElementCallError {
|
|||||||
export class LivekitConnectionError extends ElementCallError {
|
export class LivekitConnectionError extends ElementCallError {
|
||||||
public constructor(cause: ConnectionError) {
|
public constructor(cause: ConnectionError) {
|
||||||
super(
|
super(
|
||||||
t("error.livekit_connection_error"),
|
i18n.t("error.livekit_connection_error"),
|
||||||
ErrorCode.SFU_ERROR,
|
ErrorCode.SFU_ERROR,
|
||||||
ErrorCategory.NETWORK_CONNECTIVITY,
|
ErrorCategory.NETWORK_CONNECTIVITY,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,5 +5,26 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Element Call's own i18next instance.
|
||||||
|
*
|
||||||
|
* We deliberately do not use the global i18next singleton: when Element Call is
|
||||||
|
* embedded in a host application (rather than running as its own page), that
|
||||||
|
* singleton belongs to the host, and configuring it would clobber the host's
|
||||||
|
* translations.
|
||||||
|
*
|
||||||
|
* It is configured by `Initializer.initBeforeReact` and made available to
|
||||||
|
* components via `<I18nextProvider>`; tests and stories configure it directly.
|
||||||
|
*
|
||||||
|
* Non-React code should call `i18n.t(...)` on this instance. Components should
|
||||||
|
* use `useTranslation()` instead, so that they re-render on a language change.
|
||||||
|
* Note that `t` must be reached through the instance at call time — i18next
|
||||||
|
* only assigns it during `init()`, so destructuring it at module scope would
|
||||||
|
* capture an uninitialised function.
|
||||||
|
*/
|
||||||
|
export const i18n: I18nInstance = i18next.createInstance();
|
||||||
|
|||||||
+4
-2
@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import "@formatjs/intl-durationformat/polyfill.js";
|
import "@formatjs/intl-durationformat/polyfill.js";
|
||||||
import "@formatjs/intl-segmenter/polyfill";
|
import "@formatjs/intl-segmenter/polyfill";
|
||||||
import i18n from "i18next";
|
|
||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
import { afterEach } from "vitest";
|
import { afterEach } from "vitest";
|
||||||
@@ -18,8 +17,11 @@ import "@testing-library/jest-dom/vitest";
|
|||||||
|
|
||||||
import EN from "../locales/en/app.json";
|
import EN from "../locales/en/app.json";
|
||||||
import { Config } from "./config/Config";
|
import { Config } from "./config/Config";
|
||||||
|
import { i18n } from "./utils/i18n";
|
||||||
|
|
||||||
// Bare-minimum i18n config
|
// Bare-minimum i18n config.
|
||||||
|
// Unlike the app, tests register the instance as react-i18next's default rather
|
||||||
|
// than wrapping every render in an <I18nextProvider>.
|
||||||
i18n
|
i18n
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
|
|||||||
Reference in New Issue
Block a user