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:
parent
3bbfe87e9e
commit
b247f272ba
@ -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 };
|
||||||
|
24
app/_locales/en/messages.json
generated
24
app/_locales/en/messages.json
generated
@ -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"
|
||||||
},
|
},
|
||||||
|
@ -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(
|
||||||
|
@ -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);
|
||||||
|
125
app/scripts/translate.test.ts
Normal file
125
app/scripts/translate.test.ts
Normal 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
31
app/scripts/translate.ts
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
@ -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],
|
||||||
|
@ -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)',
|
||||||
|
@ -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';
|
||||||
|
@ -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
335
shared/modules/i18n.test.ts
Normal 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
223
shared/modules/i18n.ts
Normal 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();
|
||||||
|
}
|
@ -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/**/*",
|
||||||
|
@ -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}]`);
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
(element) =>
|
||||||
}
|
element !== null &&
|
||||||
if (!localeMessages[key]) {
|
(typeof element === 'function' || typeof element === 'object'),
|
||||||
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 !== null &&
|
|
||||||
(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 subMatch = part.match(/\$(\d)/u);
|
|
||||||
if (!subMatch) {
|
|
||||||
return part;
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
return substitutions?.[substituteIndex];
|
|
||||||
});
|
|
||||||
|
|
||||||
return hasReactSubstitutions ? (
|
|
||||||
<span> {substitutedParts} </span>
|
|
||||||
) : (
|
|
||||||
substitutedParts.join('')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return phrase;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
const join = hasReactSubstitutions
|
||||||
|
? (parts: I18NSubstitution[]) => <span> {parts} </span>
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const onError = (error: Error) => {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
return getMessageShared(
|
||||||
|
localeCode,
|
||||||
|
localeMessages,
|
||||||
|
key,
|
||||||
|
substitutions,
|
||||||
|
onError,
|
||||||
|
join,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export function getNetworkLabelKey(network: string): string {
|
export function getNetworkLabelKey(network: string): string {
|
||||||
if (network === NETWORK_TYPES.LINEA_GOERLI) {
|
if (network === NETWORK_TYPES.LINEA_GOERLI) {
|
||||||
|
@ -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';
|
||||||
|
@ -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(),
|
||||||
}));
|
}));
|
||||||
|
@ -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 {
|
||||||
|
Loading…
Reference in New Issue
Block a user