Introduce i18nContext (#31347)

* Introduce i18nContext

 * Adds a context that holds the module i1n API
 * Switches shared components to use that instead of importing it directly
 * Adds the context to MatrixChat and BaseDalog so it should be available most places in EW

This is a relatively small PR but does change the way the shared components do i18n so
just doing this one by itself (it stands by itself anyway).

This will allow shared components to use i18n when used in modules.

* Add the file

* Fix import lint

* Name the translate function _t

Then it should continue to get picked up by the script

This seems a bit flaky and ew but I'm not sure I want to get into
changing this in this PR.

* Put humanize back to calling something called _t too

* Missed one

* Add i18n context wrapper to stories

* Unused import

* Fix imports

* wrap richitem

* Wrap other richitem & richlist

* One day I will get my head around this syntax

* Fix import spacing

* Add wrapper to test

* unused import

* Hack around dependency cycle

* Make a moduleapi instance for tests

* Add i18n wrapper to jest-matrix-react

* Simple test for i18napi

* Import type

* Move i18n context wrapper to storybook template

* Unused imports & fix pill story

* Move i18n to its own provider

* Add i18ncontext wrapper to jest tests

* imports

* Bump module api to 1.7.0

* tsdoc
This commit is contained in:
David Baker
2025-12-02 13:47:14 +00:00
committed by GitHub
parent 04cf53e7aa
commit 5370f25870
21 changed files with 184 additions and 63 deletions

View File

@@ -81,7 +81,7 @@
},
"dependencies": {
"@babel/runtime": "^7.12.5",
"@element-hq/element-web-module-api": "1.6.0",
"@element-hq/element-web-module-api": "1.7.0",
"@element-hq/web-shared-components": "link:packages/shared-components",
"@fontsource/fira-code": "^5",
"@fontsource/inter": "^5",

View File

@@ -6,6 +6,7 @@ import React, { useLayoutEffect } from "react";
import { setLanguage } from "../src/utils/i18n";
import { TooltipProvider } from "@vector-im/compound-web";
import { StoryContext } from "storybook/internal/csf";
import { I18nApi, I18nContext } from "../src";
export const globalTypes = {
theme: {
@@ -70,9 +71,17 @@ const withTooltipProvider: Decorator = (Story) => {
);
};
const withI18nProvider: Decorator = (Story) => {
return (
<I18nContext.Provider value={new I18nApi()}>
<Story />
</I18nContext.Provider>
);
};
const preview: Preview = {
tags: ["autodocs"],
decorators: [withThemeProvider, withTooltipProvider],
decorators: [withThemeProvider, withTooltipProvider, withI18nProvider],
parameters: {
options: {
storySort: {

View File

@@ -14,6 +14,8 @@ import { fireEvent } from "@testing-library/dom";
import * as stories from "./AudioPlayerView.stories.tsx";
import { AudioPlayerView, type AudioPlayerViewActions, type AudioPlayerViewSnapshot } from "./AudioPlayerView";
import { MockViewModel } from "../../viewmodel/MockViewModel.ts";
import { I18nContext } from "../../utils/i18nContext.ts";
import { I18nApi } from "../../index.ts";
const { Default, NoMediaName, NoSize, HasError } = composeStories(stories);
@@ -64,7 +66,9 @@ describe("AudioPlayerView", () => {
error: false,
});
render(<AudioPlayerView vm={vm} />);
render(<AudioPlayerView vm={vm} />, {
wrapper: ({ children }) => <I18nContext.Provider value={new I18nApi()}>{children}</I18nContext.Provider>,
});
await user.click(screen.getByRole("button", { name: "Play" }));
expect(togglePlay).toHaveBeenCalled();

View File

@@ -14,7 +14,7 @@ import { Flex } from "../../utils/Flex";
import styles from "./AudioPlayerView.module.css";
import { PlayPauseButton } from "../PlayPauseButton";
import { type PlaybackState } from "../playback";
import { _t } from "../../utils/i18n";
import { useI18n } from "../../utils/i18nContext";
import { formatBytes } from "../../utils/FormattingUtils";
import { Clock } from "../Clock";
import { SeekBar } from "../SeekBar";
@@ -90,6 +90,8 @@ interface AudioPlayerViewProps {
* ```
*/
export function AudioPlayerView({ vm }: Readonly<AudioPlayerViewProps>): JSX.Element {
const { translate: _t } = useI18n();
const {
playbackState,
mediaName = _t("timeline|m.audio|unnamed_audio"),

View File

@@ -11,7 +11,7 @@ import Play from "@vector-im/compound-design-tokens/assets/web/icons/play-solid"
import Pause from "@vector-im/compound-design-tokens/assets/web/icons/pause-solid";
import styles from "./PlayPauseButton.module.css";
import { _t } from "../../utils/i18n";
import { useI18n } from "../../utils/i18nContext";
export interface PlayPauseButtonProps extends HTMLAttributes<HTMLButtonElement> {
/**
@@ -46,6 +46,8 @@ export function PlayPauseButton({
togglePlay,
...rest
}: Readonly<PlayPauseButtonProps>): JSX.Element {
const { translate: _t } = useI18n();
const label = playing ? _t("action|pause") : _t("action|play");
return (

View File

@@ -10,7 +10,7 @@ import { throttle } from "lodash";
import classNames from "classnames";
import style from "./SeekBar.module.css";
import { _t } from "../../utils/i18n";
import { useI18n } from "../../utils/i18nContext";
export interface SeekBarProps extends React.InputHTMLAttributes<HTMLInputElement> {
/**
@@ -33,6 +33,8 @@ interface ISeekCSS extends CSSProperties {
* ```
*/
export function SeekBar({ value = 0, className, ...rest }: Readonly<SeekBarProps>): JSX.Element {
const { translate: _t } = useI18n();
const [newValue, setNewValue] = useState(value);
// Throttle the value setting to avoid excessive re-renders
const setThrottledValue = useMemo(() => throttle(setNewValue, 10), []);

View File

@@ -23,10 +23,12 @@ export * from "./utils/Flex";
// Utils
export * from "./utils/i18n";
export * from "./utils/i18nContext";
export * from "./utils/humanize";
export * from "./utils/DateUtils";
export * from "./utils/numbers";
export * from "./utils/FormattingUtils";
export * from "./utils/I18nApi";
// MVVM
export * from "./viewmodel";

View File

@@ -12,7 +12,7 @@ import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"
import { Flex } from "../../utils/Flex";
import styles from "./Pill.module.css";
import { _t } from "../../utils/i18n";
import { useI18n } from "../../utils/i18nContext";
export interface PillProps extends Omit<HTMLAttributes<HTMLDivElement>, "onClick"> {
/**
@@ -39,6 +39,7 @@ export interface PillProps extends Omit<HTMLAttributes<HTMLDivElement>, "onClick
*/
export function Pill({ className, children, label, onClick, ...props }: PropsWithChildren<PillProps>): JSX.Element {
const id = useId();
const { translate: _t } = useI18n();
return (
<Flex

View File

@@ -8,8 +8,8 @@
import React from "react";
import { fn } from "storybook/test";
import { RichItem } from "./RichItem";
import type { Meta, StoryFn } from "@storybook/react-vite";
import { RichItem } from "./RichItem";
const currentTimestamp = new Date("2025-03-09T12:00:00Z").getTime();

View File

@@ -9,8 +9,8 @@ import React, { type HTMLAttributes, type JSX, memo } from "react";
import CheckIcon from "@vector-im/compound-design-tokens/assets/web/icons/check";
import styles from "./RichItem.module.css";
import { humanizeTime } from "../../utils/humanize";
import { Flex } from "../../utils/Flex";
import { useI18n } from "../../utils/i18nContext";
export interface RichItemProps extends HTMLAttributes<HTMLLIElement> {
/**
@@ -63,6 +63,8 @@ export const RichItem = memo(function RichItem({
selected,
...props
}: RichItemProps): JSX.Element {
const i18n = useI18n();
return (
<li
className={styles.richItem}
@@ -77,7 +79,7 @@ export const RichItem = memo(function RichItem({
<span className={styles.description}>{description}</span>
{timestamp && (
<span role="timer" className={styles.timestamp}>
{humanizeTime(timestamp)}
{i18n.humanizeTime(timestamp)}
</span>
)}
</li>

View File

@@ -16,16 +16,24 @@ import React, { type ReactElement } from "react";
import { render, type RenderOptions } from "@testing-library/react";
import { TooltipProvider } from "@vector-im/compound-web";
import { I18nApi, I18nContext } from "../..";
const wrapWithTooltipProvider = (Wrapper: RenderOptions["wrapper"]) => {
return ({ children }: { children: React.ReactNode }) => {
if (Wrapper) {
return (
<Wrapper>
<TooltipProvider>{children}</TooltipProvider>
</Wrapper>
<I18nContext.Provider value={new I18nApi()}>
<Wrapper>
<TooltipProvider>{children}</TooltipProvider>
</Wrapper>
</I18nContext.Provider>
);
} else {
return <TooltipProvider>{children}</TooltipProvider>;
return (
<I18nContext.Provider value={new I18nApi()}>
<TooltipProvider>{children}</TooltipProvider>
</I18nContext.Provider>
);
}
};
};

View File

@@ -0,0 +1,22 @@
/*
* Copyright 2025 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import { type TranslationKey } from "../i18nKeys";
import { I18nApi } from "./I18nApi";
describe("I18nApi", () => {
it("can register a translation and use it", () => {
const i18n = new I18nApi();
i18n.register({
"hello.world": {
en: "Hello, World!",
},
});
expect(i18n.translate("hello.world" as TranslationKey)).toBe("Hello, World!");
});
});

View File

@@ -6,16 +6,17 @@ Please see LICENSE files in the repository root for full details.
*/
import { type I18nApi as II18nApi, type Variables, type Translations } from "@element-hq/element-web-module-api";
import { registerTranslations } from "@element-hq/web-shared-components";
import { _t, getCurrentLanguage, type TranslationKey } from "../languageHandler.tsx";
import { humanizeTime } from "./humanize";
import { _t, getLocale, registerTranslations } from "./i18n";
import { type TranslationKey } from "../i18nKeys";
export class I18nApi implements II18nApi {
/**
* Read the current language of the user in IETF Language Tag format
*/
public get language(): string {
return getCurrentLanguage();
return getLocale();
}
/**
@@ -44,4 +45,8 @@ export class I18nApi implements II18nApi {
public translate(key: TranslationKey, variables?: Variables): string {
return _t(key, variables);
}
public humanizeTime(timeMillis: number): string {
return humanizeTime(timeMillis, this);
}
}

View File

@@ -6,7 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { _t } from "./i18n";
import { type I18nApi } from "@element-hq/element-web-module-api";
import { _t as _tFromModule } from "./i18n";
// These are the constants we use for when to break the text
const MILLISECONDS_RECENT = 15000;
@@ -21,13 +23,15 @@ const HOURS_1_DAY = 26;
* @param {number} timeMillis The time in millis to compare against.
* @returns {string} The humanized time.
*/
export function humanizeTime(timeMillis: number): string {
export function humanizeTime(timeMillis: number, i18nApi?: I18nApi): string {
const now = Date.now();
let msAgo = now - timeMillis;
const minutes = Math.abs(Math.ceil(msAgo / 60000));
const hours = Math.ceil(minutes / 60);
const days = Math.ceil(hours / 24);
const _t = i18nApi?.translate ?? _tFromModule;
if (msAgo >= 0) {
// Past
if (msAgo <= MILLISECONDS_RECENT) return _t("time|few_seconds_ago");

View File

@@ -0,0 +1,27 @@
/*
Copyright 2025 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { createContext, useContext } from "react";
import { type I18nApi } from "@element-hq/element-web-module-api";
export const I18nContext = createContext<I18nApi | null>(null);
I18nContext.displayName = "I18nContext";
/**
* A hook to get the i18n API from the context. Will throw if no i18n context is found.
* @throws If no i18n context is found
* @returns The i18n API from the context
*/
export function useI18n(): I18nApi {
const i18n = useContext(I18nContext);
if (!i18n) {
throw new Error("useI18n must be used within an I18nContext.Provider");
}
return i18n;
}

View File

@@ -28,6 +28,7 @@ import { TooltipProvider } from "@vector-im/compound-web";
// what-input helps improve keyboard accessibility
import "what-input";
import sanitizeHtml from "sanitize-html";
import { I18nContext } from "@element-hq/web-shared-components";
import PosthogTrackers from "../../PosthogTrackers";
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
@@ -2272,9 +2273,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
return (
<ErrorBoundary>
<SDKContext.Provider value={this.stores}>
<TooltipProvider>{view}</TooltipProvider>
</SDKContext.Provider>
<I18nContext.Provider value={ModuleApi.instance.i18n}>
<SDKContext.Provider value={this.stores}>
<TooltipProvider>{view}</TooltipProvider>
</SDKContext.Provider>
</I18nContext.Provider>
</ErrorBoundary>
);
}

View File

@@ -12,6 +12,7 @@ import React, { type JSX } from "react";
import FocusLock from "react-focus-lock";
import classNames from "classnames";
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { I18nContext } from "@element-hq/web-shared-components";
import AccessibleButton from "../elements/AccessibleButton";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@@ -164,38 +165,42 @@ export default class BaseDialog extends React.Component<IProps> {
}
return (
<MatrixClientContext.Provider value={this.matrixClient}>
{this.props.screenName && <PosthogScreenTracker screenName={this.props.screenName} />}
<FocusLock
returnFocus={true}
lockProps={lockProps}
className={classNames(this.props.className, {
mx_Dialog_fixedWidth: this.props.fixedWidth,
})}
>
{this.props.top}
<div
className={classNames("mx_Dialog_header", {
mx_Dialog_headerWithButton: !!this.props.headerButton,
// XXX: We can't import ModuleAPI here because it causes a dependency cycle - hack and
// use the copy on the window object :(
<I18nContext.Provider value={window.mxModuleApi.i18n}>
<MatrixClientContext.Provider value={this.matrixClient}>
{this.props.screenName && <PosthogScreenTracker screenName={this.props.screenName} />}
<FocusLock
returnFocus={true}
lockProps={lockProps}
className={classNames(this.props.className, {
mx_Dialog_fixedWidth: this.props.fixedWidth,
})}
>
{!!(this.props.title || headerImage) && (
<Heading
size="3"
as="h1"
className={classNames("mx_Dialog_title", this.props.titleClass)}
id="mx_BaseDialog_title"
>
{headerImage}
{this.props.title}
</Heading>
)}
{this.props.headerButton}
</div>
{this.props.children}
{cancelButton}
</FocusLock>
</MatrixClientContext.Provider>
{this.props.top}
<div
className={classNames("mx_Dialog_header", {
mx_Dialog_headerWithButton: !!this.props.headerButton,
})}
>
{!!(this.props.title || headerImage) && (
<Heading
size="3"
as="h1"
className={classNames("mx_Dialog_title", this.props.titleClass)}
id="mx_BaseDialog_title"
>
{headerImage}
{this.props.title}
</Heading>
)}
{this.props.headerButton}
</div>
{this.props.children}
{cancelButton}
</FocusLock>
</MatrixClientContext.Provider>
</I18nContext.Provider>
);
}
}

View File

@@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details.
import { createRoot, type Root } from "react-dom/client";
import { type Api, type RuntimeModuleConstructor } from "@element-hq/element-web-module-api";
import { I18nApi } from "@element-hq/web-shared-components";
import { ModuleRunner } from "./ModuleRunner.ts";
import AliasCustomisations from "../customisations/Alias.ts";
@@ -20,7 +21,6 @@ import UserIdentifierCustomisations from "../customisations/UserIdentifier.ts";
import { WidgetPermissionCustomisations } from "../customisations/WidgetPermissions.ts";
import { WidgetVariableCustomisations } from "../customisations/WidgetVariables.ts";
import { ConfigApi } from "./ConfigApi.ts";
import { I18nApi } from "./I18nApi.ts";
import { CustomComponentsApi } from "./customComponentApi";
import { WatchableProfile } from "./Profile.ts";
import { NavigationApi } from "./Navigation.ts";

View File

@@ -7,10 +7,12 @@ Please see LICENSE files in the repository root for full details.
*/
import fetchMock from "fetch-mock-jest";
import { ModuleLoader } from "@element-hq/element-web-module-api";
import * as languageHandler from "../../src/languageHandler";
import en from "../../src/i18n/strings/en_EN.json";
import de from "../../src/i18n/strings/de_DE.json";
import { ModuleApi } from "../../src/modules/Api";
const lv = {
Save: "Saglabāt",
@@ -43,3 +45,7 @@ setupLanguageMock();
languageHandler.setLanguage("en");
languageHandler.setMissingEntryGenerator((key) => key.split("|", 2)[1]);
// Set up the mdule API (so the i18n API exists)
const moduleLoader = new ModuleLoader(ModuleApi.instance);
window.mxModuleLoader = moduleLoader;

View File

@@ -10,17 +10,33 @@ import React, { type ReactElement } from "react";
// eslint-disable-next-line no-restricted-imports
import { render, type RenderOptions } from "@testing-library/react";
import { TooltipProvider } from "@vector-im/compound-web";
import { I18nContext } from "@element-hq/web-shared-components";
const wrapWithTooltipProvider = (Wrapper: RenderOptions["wrapper"]) => {
/**
* Wraps the provided components in:
* * A TooltipProvider
* * An I18nContext.Provider
*
* ...plus any wrapper provided in the options.
* @param Wrapper Additional wrapper to include
* @returns The wrapped component
*/
const wrapWithStandardContexts = (Wrapper: RenderOptions["wrapper"]) => {
return ({ children }: { children: React.ReactNode }) => {
if (Wrapper) {
return (
<Wrapper>
<TooltipProvider>{children}</TooltipProvider>
<I18nContext.Provider value={window.mxModuleApi.i18n}>
<TooltipProvider>{children}</TooltipProvider>
</I18nContext.Provider>
</Wrapper>
);
} else {
return <TooltipProvider>{children}</TooltipProvider>;
return (
<TooltipProvider>
<I18nContext.Provider value={window.mxModuleApi.i18n}>{children}</I18nContext.Provider>
</TooltipProvider>
);
}
};
};
@@ -28,7 +44,7 @@ const wrapWithTooltipProvider = (Wrapper: RenderOptions["wrapper"]) => {
const customRender = (ui: ReactElement, options: RenderOptions = {}) => {
return render(ui, {
...options,
wrapper: wrapWithTooltipProvider(options?.wrapper) as RenderOptions["wrapper"],
wrapper: wrapWithStandardContexts(options?.wrapper) as RenderOptions["wrapper"],
}) as ReturnType<typeof render>;
};

View File

@@ -1559,10 +1559,10 @@
resolved "https://registry.yarnpkg.com/@element-hq/element-call-embedded/-/element-call-embedded-0.16.1.tgz#28bdbde426051cc2a3228a36e7196e0a254569d3"
integrity sha512-g3v/QFuNy8YVRGrKC5SxjIYvgBh6biOHgejhJT2Jk/yjOOUEuP0y2PBaADm+suPD9BB/Vk1jPxFk2uEIpEzhpA==
"@element-hq/element-web-module-api@1.6.0":
version "1.6.0"
resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.6.0.tgz#bb109035cd0c82c094e2e83ba66b6c5b9788d58f"
integrity sha512-7xew6AVX4T3J37KyhdgHuiEYdDMMYJC0/aIQOmBvVylWQFnmMmbkmkuqOBqkumcx7q6LgkB0z3cSzdKAKHIw/g==
"@element-hq/element-web-module-api@1.7.0":
version "1.7.0"
resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.7.0.tgz#7657df25cc1e7075718af2c6ea8a4ebfaa9cfb2c"
integrity sha512-WhiJTmdETK8vvaYExqyhQ9rtLjxBv9PprWr6dCa1/1VRFSkfFZRlzy2P08nHX2YXpRMTpXb39SLeleR1dgLzow==
"@element-hq/element-web-playwright-common@^2.0.0":
version "2.0.0"
@@ -4153,13 +4153,14 @@
"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm":
version "0.0.0"
uid ""
"@vector-im/matrix-wysiwyg@2.40.0":
version "2.40.0"
resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.40.0.tgz#53c9ca5ea907d91e4515da64f20a82e5586b882c"
integrity sha512-8LRFLs5PEKYs4lOL7aJ4lL/hGCrvEvOYkCR3JggXYXDVMtX4LmfdlKYucSAe98pCmqAAbLRvlRcR1bTOYvM8ug==
dependencies:
"@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm"
"@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.40.0-53c9ca5ea907d91e4515da64f20a82e5586b882c-integrity/node_modules/bindings/wysiwyg-wasm"
"@vitest/expect@3.2.4":
version "3.2.4"
@@ -9599,7 +9600,7 @@ matrix-events-sdk@0.0.1:
jwt-decode "^4.0.0"
loglevel "^1.9.2"
matrix-events-sdk "0.0.1"
matrix-widget-api "^1.14.0"
matrix-widget-api "^1.10.0"
oidc-client-ts "^3.0.1"
p-retry "7"
sdp-transform "^3.0.0"