mirror of
https://github.com/kremalicious/metamask-extension.git
synced 2024-11-21 09:27:02 +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 PropTypes from 'prop-types';
|
||||
import { getMessage } from '../ui/helpers/utils/i18n-helper';
|
||||
import { getMessage } from '../shared/modules/i18n';
|
||||
import { I18nContext } from '../ui/contexts/i18n';
|
||||
|
||||
export { I18nContext };
|
||||
|
24
app/_locales/en/messages.json
generated
24
app/_locales/en/messages.json
generated
@ -2559,6 +2559,30 @@
|
||||
"notePlaceholder": {
|
||||
"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": {
|
||||
"message": "Notifications"
|
||||
},
|
||||
|
@ -205,6 +205,7 @@ import {
|
||||
} from './controllers/permissions';
|
||||
import createRPCMethodTrackingMiddleware from './lib/createRPCMethodTrackingMiddleware';
|
||||
import { securityProviderCheck } from './lib/security-provider-helpers';
|
||||
import { updateCurrentLocale } from './translate';
|
||||
|
||||
export const METAMASK_CONTROLLER_EVENTS = {
|
||||
// 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
|
||||
});
|
||||
|
||||
this.preferencesController.store.subscribe(async ({ currentLocale }) => {
|
||||
await updateCurrentLocale(currentLocale);
|
||||
});
|
||||
|
||||
this.tokensController = new TokensController({
|
||||
chainId: this.networkController.store.getState().providerConfig.chainId,
|
||||
onPreferencesStateChange: this.preferencesController.store.subscribe.bind(
|
||||
|
@ -6,6 +6,7 @@ import { getEnvironmentType } from '../lib/util';
|
||||
import { ENVIRONMENT_TYPE_BACKGROUND } from '../../../shared/constants/app';
|
||||
import { TransactionStatus } from '../../../shared/constants/transaction';
|
||||
import { getURLHostName } from '../../../ui/helpers/utils/util';
|
||||
import { t } from '../translate';
|
||||
|
||||
export default class ExtensionPlatform {
|
||||
//
|
||||
@ -181,22 +182,30 @@ export default class ExtensionPlatform {
|
||||
toLower(getURLHostName(url).replace(/([.]\w+)$/u, '')),
|
||||
);
|
||||
|
||||
const title = 'Confirmed transaction';
|
||||
const message = `Transaction ${nonce} confirmed! ${
|
||||
url.length ? `View on ${view}` : ''
|
||||
}`;
|
||||
const title = t('notificationTransactionSuccessTitle');
|
||||
let message = t('notificationTransactionSuccessMessage', nonce);
|
||||
|
||||
if (url.length) {
|
||||
message += ` ${t('notificationTransactionSuccessView', view)}`;
|
||||
}
|
||||
|
||||
await this._showNotification(title, message, url);
|
||||
}
|
||||
|
||||
async _showFailedTransaction(txMeta, errorMessage) {
|
||||
const nonce = parseInt(txMeta.txParams.nonce, 16);
|
||||
const title = 'Failed transaction';
|
||||
let message = `Transaction ${nonce} failed! ${
|
||||
errorMessage || txMeta.err.message
|
||||
}`;
|
||||
const title = t('notificationTransactionFailedTitle');
|
||||
let message = t(
|
||||
'notificationTransactionFailedMessage',
|
||||
nonce,
|
||||
errorMessage || txMeta.err.message,
|
||||
);
|
||||
///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
|
||||
if (isNaN(nonce)) {
|
||||
message = `Transaction failed! ${errorMessage || txMeta.err.message}`;
|
||||
message = t(
|
||||
'notificationTransactionFailedMessageMMI',
|
||||
errorMessage || txMeta.err.message,
|
||||
);
|
||||
}
|
||||
///: END:ONLY_INCLUDE_IN
|
||||
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',
|
||||
'app/scripts/constants/**/*.js',
|
||||
'app/scripts/constants/**/*.ts',
|
||||
'app/scripts/platforms/**/*.js',
|
||||
],
|
||||
{
|
||||
ignore: [...globsToStrictSearch, testGlob],
|
||||
|
@ -51,6 +51,7 @@ module.exports = {
|
||||
'<rootDir>/app/scripts/lib/createRPCMethodTrackingMiddleware.test.js',
|
||||
'<rootDir>/app/scripts/migrations/*.test.(js|ts)',
|
||||
'<rootDir>/app/scripts/platforms/*.test.js',
|
||||
'<rootDir>/app/scripts/translate.test.ts',
|
||||
'<rootDir>/shared/**/*.test.(js|ts)',
|
||||
'<rootDir>/ui/**/*.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
|
||||
import { memoize } from 'lodash';
|
||||
import getFirstPreferredLangCode from '../../app/scripts/lib/get-first-preferred-lang-code';
|
||||
import {
|
||||
fetchLocale,
|
||||
loadRelativeTimeFormatLocaleData,
|
||||
} from '../../ui/helpers/utils/i18n-helper';
|
||||
import { fetchLocale, loadRelativeTimeFormatLocaleData } from '../modules/i18n';
|
||||
///: BEGIN:ONLY_INCLUDE_IN(desktop)
|
||||
import { renderDesktopError } from '../../ui/pages/desktop-error/render-desktop-error';
|
||||
import { EXTENSION_ERROR_PAGE_TYPES } from '../constants/desktop';
|
||||
|
@ -1,5 +1,5 @@
|
||||
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 {
|
||||
downloadDesktopApp,
|
||||
@ -12,7 +12,7 @@ import {
|
||||
} from './error-utils';
|
||||
import { openCustomProtocol } from './deep-linking';
|
||||
|
||||
jest.mock('../../ui/helpers/utils/i18n-helper', () => ({
|
||||
jest.mock('../modules/i18n', () => ({
|
||||
fetchLocale: 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",
|
||||
"rootDir": ".",
|
||||
"sourceMap": true,
|
||||
"strict": true
|
||||
"strict": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"exclude": [
|
||||
"**/jest-coverage/**/*",
|
||||
|
@ -1,12 +1,12 @@
|
||||
import React, { Component, createContext, useMemo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { getMessage } from '../helpers/utils/i18n-helper';
|
||||
import {
|
||||
getCurrentLocale,
|
||||
getCurrentLocaleMessages,
|
||||
getEnLocaleMessages,
|
||||
} from '../ducks/locale/locale';
|
||||
import { getMessage } from '../helpers/utils/i18n-helper';
|
||||
|
||||
export const I18nContext = createContext((key) => `[${key}]`);
|
||||
|
||||
|
@ -1,64 +1,20 @@
|
||||
// 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>
|
||||
<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
|
||||
style="color: orange;"
|
||||
>
|
||||
TEST_SUBSTITUTION_3
|
||||
a1
|
||||
</div>
|
||||
and string substitutions
|
||||
TEST_SUBSTITUTION_2
|
||||
+
|
||||
<div
|
||||
style="color: pink;"
|
||||
>
|
||||
TEST_SUBSTITUTION_4
|
||||
b2
|
||||
</div>
|
||||
.
|
||||
|
||||
</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>
|
||||
.
|
||||
c3
|
||||
|
||||
</span>
|
||||
</div>
|
||||
|
@ -1,169 +1,93 @@
|
||||
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 { getMessage } from './i18n-helper';
|
||||
|
||||
describe('i18n helper', () => {
|
||||
const TEST_LOCALE_CODE = 'TEST_LOCALE_CODE';
|
||||
jest.mock('../../../shared/modules/i18n');
|
||||
jest.mock('@sentry/browser');
|
||||
|
||||
const TEST_KEY_1 = 'TEST_KEY_1';
|
||||
const TEST_KEY_2 = 'TEST_KEY_2';
|
||||
const TEST_KEY_3 = 'TEST_KEY_3';
|
||||
const TEST_KEY_4 = 'TEST_KEY_4';
|
||||
const TEST_KEY_5 = 'TEST_KEY_5';
|
||||
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 localeCodeMock = 'te';
|
||||
const keyMock = 'testKey';
|
||||
const localeMessagesMock = { [keyMock]: { message: 'testMessage' } };
|
||||
const errorMock = new Error('testError');
|
||||
const messageMock = 'testMessage';
|
||||
|
||||
const TEST_SUBSTITUTION_1 = 'TEST_SUBSTITUTION_1';
|
||||
const TEST_SUBSTITUTION_2 = 'TEST_SUBSTITUTION_2';
|
||||
const TEST_SUBSTITUTION_3 = 'TEST_SUBSTITUTION_3';
|
||||
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('I18N Helper', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('getMessage', () => {
|
||||
it('should return the exact message paired with key if there are no substitutions', () => {
|
||||
const result = t(TEST_KEY_1);
|
||||
expect(result).toStrictEqual('This is a simple message.');
|
||||
});
|
||||
it('returns value from getMessage in shared module', () => {
|
||||
getMessageShared.mockReturnValue(messageMock);
|
||||
|
||||
it('should return the correct message when a single non-react substitution is made', () => {
|
||||
const result = t(TEST_KEY_2, [TEST_SUBSTITUTION_1]);
|
||||
expect(result).toStrictEqual(
|
||||
`This is a message with a single non-react substitution ${TEST_SUBSTITUTION_1}.`,
|
||||
expect(
|
||||
getMessage(localeCodeMock, localeMessagesMock, keyMock),
|
||||
).toStrictEqual(messageMock);
|
||||
|
||||
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', () => {
|
||||
const result = t(TEST_KEY_3, [TEST_SUBSTITUTION_1, TEST_SUBSTITUTION_2]);
|
||||
expect(result).toStrictEqual(
|
||||
`This is a message with two non-react substitutions ${TEST_SUBSTITUTION_1} and ${TEST_SUBSTITUTION_2}.`,
|
||||
it('invokes getMessage from shared module with onError callback that logs Sentry exception', () => {
|
||||
getMessage(localeCodeMock, localeMessagesMock, keyMock);
|
||||
|
||||
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', () => {
|
||||
const result = t(TEST_KEY_4, [
|
||||
TEST_SUBSTITUTION_1,
|
||||
TEST_SUBSTITUTION_2,
|
||||
TEST_SUBSTITUTION_3,
|
||||
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('renders substitutions inside span if substitutions include React components', () => {
|
||||
const substitution1 = (
|
||||
<div style={{ color: 'orange' }} key="substitution-1">
|
||||
a1
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
it('should correctly render falsy substitutions', () => {
|
||||
const result = t(TEST_KEY_4, [0, -0, '', false, NaN]);
|
||||
expect(result).toStrictEqual('0 - 0 - - false - NaN');
|
||||
});
|
||||
const substitution2 = (
|
||||
<div style={{ color: 'pink' }} key="substitution-2">
|
||||
b2
|
||||
</div>
|
||||
);
|
||||
|
||||
it('should render nothing for "null" and "undefined" substitutions', () => {
|
||||
const result = t(TEST_KEY_5, [null, TEST_SUBSTITUTION_2]);
|
||||
expect(result).toStrictEqual(` - ${TEST_SUBSTITUTION_2} - `);
|
||||
});
|
||||
const substitution3 = 'c3';
|
||||
|
||||
it('should return the correct message when a single react substitution is made', () => {
|
||||
const result = t(TEST_KEY_6, [TEST_SUBSTITUTION_6]);
|
||||
|
||||
const { container } = renderWithProvider(result);
|
||||
|
||||
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,
|
||||
getMessage(localeCodeMock, localeMessagesMock, keyMock, [
|
||||
substitution1,
|
||||
substitution2,
|
||||
substitution3,
|
||||
]);
|
||||
|
||||
const { container } = renderWithProvider(result);
|
||||
const joinCallback = getMessageShared.mock.calls[0][5];
|
||||
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should return the correct message when substituting a mix of react elements and strings', () => {
|
||||
const result = t(TEST_KEY_8, [
|
||||
TEST_SUBSTITUTION_1,
|
||||
TEST_SUBSTITUTION_8_1,
|
||||
TEST_SUBSTITUTION_2,
|
||||
TEST_SUBSTITUTION_8_2,
|
||||
const result = joinCallback([
|
||||
substitution1,
|
||||
substitution2,
|
||||
substitution3,
|
||||
]);
|
||||
|
||||
const { container } = renderWithProvider(result);
|
||||
|
@ -1,46 +1,12 @@
|
||||
// cross-browser connection to extension i18n API
|
||||
import React from 'react';
|
||||
import log from 'loglevel';
|
||||
import { Json } from '@metamask/utils';
|
||||
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';
|
||||
|
||||
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
|
||||
*
|
||||
@ -56,116 +22,29 @@ export const getMessage = (
|
||||
key: string,
|
||||
substitutions?: string[],
|
||||
): JSX.Element | string | null => {
|
||||
if (!localeMessages) {
|
||||
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 !== 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`,
|
||||
const hasReactSubstitutions = substitutions?.some(
|
||||
(element) =>
|
||||
element !== null &&
|
||||
(typeof element === 'function' || typeof element === 'object'),
|
||||
);
|
||||
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 {
|
||||
if (network === NETWORK_TYPES.LINEA_GOERLI) {
|
||||
|
@ -15,7 +15,6 @@ import {
|
||||
getNativeCurrency,
|
||||
getNfts,
|
||||
} from '../ducks/metamask/metamask';
|
||||
import { getMessage } from '../helpers/utils/i18n-helper';
|
||||
import messages from '../../app/_locales/en/messages.json';
|
||||
import { ASSET_ROUTE, DEFAULT_ROUTE } from '../helpers/constants/routes';
|
||||
import { CHAIN_IDS } from '../../shared/constants/network';
|
||||
@ -25,6 +24,7 @@ import {
|
||||
TransactionStatus,
|
||||
} from '../../shared/constants/transaction';
|
||||
import { formatDateWithYearContext } from '../helpers/utils/util';
|
||||
import { getMessage } from '../helpers/utils/i18n-helper';
|
||||
import * as i18nhooks from './useI18nContext';
|
||||
import * as useTokenFiatAmountHooks from './useTokenFiatAmount';
|
||||
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)),
|
||||
loadRelativeTimeFormatLocaleData: jest.fn(),
|
||||
}));
|
||||
|
@ -72,7 +72,7 @@ import { NOTIFICATIONS_EXPIRATION_DELAY } from '../helpers/constants/notificatio
|
||||
import {
|
||||
fetchLocale,
|
||||
loadRelativeTimeFormatLocaleData,
|
||||
} from '../helpers/utils/i18n-helper';
|
||||
} from '../../shared/modules/i18n';
|
||||
import { decimalToHex } from '../../shared/modules/conversion.utils';
|
||||
import { TxGasFees, PriorityLevels } from '../../shared/constants/gas';
|
||||
import {
|
||||
|
Loading…
Reference in New Issue
Block a user