Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion packages/shared-components/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
/**
Expand Down Expand Up @@ -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 (
Expand Down
4 changes: 3 additions & 1 deletion packages/shared-components/src/audio/SeekBar/SeekBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
/**
Expand All @@ -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), []);
Expand Down
2 changes: 2 additions & 0 deletions packages/shared-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,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";
Expand Down
3 changes: 2 additions & 1 deletion packages/shared-components/src/pill-input/Pill/Pill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"> {
/**
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
/**
Expand Down Expand Up @@ -63,6 +63,8 @@ export const RichItem = memo(function RichItem({
selected,
...props
}: RichItemProps): JSX.Element {
const i18n = useI18n();

return (
<li
className={styles.richItem}
Expand All @@ -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>
Expand Down
16 changes: 12 additions & 4 deletions packages/shared-components/src/test/utils/jest-matrix-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
);
}
};
};
Expand Down
22 changes: 22 additions & 0 deletions packages/shared-components/src/utils/I18nApi.test.ts
Original file line number Diff line number Diff line change
@@ -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!");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

/**
Expand Down Expand Up @@ -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);
}
}
8 changes: 6 additions & 2 deletions packages/shared-components/src/utils/humanize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
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;
Expand All @@ -21,13 +23,15 @@
* @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 {

Check failure on line 26 in packages/shared-components/src/utils/humanize.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 26 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZrGkwZzMul3jc-P7SZW&open=AZrGkwZzMul3jc-P7SZW&pullRequest=31347
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");
Expand Down
22 changes: 22 additions & 0 deletions packages/shared-components/src/utils/i18nContext.ts
Original file line number Diff line number Diff line change
@@ -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 { createContext, useContext } from "react";
import { type I18nApi } from "@element-hq/element-web-module-api";

export const I18nContext = createContext<I18nApi | null>(null);
I18nContext.displayName = "I18nContext";

export function useI18n(): I18nApi {
const i18n = useContext(I18nContext);

if (!i18n) {
throw new Error("useI18n must be used within an I18nContext.Provider");
}

return i18n;
}
9 changes: 6 additions & 3 deletions src/components/structures/MatrixChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -2218,9 +2219,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>
);
}
Expand Down
Loading
Loading