1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-24 19:10:22 +01:00

Support translation in background code (#19650)

This commit is contained in:
Matthew Walsh 2023-06-20 13:44:11 +01:00 committed by GitHub
parent 3bbfe87e9e
commit b247f272ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 872 additions and 361 deletions

View File

@ -1,6 +1,6 @@
import React, { Component, createContext, useMemo } from 'react'; import React, { Component, createContext, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { getMessage } from '../ui/helpers/utils/i18n-helper'; import { getMessage } from '../shared/modules/i18n';
import { I18nContext } from '../ui/contexts/i18n'; import { I18nContext } from '../ui/contexts/i18n';
export { I18nContext }; export { I18nContext };

View File

@ -2559,6 +2559,30 @@
"notePlaceholder": { "notePlaceholder": {
"message": "The approver will see this note when approving the transaction at the custodian." "message": "The approver will see this note when approving the transaction at the custodian."
}, },
"notificationTransactionFailedMessage": {
"message": "Transaction $1 failed! $2",
"description": "Content of the browser notification that appears when a transaction fails"
},
"notificationTransactionFailedMessageMMI": {
"message": "Transaction failed! $1",
"description": "Content of the browser notification that appears when a transaction fails in MMI"
},
"notificationTransactionFailedTitle": {
"message": "Failed transaction",
"description": "Title of the browser notification that appears when a transaction fails"
},
"notificationTransactionSuccessMessage": {
"message": "Transaction $1 confirmed!",
"description": "Content of the browser notification that appears when a transaction is confirmed"
},
"notificationTransactionSuccessTitle": {
"message": "Confirmed transaction",
"description": "Title of the browser notification that appears when a transaction is confirmed"
},
"notificationTransactionSuccessView": {
"message": "View on $1",
"description": "Additional content in browser notification that appears when a transaction is confirmed and has a block explorer URL"
},
"notifications": { "notifications": {
"message": "Notifications" "message": "Notifications"
}, },

View File

@ -205,6 +205,7 @@ import {
} from './controllers/permissions'; } from './controllers/permissions';
import createRPCMethodTrackingMiddleware from './lib/createRPCMethodTrackingMiddleware'; import createRPCMethodTrackingMiddleware from './lib/createRPCMethodTrackingMiddleware';
import { securityProviderCheck } from './lib/security-provider-helpers'; import { securityProviderCheck } from './lib/security-provider-helpers';
import { updateCurrentLocale } from './translate';
export const METAMASK_CONTROLLER_EVENTS = { export const METAMASK_CONTROLLER_EVENTS = {
// Fired after state changes that impact the extension badge (unapproved msg count) // Fired after state changes that impact the extension badge (unapproved msg count)
@ -360,6 +361,10 @@ export default class MetamaskController extends EventEmitter {
///: END:ONLY_INCLUDE_IN ///: END:ONLY_INCLUDE_IN
}); });
this.preferencesController.store.subscribe(async ({ currentLocale }) => {
await updateCurrentLocale(currentLocale);
});
this.tokensController = new TokensController({ this.tokensController = new TokensController({
chainId: this.networkController.store.getState().providerConfig.chainId, chainId: this.networkController.store.getState().providerConfig.chainId,
onPreferencesStateChange: this.preferencesController.store.subscribe.bind( onPreferencesStateChange: this.preferencesController.store.subscribe.bind(

View File

@ -6,6 +6,7 @@ import { getEnvironmentType } from '../lib/util';
import { ENVIRONMENT_TYPE_BACKGROUND } from '../../../shared/constants/app'; import { ENVIRONMENT_TYPE_BACKGROUND } from '../../../shared/constants/app';
import { TransactionStatus } from '../../../shared/constants/transaction'; import { TransactionStatus } from '../../../shared/constants/transaction';
import { getURLHostName } from '../../../ui/helpers/utils/util'; import { getURLHostName } from '../../../ui/helpers/utils/util';
import { t } from '../translate';
export default class ExtensionPlatform { export default class ExtensionPlatform {
// //
@ -181,22 +182,30 @@ export default class ExtensionPlatform {
toLower(getURLHostName(url).replace(/([.]\w+)$/u, '')), toLower(getURLHostName(url).replace(/([.]\w+)$/u, '')),
); );
const title = 'Confirmed transaction'; const title = t('notificationTransactionSuccessTitle');
const message = `Transaction ${nonce} confirmed! ${ let message = t('notificationTransactionSuccessMessage', nonce);
url.length ? `View on ${view}` : ''
}`; if (url.length) {
message += ` ${t('notificationTransactionSuccessView', view)}`;
}
await this._showNotification(title, message, url); await this._showNotification(title, message, url);
} }
async _showFailedTransaction(txMeta, errorMessage) { async _showFailedTransaction(txMeta, errorMessage) {
const nonce = parseInt(txMeta.txParams.nonce, 16); const nonce = parseInt(txMeta.txParams.nonce, 16);
const title = 'Failed transaction'; const title = t('notificationTransactionFailedTitle');
let message = `Transaction ${nonce} failed! ${ let message = t(
errorMessage || txMeta.err.message 'notificationTransactionFailedMessage',
}`; nonce,
errorMessage || txMeta.err.message,
);
///: BEGIN:ONLY_INCLUDE_IN(build-mmi) ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
if (isNaN(nonce)) { if (isNaN(nonce)) {
message = `Transaction failed! ${errorMessage || txMeta.err.message}`; message = t(
'notificationTransactionFailedMessageMMI',
errorMessage || txMeta.err.message,
);
} }
///: END:ONLY_INCLUDE_IN ///: END:ONLY_INCLUDE_IN
await this._showNotification(title, message); await this._showNotification(title, message);

View File

@ -0,0 +1,125 @@
import {
getMessage,
fetchLocale,
FALLBACK_LOCALE,
} from '../../shared/modules/i18n';
import { t, updateCurrentLocale } from './translate';
const localeCodeMock = 'te';
const keyMock = 'testKey';
const substitutionsMock = ['a1', 'b2'];
const messageMock = 'testMessage';
const messageMock2 = 'testMessage2';
const alternateLocaleDataMock = { [keyMock]: { message: messageMock2 } };
jest.mock('../../shared/modules/i18n');
jest.mock('../_locales/en/messages.json', () => ({
[keyMock]: { message: messageMock },
}));
describe('Translate', () => {
const getMessageMock = getMessage as jest.MockedFunction<typeof getMessage>;
const fetchLocaleMock = fetchLocale as jest.MockedFunction<
typeof fetchLocale
>;
beforeEach(async () => {
jest.resetAllMocks();
await updateCurrentLocale(FALLBACK_LOCALE);
});
describe('updateCurrentLocale', () => {
it('retrieves locale data from shared module', async () => {
await updateCurrentLocale(localeCodeMock);
expect(fetchLocale).toHaveBeenCalledTimes(1);
expect(fetchLocale).toHaveBeenCalledWith(localeCodeMock);
});
it('does not retrieve locale data if same locale already set', async () => {
await updateCurrentLocale(localeCodeMock);
await updateCurrentLocale(localeCodeMock);
expect(fetchLocale).toHaveBeenCalledTimes(1);
expect(fetchLocale).toHaveBeenCalledWith(localeCodeMock);
});
it('does not retrieve locale data if fallback locale set', async () => {
await updateCurrentLocale(localeCodeMock);
await updateCurrentLocale(FALLBACK_LOCALE);
expect(fetchLocale).toHaveBeenCalledTimes(1);
expect(fetchLocale).toHaveBeenCalledWith(localeCodeMock);
});
});
describe('t', () => {
it('returns value from shared module', () => {
getMessageMock.mockReturnValue(messageMock);
expect(t(keyMock, ...substitutionsMock)).toStrictEqual(messageMock);
});
it('uses en locale by default', () => {
getMessageMock.mockReturnValue(messageMock);
t(keyMock, ...substitutionsMock);
expect(getMessage).toHaveBeenCalledTimes(1);
expect(getMessage).toHaveBeenCalledWith(
FALLBACK_LOCALE,
{ [keyMock]: { message: messageMock } },
keyMock,
substitutionsMock,
);
});
it('uses locale passed to updateCurrentLocale if called', async () => {
(getMessage as jest.MockedFunction<typeof getMessage>).mockReturnValue(
messageMock,
);
fetchLocaleMock.mockResolvedValueOnce(alternateLocaleDataMock);
await updateCurrentLocale(localeCodeMock);
t(keyMock, ...substitutionsMock);
expect(getMessage).toHaveBeenCalledTimes(1);
expect(getMessage).toHaveBeenCalledWith(
localeCodeMock,
alternateLocaleDataMock,
keyMock,
substitutionsMock,
);
});
it('returns value from en locale as fallback if current locale returns null', async () => {
(
getMessage as jest.MockedFunction<typeof getMessage>
).mockReturnValueOnce(null);
(
getMessage as jest.MockedFunction<typeof getMessage>
).mockReturnValueOnce(messageMock2);
fetchLocaleMock.mockResolvedValueOnce(alternateLocaleDataMock);
await updateCurrentLocale(localeCodeMock);
expect(t(keyMock, ...substitutionsMock)).toStrictEqual(messageMock2);
expect(getMessage).toHaveBeenCalledTimes(2);
expect(getMessage).toHaveBeenCalledWith(
FALLBACK_LOCALE,
{ [keyMock]: { message: messageMock } },
keyMock,
substitutionsMock,
);
expect(getMessage).toHaveBeenCalledWith(
localeCodeMock,
alternateLocaleDataMock,
keyMock,
substitutionsMock,
);
});
});
});

31
app/scripts/translate.ts Normal file
View File

@ -0,0 +1,31 @@
import enTranslations from '../_locales/en/messages.json';
import {
FALLBACK_LOCALE,
I18NMessageDict,
fetchLocale,
getMessage,
} from '../../shared/modules/i18n';
let currentLocale: string = FALLBACK_LOCALE;
let translations: I18NMessageDict = enTranslations;
export async function updateCurrentLocale(locale: string): Promise<void> {
if (currentLocale === locale) {
return;
}
if (locale === FALLBACK_LOCALE) {
translations = enTranslations;
} else {
translations = await fetchLocale(locale);
}
currentLocale = locale;
}
export function t(key: string, ...substitutions: string[]): string | null {
return (
getMessage(currentLocale, translations, key, substitutions) ||
getMessage(FALLBACK_LOCALE, enTranslations, key, substitutions)
);
}

View File

@ -187,6 +187,7 @@ async function verifyEnglishLocale() {
'shared/**/*.ts', 'shared/**/*.ts',
'app/scripts/constants/**/*.js', 'app/scripts/constants/**/*.js',
'app/scripts/constants/**/*.ts', 'app/scripts/constants/**/*.ts',
'app/scripts/platforms/**/*.js',
], ],
{ {
ignore: [...globsToStrictSearch, testGlob], ignore: [...globsToStrictSearch, testGlob],

View File

@ -51,6 +51,7 @@ module.exports = {
'<rootDir>/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js', '<rootDir>/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js',
'<rootDir>/app/scripts/migrations/*.test.(js|ts)', '<rootDir>/app/scripts/migrations/*.test.(js|ts)',
'<rootDir>/app/scripts/platforms/*.test.js', '<rootDir>/app/scripts/platforms/*.test.js',
'<rootDir>/app/scripts/translate.test.ts',
'<rootDir>/shared/**/*.test.(js|ts)', '<rootDir>/shared/**/*.test.(js|ts)',
'<rootDir>/ui/**/*.test.(js|ts|tsx)', '<rootDir>/ui/**/*.test.(js|ts|tsx)',
'<rootDir>/development/fitness-functions/**/*.test.(js|ts|tsx)', '<rootDir>/development/fitness-functions/**/*.test.(js|ts|tsx)',

View File

@ -3,10 +3,7 @@ import browser from 'webextension-polyfill';
///: END:ONLY_INCLUDE_IN ///: END:ONLY_INCLUDE_IN
import { memoize } from 'lodash'; import { memoize } from 'lodash';
import getFirstPreferredLangCode from '../../app/scripts/lib/get-first-preferred-lang-code'; import getFirstPreferredLangCode from '../../app/scripts/lib/get-first-preferred-lang-code';
import { import { fetchLocale, loadRelativeTimeFormatLocaleData } from '../modules/i18n';
fetchLocale,
loadRelativeTimeFormatLocaleData,
} from '../../ui/helpers/utils/i18n-helper';
///: BEGIN:ONLY_INCLUDE_IN(desktop) ///: BEGIN:ONLY_INCLUDE_IN(desktop)
import { renderDesktopError } from '../../ui/pages/desktop-error/render-desktop-error'; import { renderDesktopError } from '../../ui/pages/desktop-error/render-desktop-error';
import { EXTENSION_ERROR_PAGE_TYPES } from '../constants/desktop'; import { EXTENSION_ERROR_PAGE_TYPES } from '../constants/desktop';

View File

@ -1,5 +1,5 @@
import browser from 'webextension-polyfill'; import browser from 'webextension-polyfill';
import { fetchLocale } from '../../ui/helpers/utils/i18n-helper'; import { fetchLocale } from '../modules/i18n';
import { SUPPORT_LINK } from './ui-utils'; import { SUPPORT_LINK } from './ui-utils';
import { import {
downloadDesktopApp, downloadDesktopApp,
@ -12,7 +12,7 @@ import {
} from './error-utils'; } from './error-utils';
import { openCustomProtocol } from './deep-linking'; import { openCustomProtocol } from './deep-linking';
jest.mock('../../ui/helpers/utils/i18n-helper', () => ({ jest.mock('../modules/i18n', () => ({
fetchLocale: jest.fn(), fetchLocale: jest.fn(),
loadRelativeTimeFormatLocaleData: jest.fn(), loadRelativeTimeFormatLocaleData: jest.fn(),
})); }));

335
shared/modules/i18n.test.ts Normal file
View File

@ -0,0 +1,335 @@
import log from 'loglevel';
import {
FALLBACK_LOCALE,
I18NMessageDict,
clearCaches,
fetchLocale,
getMessage,
loadRelativeTimeFormatLocaleData,
} from './i18n';
const localeCodeMock = 'te';
const keyMock = 'testKey';
const errorLocaleMock = 'testLocaleError';
const errorMock = 'TestError';
jest.mock('loglevel');
jest.mock('./fetch-with-timeout', () =>
jest.fn(() => (url: string) => {
return Promise.resolve({
json: () => {
if (url.includes(errorLocaleMock)) {
throw new Error(errorMock);
}
return { url };
},
});
}),
);
describe('I18N Module', () => {
beforeEach(() => {
jest.resetAllMocks();
clearCaches();
process.env.IN_TEST = 'true';
});
describe('getMessage', () => {
describe('on error', () => {
it('returns null if no messages', () => {
expect(
getMessage(
localeCodeMock,
null as unknown as I18NMessageDict,
keyMock,
),
).toBeNull();
});
describe('if missing key', () => {
describe('if not using fallback locale', () => {
it('logs warning', () => {
expect(
getMessage(
localeCodeMock,
{} as unknown as I18NMessageDict,
keyMock,
),
).toBeNull();
expect(log.warn).toHaveBeenCalledTimes(1);
expect(log.warn).toHaveBeenCalledWith(
`Translator - Unable to find value of key "${keyMock}" for locale "${localeCodeMock}"`,
);
});
it('does not log warning if warning already created', () => {
expect(
getMessage(
localeCodeMock,
{} as unknown as I18NMessageDict,
keyMock,
),
).toBeNull();
expect(
getMessage(
localeCodeMock,
{} as unknown as I18NMessageDict,
keyMock,
),
).toBeNull();
expect(log.warn).toHaveBeenCalledTimes(1);
expect(log.warn).toHaveBeenCalledWith(
`Translator - Unable to find value of key "${keyMock}" for locale "${localeCodeMock}"`,
);
});
});
describe('if using fallback locale', () => {
it('logs error', () => {
delete process.env.IN_TEST;
expect(
getMessage(
FALLBACK_LOCALE,
{} as unknown as I18NMessageDict,
keyMock,
),
).toBeNull();
expect(log.error).toHaveBeenCalledTimes(1);
expect(log.error).toHaveBeenCalledWith(
new Error(
`Unable to find value of key "${keyMock}" for locale "${FALLBACK_LOCALE}"`,
),
);
});
it('throws if test env set', () => {
expect(() =>
getMessage(
FALLBACK_LOCALE,
{} as unknown as I18NMessageDict,
keyMock,
),
).toThrow(
`Unable to find value of key "${keyMock}" for locale "${FALLBACK_LOCALE}"`,
);
});
it('calls onError callback', () => {
const onErrorMock = jest.fn();
try {
getMessage(
FALLBACK_LOCALE,
{} as unknown as I18NMessageDict,
keyMock,
[],
onErrorMock,
);
} catch {
// Expected
}
expect(onErrorMock).toHaveBeenCalledTimes(1);
expect(onErrorMock).toHaveBeenCalledWith(
new Error(
`Unable to find value of key "${keyMock}" for locale "${FALLBACK_LOCALE}"`,
),
);
});
it('does nothing if error already created', () => {
const onErrorMock = jest.fn();
try {
getMessage(
FALLBACK_LOCALE,
{} as unknown as I18NMessageDict,
keyMock,
[],
onErrorMock,
);
} catch {
// Expected
}
getMessage(
FALLBACK_LOCALE,
{} as unknown as I18NMessageDict,
keyMock,
[],
onErrorMock,
);
expect(log.error).toHaveBeenCalledTimes(1);
expect(onErrorMock).toHaveBeenCalledTimes(1);
});
});
});
describe('if missing substitution', () => {
it('logs error', () => {
expect(
getMessage(
localeCodeMock,
{ [keyMock]: { message: 'test1 $1 test2 $2 test3' } },
keyMock,
['a1'],
),
).toStrictEqual('test1 a1 test2 test3');
expect(log.error).toHaveBeenCalledTimes(1);
expect(log.error).toHaveBeenCalledWith(
new Error(
`Insufficient number of substitutions for key "${keyMock}" with locale "${localeCodeMock}"`,
),
);
});
it('calls onError callback', () => {
const onErrorMock = jest.fn();
expect(
getMessage(
localeCodeMock,
{ [keyMock]: { message: 'test1 $1 test2 $2 test3' } },
keyMock,
['a1'],
onErrorMock,
),
).toStrictEqual('test1 a1 test2 test3');
expect(onErrorMock).toHaveBeenCalledTimes(1);
expect(onErrorMock).toHaveBeenCalledWith(
new Error(
`Insufficient number of substitutions for key "${keyMock}" with locale "${localeCodeMock}"`,
),
);
});
it('does nothing if error already created', () => {
const onErrorMock = jest.fn();
expect(
getMessage(
localeCodeMock,
{ [keyMock]: { message: 'test1 $1 test2 $2 test3' } },
keyMock,
['a1'],
onErrorMock,
),
).toStrictEqual('test1 a1 test2 test3');
expect(
getMessage(
localeCodeMock,
{ [keyMock]: { message: 'test1 $1 test2 $2 test3' } },
keyMock,
['a1'],
onErrorMock,
),
).toStrictEqual('test1 a1 test2 test3');
expect(log.error).toHaveBeenCalledTimes(1);
expect(onErrorMock).toHaveBeenCalledTimes(1);
});
});
});
it('returns text only if no substitutions', () => {
expect(
getMessage(
localeCodeMock,
{ [keyMock]: { message: 'testValue' } },
keyMock,
),
).toStrictEqual('testValue');
});
it('returns text including substitutions', () => {
expect(
getMessage(
localeCodeMock,
{ [keyMock]: { message: 'test1 $1 test2 $2 test3' } },
keyMock,
['a1', 'b2'],
),
).toStrictEqual('test1 a1 test2 b2 test3');
});
it('returns text including substitutions using custom join', () => {
expect(
getMessage(
localeCodeMock,
{ [keyMock]: { message: 'test1 $1 test2 $2 test3' } },
keyMock,
['a1', 'b2'],
undefined,
(substitutions) => substitutions.join(','),
),
).toStrictEqual('test1 ,a1, test2 ,b2, test3');
});
});
describe('fetchLocale', () => {
it('returns json from locale file', async () => {
const result = await fetchLocale(localeCodeMock);
expect(result).toStrictEqual({
url: `./_locales/${localeCodeMock}/messages.json`,
});
});
it('logs if fetch fails', async () => {
await fetchLocale(errorLocaleMock);
expect(log.error).toHaveBeenCalledTimes(1);
expect(log.error).toHaveBeenCalledWith(
`failed to fetch testLocaleError locale because of Error: ${errorMock}`,
);
});
it('returns empty object if fetch fails', async () => {
expect(await fetchLocale(errorLocaleMock)).toStrictEqual({});
});
});
describe('loadRelativeTimeFormatLocaleData', () => {
it('adds locale data if function exists', async () => {
const addMock = jest.fn();
global.Intl = {
RelativeTimeFormat: {
__addLocaleData: addMock,
},
} as any;
await loadRelativeTimeFormatLocaleData(`${localeCodeMock}_test`);
expect(addMock).toHaveBeenCalledTimes(1);
expect(addMock).toHaveBeenCalledWith({
url: `./intl/${localeCodeMock}/relative-time-format-data.json`,
});
});
it('does not add locale data if language tag already processed', async () => {
const addMock = jest.fn();
global.Intl = {
RelativeTimeFormat: {
__addLocaleData: addMock,
},
} as any;
await loadRelativeTimeFormatLocaleData(`${localeCodeMock}_test`);
await loadRelativeTimeFormatLocaleData(`${localeCodeMock}_test`);
expect(addMock).toHaveBeenCalledTimes(1);
});
});
});

223
shared/modules/i18n.ts Normal file
View File

@ -0,0 +1,223 @@
import log from 'loglevel';
import { Json } from '@metamask/utils';
import getFetchWithTimeout from './fetch-with-timeout';
const fetchWithTimeout = getFetchWithTimeout();
// From app/_locales folders there is a messages.json file such as app/_locales/en, comes with key and translated results
// and we use as t('reject') to get the translated message in the codebase
// and in i18n lib, the translated message is an object (I18NMessage) with message & description -
// message is the string that will replace the translationKey, and that message may contain replacement variables such as $1, $2, etc.
// Description is key describing the usage of the message.
export interface I18NMessage {
message: string;
description?: string;
}
// The overall translation file is made of same entries
// translationKey (string) and the I18NMessage as the value.
export interface I18NMessageDict {
[translationKey: string]: I18NMessage;
}
export type I18NSubstitution = string | (() => any) | object;
// A parameterized type (or generic type) of maps that use the same structure (translationKey) key
interface I18NMessageDictMap<R> {
[translationKey: string]: R;
}
export const FALLBACK_LOCALE = 'en';
const warned: { [localeCode: string]: I18NMessageDictMap<boolean> } = {};
const missingMessageErrors: I18NMessageDictMap<Error> = {};
const missingSubstitutionErrors: {
[localeCode: string]: I18NMessageDictMap<boolean>;
} = {};
const relativeTimeFormatLocaleData = new Set();
/**
* Returns a localized message for the given key
*
* @param localeCode - The code for the current locale
* @param localeMessages - The map of messages for the current locale
* @param key - The message key
* @param substitutions - A list of message substitution replacements can replace $n in given message
* @param onError - An optional callback to provide additional processing on any errors
* @param join - An optional callback to join the substituted parts using custom logic
* @returns The localized message
*/
export const getMessage = <T>(
localeCode: string,
localeMessages: I18NMessageDict,
key: string,
substitutions?: I18NSubstitution[],
onError?: (error: Error) => void,
join?: (substitutedParts: I18NSubstitution[]) => T,
): T | string | null => {
if (!localeMessages) {
return null;
}
const message = localeMessages[key];
if (!message) {
missingKeyError(key, localeCode, onError);
return null;
}
const text = message.message;
const parts = hasSubstitutions(substitutions)
? applySubstitutions(
text,
substitutions as I18NSubstitution[],
key,
localeCode,
onError,
)
: [text];
return join ? join(parts) : parts.join('');
};
export async function fetchLocale(
localeCode: string,
): Promise<I18NMessageDict> {
try {
const response = await fetchWithTimeout(
`./_locales/${localeCode}/messages.json`,
);
return await response.json();
} catch (error) {
log.error(`failed to fetch ${localeCode} locale because of ${error}`);
return {};
}
}
export async function loadRelativeTimeFormatLocaleData(
localeCode: string,
): Promise<void> {
const languageTag = localeCode.split('_')[0];
if (
Intl.RelativeTimeFormat &&
typeof (Intl.RelativeTimeFormat as any).__addLocaleData === 'function' &&
!relativeTimeFormatLocaleData.has(languageTag)
) {
const localeData = await fetchRelativeTimeFormatData(languageTag);
(Intl.RelativeTimeFormat as any).__addLocaleData(localeData);
relativeTimeFormatLocaleData.add(languageTag);
}
}
export function clearCaches() {
Object.keys(warned).forEach((key) => {
delete warned[key];
});
Object.keys(missingMessageErrors).forEach((key) => {
delete missingMessageErrors[key];
});
Object.keys(missingSubstitutionErrors).forEach((key) => {
delete missingSubstitutionErrors[key];
});
relativeTimeFormatLocaleData.clear();
}
function applySubstitutions(
message: string,
substitutions: I18NSubstitution[],
key: string,
localeCode: string,
onError?: (error: Error) => void,
): I18NSubstitution[] {
const parts = message.split(/(\$\d)/gu);
return parts.map((part: string) => {
const subMatch = part.match(/\$(\d)/u);
if (!subMatch) {
return part;
}
const substituteIndex = Number(subMatch[1]) - 1;
const substitution = substitutions[substituteIndex];
if (substitution === null || substitution === undefined) {
missingSubstitutionError(key, localeCode, onError);
}
return substitutions?.[substituteIndex];
});
}
function missingKeyError(
key: string,
localeCode: string,
onError?: (error: Error) => void,
) {
if (localeCode === FALLBACK_LOCALE && !missingMessageErrors[key]) {
const error = new Error(
`Unable to find value of key "${key}" for locale "${localeCode}"`,
);
missingMessageErrors[key] = error;
onError?.(error);
log.error(error);
if (process.env.IN_TEST) {
throw error;
}
}
if (localeCode === FALLBACK_LOCALE || warned[localeCode]?.[key]) {
return;
}
warned[localeCode] = warned[localeCode] ?? {};
warned[localeCode][key] = true;
log.warn(
`Translator - Unable to find value of key "${key}" for locale "${localeCode}"`,
);
}
function missingSubstitutionError(
key: string,
localeCode: string,
onError?: (error: Error) => void,
) {
if (missingSubstitutionErrors[localeCode]?.[key]) {
return;
}
missingSubstitutionErrors[localeCode] =
missingSubstitutionErrors[localeCode] ?? {};
missingSubstitutionErrors[localeCode][key] = true;
const error = new Error(
`Insufficient number of substitutions for key "${key}" with locale "${localeCode}"`,
);
log.error(error);
onError?.(error);
}
function hasSubstitutions(substitutions?: I18NSubstitution[]) {
return (substitutions?.length ?? 0) > 0;
}
async function fetchRelativeTimeFormatData(languageTag: string): Promise<Json> {
const response = await fetchWithTimeout(
`./intl/${languageTag}/relative-time-format-data.json`,
);
return await response.json();
}

View File

@ -14,7 +14,8 @@
"outDir": "tsout", "outDir": "tsout",
"rootDir": ".", "rootDir": ".",
"sourceMap": true, "sourceMap": true,
"strict": true "strict": true,
"resolveJsonModule": true
}, },
"exclude": [ "exclude": [
"**/jest-coverage/**/*", "**/jest-coverage/**/*",

View File

@ -1,12 +1,12 @@
import React, { Component, createContext, useMemo } from 'react'; import React, { Component, createContext, useMemo } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { getMessage } from '../helpers/utils/i18n-helper';
import { import {
getCurrentLocale, getCurrentLocale,
getCurrentLocaleMessages, getCurrentLocaleMessages,
getEnLocaleMessages, getEnLocaleMessages,
} from '../ducks/locale/locale'; } from '../ducks/locale/locale';
import { getMessage } from '../helpers/utils/i18n-helper';
export const I18nContext = createContext((key) => `[${key}]`); export const I18nContext = createContext((key) => `[${key}]`);

View File

@ -1,64 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`i18n helper getMessage should return the correct message when a single react substitution is made 1`] = ` exports[`I18N Helper getMessage renders substitutions inside span if substitutions include React components 1`] = `
<div> <div>
<span> <span>
Testing a react substitution
<div
style="color: red;"
>
TEST_SUBSTITUTION_1
</div>
.
</span>
</div>
`;
exports[`i18n helper getMessage should return the correct message when substituting a mix of react elements and strings 1`] = `
<div>
<span>
Testing a mix
TEST_SUBSTITUTION_1
of react substitutions
<div <div
style="color: orange;" style="color: orange;"
> >
TEST_SUBSTITUTION_3 a1
</div> </div>
and string substitutions
TEST_SUBSTITUTION_2
+
<div <div
style="color: pink;" style="color: pink;"
> >
TEST_SUBSTITUTION_4 b2
</div> </div>
. c3
</span>
</div>
`;
exports[`i18n helper getMessage should return the correct message when two react substitutions are made 1`] = `
<div>
<span>
Testing a react substitution
<div
style="color: red;"
>
TEST_SUBSTITUTION_1
</div>
and another
<div
style="color: blue;"
>
TEST_SUBSTITUTION_2
</div>
.
</span> </span>
</div> </div>

View File

@ -1,169 +1,93 @@
import React from 'react'; import React from 'react';
import * as Sentry from '@sentry/browser';
import { getMessage as getMessageShared } from '../../../shared/modules/i18n';
import { renderWithProvider } from '../../../test/lib/render-helpers'; import { renderWithProvider } from '../../../test/lib/render-helpers';
import { getMessage } from './i18n-helper'; import { getMessage } from './i18n-helper';
describe('i18n helper', () => { jest.mock('../../../shared/modules/i18n');
const TEST_LOCALE_CODE = 'TEST_LOCALE_CODE'; jest.mock('@sentry/browser');
const TEST_KEY_1 = 'TEST_KEY_1'; const localeCodeMock = 'te';
const TEST_KEY_2 = 'TEST_KEY_2'; const keyMock = 'testKey';
const TEST_KEY_3 = 'TEST_KEY_3'; const localeMessagesMock = { [keyMock]: { message: 'testMessage' } };
const TEST_KEY_4 = 'TEST_KEY_4'; const errorMock = new Error('testError');
const TEST_KEY_5 = 'TEST_KEY_5'; const messageMock = 'testMessage';
const TEST_KEY_6 = 'TEST_KEY_6';
const TEST_KEY_6_HELPER = 'TEST_KEY_6_HELPER';
const TEST_KEY_7 = 'TEST_KEY_7';
const TEST_KEY_7_HELPER_1 = 'TEST_KEY_7_HELPER_1';
const TEST_KEY_7_HELPER_2 = 'TEST_KEY_7_HELPER_2';
const TEST_KEY_8 = 'TEST_KEY_8';
const TEST_KEY_8_HELPER_1 = 'TEST_KEY_8_HELPER_1';
const TEST_KEY_8_HELPER_2 = 'TEST_KEY_8_HELPER_2';
const TEST_SUBSTITUTION_1 = 'TEST_SUBSTITUTION_1'; describe('I18N Helper', () => {
const TEST_SUBSTITUTION_2 = 'TEST_SUBSTITUTION_2'; beforeEach(() => {
const TEST_SUBSTITUTION_3 = 'TEST_SUBSTITUTION_3'; jest.resetAllMocks();
const TEST_SUBSTITUTION_4 = 'TEST_SUBSTITUTION_4'; });
const TEST_SUBSTITUTION_5 = 'TEST_SUBSTITUTION_5';
const testLocaleMessages = {
[TEST_KEY_1]: {
message: 'This is a simple message.',
expectedResult: 'This is a simple message.',
},
[TEST_KEY_2]: {
message: 'This is a message with a single non-react substitution $1.',
},
[TEST_KEY_3]: {
message: 'This is a message with two non-react substitutions $1 and $2.',
},
[TEST_KEY_4]: {
message: '$1 - $2 - $3 - $4 - $5',
},
[TEST_KEY_5]: {
message: '$1 - $2 - $3',
},
[TEST_KEY_6]: {
message: 'Testing a react substitution $1.',
},
[TEST_KEY_6_HELPER]: {
message: TEST_SUBSTITUTION_1,
},
[TEST_KEY_7]: {
message: 'Testing a react substitution $1 and another $2.',
},
[TEST_KEY_7_HELPER_1]: {
message: TEST_SUBSTITUTION_1,
},
[TEST_KEY_7_HELPER_2]: {
message: TEST_SUBSTITUTION_2,
},
[TEST_KEY_8]: {
message:
'Testing a mix $1 of react substitutions $2 and string substitutions $3 + $4.',
},
[TEST_KEY_8_HELPER_1]: {
message: TEST_SUBSTITUTION_3,
},
[TEST_KEY_8_HELPER_2]: {
message: TEST_SUBSTITUTION_4,
},
};
const t = getMessage.bind(null, TEST_LOCALE_CODE, testLocaleMessages);
const TEST_SUBSTITUTION_6 = (
<div style={{ color: 'red' }} key="test-react-substitutions-1">
{t(TEST_KEY_6_HELPER)}
</div>
);
const TEST_SUBSTITUTION_7_1 = (
<div style={{ color: 'red' }} key="test-react-substitutions-7-1">
{t(TEST_KEY_7_HELPER_1)}
</div>
);
const TEST_SUBSTITUTION_7_2 = (
<div style={{ color: 'blue' }} key="test-react-substitutions-7-2">
{t(TEST_KEY_7_HELPER_2)}
</div>
);
const TEST_SUBSTITUTION_8_1 = (
<div style={{ color: 'orange' }} key="test-react-substitutions-8-1">
{t(TEST_KEY_8_HELPER_1)}
</div>
);
const TEST_SUBSTITUTION_8_2 = (
<div style={{ color: 'pink' }} key="test-react-substitutions-1">
{t(TEST_KEY_8_HELPER_2)}
</div>
);
describe('getMessage', () => { describe('getMessage', () => {
it('should return the exact message paired with key if there are no substitutions', () => { it('returns value from getMessage in shared module', () => {
const result = t(TEST_KEY_1); getMessageShared.mockReturnValue(messageMock);
expect(result).toStrictEqual('This is a simple message.');
});
it('should return the correct message when a single non-react substitution is made', () => { expect(
const result = t(TEST_KEY_2, [TEST_SUBSTITUTION_1]); getMessage(localeCodeMock, localeMessagesMock, keyMock),
expect(result).toStrictEqual( ).toStrictEqual(messageMock);
`This is a message with a single non-react substitution ${TEST_SUBSTITUTION_1}.`,
expect(getMessageShared).toHaveBeenCalledTimes(1);
expect(getMessageShared).toHaveBeenCalledWith(
localeCodeMock,
localeMessagesMock,
keyMock,
undefined,
expect.any(Function),
undefined,
); );
}); });
it('should return the correct message when two non-react substitutions are made', () => { it('invokes getMessage from shared module with onError callback that logs Sentry exception', () => {
const result = t(TEST_KEY_3, [TEST_SUBSTITUTION_1, TEST_SUBSTITUTION_2]); getMessage(localeCodeMock, localeMessagesMock, keyMock);
expect(result).toStrictEqual(
`This is a message with two non-react substitutions ${TEST_SUBSTITUTION_1} and ${TEST_SUBSTITUTION_2}.`, const onErrorCallback = getMessageShared.mock.calls[0][4];
onErrorCallback(errorMock);
expect(Sentry.captureException).toHaveBeenCalledTimes(1);
expect(Sentry.captureException).toHaveBeenCalledWith(errorMock);
});
it('does not provide custom join logic if only strings in substitutions', () => {
getMessage(localeCodeMock, localeMessagesMock, keyMock, ['a1', 'a2']);
expect(getMessageShared).toHaveBeenCalledTimes(1);
expect(getMessageShared).toHaveBeenCalledWith(
localeCodeMock,
localeMessagesMock,
keyMock,
['a1', 'a2'],
expect.any(Function),
undefined,
); );
}); });
it('should return the correct message when multiple non-react substitutions are made', () => { it('renders substitutions inside span if substitutions include React components', () => {
const result = t(TEST_KEY_4, [ const substitution1 = (
TEST_SUBSTITUTION_1, <div style={{ color: 'orange' }} key="substitution-1">
TEST_SUBSTITUTION_2, a1
TEST_SUBSTITUTION_3, </div>
TEST_SUBSTITUTION_4,
TEST_SUBSTITUTION_5,
]);
expect(result).toStrictEqual(
`${TEST_SUBSTITUTION_1} - ${TEST_SUBSTITUTION_2} - ${TEST_SUBSTITUTION_3} - ${TEST_SUBSTITUTION_4} - ${TEST_SUBSTITUTION_5}`,
); );
});
it('should correctly render falsy substitutions', () => { const substitution2 = (
const result = t(TEST_KEY_4, [0, -0, '', false, NaN]); <div style={{ color: 'pink' }} key="substitution-2">
expect(result).toStrictEqual('0 - 0 - - false - NaN'); b2
}); </div>
);
it('should render nothing for "null" and "undefined" substitutions', () => { const substitution3 = 'c3';
const result = t(TEST_KEY_5, [null, TEST_SUBSTITUTION_2]);
expect(result).toStrictEqual(` - ${TEST_SUBSTITUTION_2} - `);
});
it('should return the correct message when a single react substitution is made', () => { getMessage(localeCodeMock, localeMessagesMock, keyMock, [
const result = t(TEST_KEY_6, [TEST_SUBSTITUTION_6]); substitution1,
substitution2,
const { container } = renderWithProvider(result); substitution3,
expect(container).toMatchSnapshot();
});
it('should return the correct message when two react substitutions are made', () => {
const result = t(TEST_KEY_7, [
TEST_SUBSTITUTION_7_1,
TEST_SUBSTITUTION_7_2,
]); ]);
const { container } = renderWithProvider(result); const joinCallback = getMessageShared.mock.calls[0][5];
expect(container).toMatchSnapshot(); const result = joinCallback([
}); substitution1,
substitution2,
it('should return the correct message when substituting a mix of react elements and strings', () => { substitution3,
const result = t(TEST_KEY_8, [
TEST_SUBSTITUTION_1,
TEST_SUBSTITUTION_8_1,
TEST_SUBSTITUTION_2,
TEST_SUBSTITUTION_8_2,
]); ]);
const { container } = renderWithProvider(result); const { container } = renderWithProvider(result);

View File

@ -1,46 +1,12 @@
// cross-browser connection to extension i18n API
import React from 'react'; import React from 'react';
import log from 'loglevel';
import { Json } from '@metamask/utils';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
import getFetchWithTimeout from '../../../shared/modules/fetch-with-timeout'; import {
I18NMessageDict,
I18NSubstitution,
getMessage as getMessageShared,
} from '../../../shared/modules/i18n';
import { NETWORK_TYPES } from '../../../shared/constants/network'; import { NETWORK_TYPES } from '../../../shared/constants/network';
const fetchWithTimeout = getFetchWithTimeout();
// From app/_locales folders there is a messages.json file such as app/_locales/en, comes with key and translated results
// and we use as t('reject') to get the translated message in the codebase
// and in i18n lib, the translated message is an object (I18NMessage) with message & description -
// message is the string that will replace the translationKey, and that message may contain replacement variables such as $1, $2, etc.
// Description is key describing the usage of the message.
interface I18NMessage {
message: string;
description?: string;
}
// The overall translation file is made of same entries
// translationKey (string) and the I18NMessage as the value.
interface I18NMessageDict {
[translationKey: string]: I18NMessage;
}
// A parameterized type (or generic type) of maps that use the same structure (translationKey) key
interface I18NMessageDictMap<R> {
[translationKey: string]: R;
}
const warned: { [localeCode: string]: I18NMessageDictMap<boolean> } = {};
const missingMessageErrors: I18NMessageDictMap<Error> = {};
const missingSubstitutionErrors: {
[localeCode: string]: I18NMessageDictMap<boolean>;
} = {};
function getHasSubstitutions(
substitutions?: string[],
): substitutions is string[] {
return (substitutions?.length ?? 0) > 0;
}
/** /**
* Returns a localized message for the given key * Returns a localized message for the given key
* *
@ -56,116 +22,29 @@ export const getMessage = (
key: string, key: string,
substitutions?: string[], substitutions?: string[],
): JSX.Element | string | null => { ): JSX.Element | string | null => {
if (!localeMessages) { const hasReactSubstitutions = substitutions?.some(
return null;
}
if (!localeMessages[key]) {
if (localeCode === 'en') {
if (!missingMessageErrors[key]) {
missingMessageErrors[key] = new Error(
`Unable to find value of key "${key}" for locale "${localeCode}"`,
);
Sentry.captureException(missingMessageErrors[key]);
log.error(missingMessageErrors[key]);
if (process.env.IN_TEST) {
throw missingMessageErrors[key];
}
}
} else if (!warned[localeCode] || !warned[localeCode][key]) {
if (!warned[localeCode]) {
warned[localeCode] = {};
}
warned[localeCode][key] = true;
log.warn(
`Translator - Unable to find value of key "${key}" for locale "${localeCode}"`,
);
}
return null;
}
const hasSubstitutions = getHasSubstitutions(substitutions);
const hasReactSubstitutions =
hasSubstitutions &&
substitutions?.some(
(element) => (element) =>
element !== null && element !== null &&
(typeof element === 'function' || typeof element === 'object'), (typeof element === 'function' || typeof element === 'object'),
); );
const entry = localeMessages[key];
const phrase = entry.message;
// perform substitutions
if (hasSubstitutions) {
const parts = phrase.split(/(\$\d)/gu);
const substitutedParts = parts.map((part: string) => { const join = hasReactSubstitutions
const subMatch = part.match(/\$(\d)/u); ? (parts: I18NSubstitution[]) => <span> {parts} </span>
if (!subMatch) { : undefined;
return part;
} const onError = (error: Error) => {
const substituteIndex = Number(subMatch[1]) - 1;
if (
(substitutions[substituteIndex] === null ||
substitutions[substituteIndex] === undefined) &&
!missingSubstitutionErrors[localeCode]?.[key]
) {
if (!missingSubstitutionErrors[localeCode]) {
missingSubstitutionErrors[localeCode] = {};
}
missingSubstitutionErrors[localeCode][key] = true;
const error = new Error(
`Insufficient number of substitutions for key "${key}" with locale "${localeCode}"`,
);
log.error(error);
Sentry.captureException(error); Sentry.captureException(error);
}
return substitutions?.[substituteIndex];
});
return hasReactSubstitutions ? (
<span> {substitutedParts} </span>
) : (
substitutedParts.join('')
);
}
return phrase;
}; };
export async function fetchLocale( return getMessageShared(
localeCode: string, localeCode,
): Promise<I18NMessageDict> { localeMessages,
try { key,
const response = await fetchWithTimeout( substitutions,
`./_locales/${localeCode}/messages.json`, onError,
join,
); );
return await response.json(); };
} catch (error) {
log.error(`failed to fetch ${localeCode} locale because of ${error}`);
return {};
}
}
const relativeTimeFormatLocaleData = new Set();
export async function loadRelativeTimeFormatLocaleData(
localeCode: string,
): Promise<void> {
const languageTag = localeCode.split('_')[0];
if (
Intl.RelativeTimeFormat &&
typeof (Intl.RelativeTimeFormat as any).__addLocaleData === 'function' &&
!relativeTimeFormatLocaleData.has(languageTag)
) {
const localeData = await fetchRelativeTimeFormatData(languageTag);
(Intl.RelativeTimeFormat as any).__addLocaleData(localeData);
}
}
async function fetchRelativeTimeFormatData(languageTag: string): Promise<Json> {
const response = await fetchWithTimeout(
`./intl/${languageTag}/relative-time-format-data.json`,
);
return await response.json();
}
export function getNetworkLabelKey(network: string): string { export function getNetworkLabelKey(network: string): string {
if (network === NETWORK_TYPES.LINEA_GOERLI) { if (network === NETWORK_TYPES.LINEA_GOERLI) {

View File

@ -15,7 +15,6 @@ import {
getNativeCurrency, getNativeCurrency,
getNfts, getNfts,
} from '../ducks/metamask/metamask'; } from '../ducks/metamask/metamask';
import { getMessage } from '../helpers/utils/i18n-helper';
import messages from '../../app/_locales/en/messages.json'; import messages from '../../app/_locales/en/messages.json';
import { ASSET_ROUTE, DEFAULT_ROUTE } from '../helpers/constants/routes'; import { ASSET_ROUTE, DEFAULT_ROUTE } from '../helpers/constants/routes';
import { CHAIN_IDS } from '../../shared/constants/network'; import { CHAIN_IDS } from '../../shared/constants/network';
@ -25,6 +24,7 @@ import {
TransactionStatus, TransactionStatus,
} from '../../shared/constants/transaction'; } from '../../shared/constants/transaction';
import { formatDateWithYearContext } from '../helpers/utils/util'; import { formatDateWithYearContext } from '../helpers/utils/util';
import { getMessage } from '../helpers/utils/i18n-helper';
import * as i18nhooks from './useI18nContext'; import * as i18nhooks from './useI18nContext';
import * as useTokenFiatAmountHooks from './useTokenFiatAmount'; import * as useTokenFiatAmountHooks from './useTokenFiatAmount';
import { useTransactionDisplayData } from './useTransactionDisplayData'; import { useTransactionDisplayData } from './useTransactionDisplayData';

View File

@ -29,7 +29,7 @@ const esMessages = {
}, },
}; };
jest.mock('./helpers/utils/i18n-helper', () => ({ jest.mock('../shared/modules/i18n', () => ({
fetchLocale: jest.fn((locale) => (locale === 'en' ? enMessages : esMessages)), fetchLocale: jest.fn((locale) => (locale === 'en' ? enMessages : esMessages)),
loadRelativeTimeFormatLocaleData: jest.fn(), loadRelativeTimeFormatLocaleData: jest.fn(),
})); }));

View File

@ -72,7 +72,7 @@ import { NOTIFICATIONS_EXPIRATION_DELAY } from '../helpers/constants/notificatio
import { import {
fetchLocale, fetchLocale,
loadRelativeTimeFormatLocaleData, loadRelativeTimeFormatLocaleData,
} from '../helpers/utils/i18n-helper'; } from '../../shared/modules/i18n';
import { decimalToHex } from '../../shared/modules/conversion.utils'; import { decimalToHex } from '../../shared/modules/conversion.utils';
import { TxGasFees, PriorityLevels } from '../../shared/constants/gas'; import { TxGasFees, PriorityLevels } from '../../shared/constants/gas';
import { import {