diff --git a/package.json b/package.json index 236f88bd2b..7322eaa027 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/shared-components/.storybook/preview.tsx b/packages/shared-components/.storybook/preview.tsx index 52e7fd8b6c..91a6df646e 100644 --- a/packages/shared-components/.storybook/preview.tsx +++ b/packages/shared-components/.storybook/preview.tsx @@ -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 ( + + + + ); +}; + const preview: Preview = { tags: ["autodocs"], - decorators: [withThemeProvider, withTooltipProvider], + decorators: [withThemeProvider, withTooltipProvider, withI18nProvider], parameters: { options: { storySort: { diff --git a/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.test.tsx b/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.test.tsx index 018b388f6b..55e159f110 100644 --- a/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.test.tsx +++ b/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.test.tsx @@ -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(); + render(, { + wrapper: ({ children }) => {children}, + }); await user.click(screen.getByRole("button", { name: "Play" })); expect(togglePlay).toHaveBeenCalled(); diff --git a/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.tsx b/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.tsx index 29fb02ba34..d3c9fb87ee 100644 --- a/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.tsx +++ b/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.tsx @@ -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): JSX.Element { + const { translate: _t } = useI18n(); + const { playbackState, mediaName = _t("timeline|m.audio|unnamed_audio"), diff --git a/packages/shared-components/src/audio/PlayPauseButton/PlayPauseButton.tsx b/packages/shared-components/src/audio/PlayPauseButton/PlayPauseButton.tsx index 400357a3f5..cc2ab5bb65 100644 --- a/packages/shared-components/src/audio/PlayPauseButton/PlayPauseButton.tsx +++ b/packages/shared-components/src/audio/PlayPauseButton/PlayPauseButton.tsx @@ -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 { /** @@ -46,6 +46,8 @@ export function PlayPauseButton({ togglePlay, ...rest }: Readonly): JSX.Element { + const { translate: _t } = useI18n(); + const label = playing ? _t("action|pause") : _t("action|play"); return ( diff --git a/packages/shared-components/src/audio/SeekBar/SeekBar.tsx b/packages/shared-components/src/audio/SeekBar/SeekBar.tsx index 3063e2442d..a30e527eee 100644 --- a/packages/shared-components/src/audio/SeekBar/SeekBar.tsx +++ b/packages/shared-components/src/audio/SeekBar/SeekBar.tsx @@ -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 { /** @@ -33,6 +33,8 @@ interface ISeekCSS extends CSSProperties { * ``` */ export function SeekBar({ value = 0, className, ...rest }: Readonly): 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), []); diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 565f7013d3..8440a4fb0a 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -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"; diff --git a/packages/shared-components/src/pill-input/Pill/Pill.tsx b/packages/shared-components/src/pill-input/Pill/Pill.tsx index b2ac2e28b2..64fdac3d2d 100644 --- a/packages/shared-components/src/pill-input/Pill/Pill.tsx +++ b/packages/shared-components/src/pill-input/Pill/Pill.tsx @@ -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, "onClick"> { /** @@ -39,6 +39,7 @@ export interface PillProps extends Omit, "onClick */ export function Pill({ className, children, label, onClick, ...props }: PropsWithChildren): JSX.Element { const id = useId(); + const { translate: _t } = useI18n(); return ( { /** @@ -63,6 +63,8 @@ export const RichItem = memo(function RichItem({ selected, ...props }: RichItemProps): JSX.Element { + const i18n = useI18n(); + return (
  • {description} {timestamp && ( - {humanizeTime(timestamp)} + {i18n.humanizeTime(timestamp)} )}
  • diff --git a/packages/shared-components/src/test/utils/jest-matrix-react.tsx b/packages/shared-components/src/test/utils/jest-matrix-react.tsx index 30d673aa22..d610d87211 100644 --- a/packages/shared-components/src/test/utils/jest-matrix-react.tsx +++ b/packages/shared-components/src/test/utils/jest-matrix-react.tsx @@ -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 ( - - {children} - + + + {children} + + ); } else { - return {children}; + return ( + + {children} + + ); } }; }; diff --git a/packages/shared-components/src/utils/I18nApi.test.ts b/packages/shared-components/src/utils/I18nApi.test.ts new file mode 100644 index 0000000000..2b3431f07c --- /dev/null +++ b/packages/shared-components/src/utils/I18nApi.test.ts @@ -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!"); + }); +}); diff --git a/src/modules/I18nApi.ts b/packages/shared-components/src/utils/I18nApi.ts similarity index 82% rename from src/modules/I18nApi.ts rename to packages/shared-components/src/utils/I18nApi.ts index 9a195a47ec..20d641f5ce 100644 --- a/src/modules/I18nApi.ts +++ b/packages/shared-components/src/utils/I18nApi.ts @@ -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); + } } diff --git a/packages/shared-components/src/utils/humanize.ts b/packages/shared-components/src/utils/humanize.ts index 61f7705ace..1e00e69a9d 100644 --- a/packages/shared-components/src/utils/humanize.ts +++ b/packages/shared-components/src/utils/humanize.ts @@ -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"); diff --git a/packages/shared-components/src/utils/i18nContext.ts b/packages/shared-components/src/utils/i18nContext.ts new file mode 100644 index 0000000000..46c4185329 --- /dev/null +++ b/packages/shared-components/src/utils/i18nContext.ts @@ -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(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; +} diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 21706e9f34..3764051384 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -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 { return ( - - {view} - + + + {view} + + ); } diff --git a/src/components/views/dialogs/BaseDialog.tsx b/src/components/views/dialogs/BaseDialog.tsx index a946a44e91..8eba4d3fc6 100644 --- a/src/components/views/dialogs/BaseDialog.tsx +++ b/src/components/views/dialogs/BaseDialog.tsx @@ -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 { } return ( - - {this.props.screenName && } - - {this.props.top} -
    + + {this.props.screenName && } + - {!!(this.props.title || headerImage) && ( - - {headerImage} - {this.props.title} - - )} - {this.props.headerButton} -
    - {this.props.children} - {cancelButton} -
    -
    + {this.props.top} +
    + {!!(this.props.title || headerImage) && ( + + {headerImage} + {this.props.title} + + )} + {this.props.headerButton} +
    + {this.props.children} + {cancelButton} + + + ); } } diff --git a/src/modules/Api.ts b/src/modules/Api.ts index 2ff85c968f..057cbf71b0 100644 --- a/src/modules/Api.ts +++ b/src/modules/Api.ts @@ -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"; diff --git a/test/setup/setupLanguage.ts b/test/setup/setupLanguage.ts index 347f38edfc..124ff7cbbd 100644 --- a/test/setup/setupLanguage.ts +++ b/test/setup/setupLanguage.ts @@ -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; diff --git a/test/test-utils/jest-matrix-react.tsx b/test/test-utils/jest-matrix-react.tsx index ed5ff0075e..76150df478 100644 --- a/test/test-utils/jest-matrix-react.tsx +++ b/test/test-utils/jest-matrix-react.tsx @@ -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 ( - {children} + + {children} + ); } else { - return {children}; + return ( + + {children} + + ); } }; }; @@ -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; }; diff --git a/yarn.lock b/yarn.lock index 14ae639e6b..b70d671120 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"