support icu messageformat for translations

This commit is contained in:
Jamie Kyle 2022-10-03 14:19:54 -07:00 committed by GitHub
parent b5c514e1d1
commit 6d56f8b8aa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 839 additions and 104 deletions

View file

@ -1,4 +1,3 @@
build/**
components/**
coverage/**
dist/**

View file

@ -191,6 +191,7 @@ module.exports = {
'app/**/*.ts',
'sticker-creator/**/*.ts',
'sticker-creator/**/*.tsx',
'build/intl-linter/**/*.ts',
],
parser: '@typescript-eslint/parser',
parserOptions: {
@ -211,7 +212,12 @@ module.exports = {
rules: typescriptRules,
},
{
files: ['**/*.stories.tsx', 'ts/build/**', 'ts/test-*/**'],
files: [
'**/*.stories.tsx',
'ts/build/**',
'ts/test-*/**',
'build/intl-linter/**/*.ts',
],
rules: {
...typescriptRules,
'import/no-extraneous-dependencies': 'off',

View file

@ -5,6 +5,10 @@
Signal Desktop makes use of the following open source projects.
## @formatjs/fast-memoize
License: MIT
## @indutny/frameless-titlebar
MIT License
@ -2863,6 +2867,10 @@ Signal Desktop makes use of the following open source projects.
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## react-intl
License: BSD-3-Clause
## react-measure
The MIT License (MIT)

View file

@ -1,7 +1,13 @@
{
"smartling": {
"placeholder_format_custom": "(\\$.+?\\$)",
"string_format_paths": "icu: [*/messageformat]",
"translate_paths": [
{
"path": "*/messageformat",
"key": "{*}/messageformat",
"instruction": "*/description"
},
{
"key": "{*}/message",
"path": "*/message",
@ -733,8 +739,8 @@
"message": "SMS/MMS contacts are not available on Desktop.",
"description": "Shown in the search left pane when no results were found and primary device has SMS/MMS handling enabled"
},
"noSearchResultsInConversation": {
"message": "No results for \"$searchTerm$\" in $conversationName$",
"icu:noSearchResultsInConversation": {
"messageformat": "No results for \"{searchTerm}\" in {conversationName}",
"description": "Shown in the search left pane when no results were found"
},
"conversationsHeader": {
@ -1212,12 +1218,8 @@
"message": "Tap $plusButton$ (Android) or $linkNewDevice$ (iPhone)",
"description": "Instructions on the device link screen"
},
"Install__qr-failed": {
"message": "The QR code couldn't load. Check your internet and try again. $learnMore$",
"description": "Shown on the install screen if the QR code fails to load"
},
"Install__qr-failed__learn-more": {
"message": "Learn more",
"icu:Install__qr-failed": {
"messageformat": "The QR code couldn't load. Check your internet and try again. <learnMoreLink>Learn more</learnMoreLink>",
"description": "Shown on the install screen if the QR code fails to load"
},
"Install__support-link": {
@ -1777,8 +1779,8 @@
"message": "off",
"description": "Label for option to turn off message expiration in the timer menu"
},
"disappearingMessages": {
"message": "Disappearing messages",
"icu:disappearingMessages": {
"messageformat": "Disappearing messages",
"description": "Conversation menu option to enable disappearing messages. Title of the settings section for Disappearing Messages. Label of the disappearing timer select in group creation flow"
},
"disappearingMessagesDisabled": {
@ -2741,8 +2743,8 @@
"message": "Back",
"description": "Default text for the previous button on all stages of the sticker creator"
},
"StickerCreator--DropStage--title": {
"message": "Add your stickers",
"icu:StickerCreator--DropStage--title": {
"messageformat": "Add your stickers",
"description": "Title for the drop stage of the sticker creator"
},
"StickerCreator--DropStage--removeSticker": {
@ -2761,8 +2763,8 @@
"message": "Show margins",
"description": "Text for the show margins toggle on the drop stage of the sticker creator"
},
"StickerCreator--DropStage--addMore": {
"message": "Add $count$ or more",
"icu:StickerCreator--DropStage--addMore": {
"messageformat": "Add {count, number} or more",
"description": "Text to show user how many more stickers they must add"
},
"StickerCreator--EmojiStage--title": {
@ -2841,8 +2843,8 @@
"message": "Check out this new sticker pack I created for Signal. #makeprivacystick",
"description": "Text which is shared to social media platforms for sticker packs"
},
"StickerCreator--Toasts--imagesAdded": {
"message": "$count$ image(s) added",
"icu:StickerCreator--Toasts--imagesAdded": {
"messageformat": "{count, plural, one {1 image} other {# images}} added",
"description": "Text for the toast when images are added to the sticker creator"
},
"StickerCreator--Toasts--animated": {
@ -4569,8 +4571,8 @@
"message": ".5",
"description": "Button in the voice note message widget that shows the current playback rate of .5x (half speed) and allows the user to toggle to the next rate. Don't include the 'x'."
},
"emptyInboxMessage": {
"message": "Click the $composeIcon$ above and search for your contacts or groups to message.",
"icu:emptyInboxMessage": {
"messageformat": "Click the {composeIcon} above and search for your contacts or groups to message.",
"description": "Shown in the left-pane when the inbox is empty"
},
"composeIcon": {
@ -4985,8 +4987,8 @@
"message": "Would you like to discard these changes?",
"description": "ConfirmationDialog text for discarding changes"
},
"ProfileEditor--info": {
"message": "Your profile is encrypted. Your profile and changes to it will be visible to your contacts and when you start or accept new chats. $learnMore$",
"icu:ProfileEditor--info": {
"messageformat": "Your profile is encrypted. Your profile and changes to it will be visible to your contacts and when you start or accept new chats. {learnMore}",
"description": "Information shown at the bottom of the profile editor section"
},
"ProfileEditor--learnMore": {

177
build/intl-linter/linter.ts Normal file
View file

@ -0,0 +1,177 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { parse as parseIcuMessage } from '@formatjs/icu-messageformat-parser';
import type {
MessageFormatElement,
Location,
} from '@formatjs/icu-messageformat-parser';
import parseJsonToAst from 'json-to-ast';
import { readFile } from 'fs/promises';
import { join as pathJoin, relative as pathRelative } from 'path';
import chalk from 'chalk';
import { deepEqual } from 'assert';
import type { Rule } from './utils/rule';
import icuPrefix from './rules/icuPrefix';
import onePlural from './rules/onePlural';
import noLegacyVariables from './rules/noLegacyVariables';
import noNestedChoice from './rules/noNestedChoice';
import noOffset from './rules/noOffset';
import noOrdinal from './rules/noOrdinal';
const RULES = [
icuPrefix,
noLegacyVariables,
noNestedChoice,
noOffset,
noOrdinal,
onePlural,
];
type Test = {
messageformat: string;
expectErrors: Array<string>;
};
const tests: Record<string, Test> = {
err1: {
messageformat: '{a, plural, other {a}} {b, plural, other {b}}',
expectErrors: ['onePlural'],
},
err2: {
messageformat: '{a, plural, other {{b, plural, other {b}}}}',
expectErrors: ['noNestedChoice', 'onePlural'],
},
err3: {
messageformat: '{a, select, other {{b, select, other {b}}}}',
expectErrors: ['noNestedChoice'],
},
err4: {
messageformat: '{a, plural, offset:1 other {a}}',
expectErrors: ['noOffset'],
},
err5: {
messageformat: '{a, selectordinal, other {a}}',
expectErrors: ['noOrdinal'],
},
err6: {
messageformat: '$a$',
expectErrors: ['noLegacyVariables'],
},
};
type Report = {
id: string;
message: string;
location: Location | void;
};
function lintMessage(
messageId: string,
elements: Array<MessageFormatElement>,
rules: Array<Rule>
) {
const reports: Array<Report> = [];
for (const rule of rules) {
rule.run(elements, {
messageId,
report(message, location) {
reports.push({ id: rule.id, message, location });
},
});
}
return reports;
}
function assert(condition: unknown, message: string): asserts condition {
if (!condition) {
throw new Error(message);
}
}
async function lintMessages() {
const repoRoot = pathJoin(__dirname, '..', '..');
const filePath = pathJoin(repoRoot, '_locales/en/messages.json');
const relativePath = pathRelative(repoRoot, filePath);
let file = await readFile(filePath, 'utf-8');
if (process.argv.includes('--test')) {
file = JSON.stringify(tests);
}
const jsonAst = parseJsonToAst(file);
assert(jsonAst.type === 'Object', 'Expected an object');
for (const topProp of jsonAst.children) {
if (topProp.key.value === 'smartling') {
continue;
}
const messageId = topProp.key.value;
assert(topProp.value.type === 'Object', 'Expected an object');
const icuMessageProp = topProp.value.children.find(messageProp => {
return messageProp.key.value === 'messageformat';
});
if (icuMessageProp == null) {
continue;
}
const icuMesssageLiteral = icuMessageProp.value;
assert(
icuMesssageLiteral.type === 'Literal' &&
typeof icuMesssageLiteral.value === 'string',
'Expected a string'
);
const icuMessage: string = icuMesssageLiteral.value;
const ast = parseIcuMessage(icuMessage, {
captureLocation: true,
shouldParseSkeletons: true,
requiresOtherClause: true,
});
const reports = lintMessage(messageId, ast, RULES);
const key = topProp.key.value;
if (process.argv.includes('--test')) {
const test = tests[key];
const actualErrors = reports.map(report => report.id);
deepEqual(actualErrors, test.expectErrors);
continue;
}
for (const report of reports) {
let loc = '';
if (report.location != null && icuMesssageLiteral.loc != null) {
const line =
icuMesssageLiteral.loc.start.line + (report.location.start.line - 1);
const column =
icuMesssageLiteral.loc.start.column + report.location.start.column;
loc = `:${line}:${column}`;
} else if (icuMesssageLiteral.loc != null) {
const { line, column } = icuMesssageLiteral.loc.start;
loc = `:${line}:${column}`;
}
// eslint-disable-next-line no-console
console.error(
chalk`{bold.cyan ${relativePath}${loc}} ${report.message} {magenta ({underline ${report.id}})}`
);
// eslint-disable-next-line no-console
console.error(chalk` {dim in ${key} is "}{red ${icuMessage}}{dim "}`);
// eslint-disable-next-line no-console
console.error();
}
}
}
lintMessages().catch(error => {
// eslint-disable-next-line no-console
console.error(error);
process.exit(1);
});

View file

@ -0,0 +1,11 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { rule } from '../utils/rule';
export default rule('icuPrefix', context => {
if (!context.messageId.startsWith('icu:')) {
context.report('ICU message IDs must start with "icu:"');
}
return {};
});

View file

@ -0,0 +1,17 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { rule } from '../utils/rule';
export default rule('noLegacyVariables', context => {
return {
enterLiteral(element) {
if (/(\$.+?\$)/.test(element.value)) {
context.report(
'String must not contain legacy $variables$',
element.location
);
}
},
};
});

View file

@ -0,0 +1,35 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Element } from '../utils/rule';
import { rule } from '../utils/rule';
export default rule('noNestedChoice', context => {
let insideChoice = false;
function check(element: Element) {
if (insideChoice) {
context.report(
'Nested {select}/{plural} is not supported by Smartling',
element.location
);
}
}
return {
enterSelect(element) {
check(element);
insideChoice = true;
},
exitSelect() {
insideChoice = false;
},
enterPlural(element) {
check(element);
insideChoice = true;
},
exitPlural() {
insideChoice = false;
},
};
});

View file

@ -0,0 +1,17 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { rule } from '../utils/rule';
export default rule('noOffset', context => {
return {
enterPlural(element) {
if (element.offset !== 0) {
context.report(
'{plural} with offset is not supported by Smartling',
element.location
);
}
},
};
});

View file

@ -0,0 +1,17 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { rule } from '../utils/rule';
export default rule('noOrdinal', context => {
return {
enterPlural(element) {
if (element.pluralType === 'ordinal') {
context.report(
'{selectordinal} is not supported by Smartling',
element.location
);
}
},
};
});

View file

@ -0,0 +1,19 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { rule } from '../utils/rule';
export default rule('onePlural', context => {
let plurals = 0;
return {
enterPlural(element) {
plurals += 1;
if (plurals > 1) {
context.report(
'Multiple {plural} is not supported by Smartling',
element.location
);
}
},
};
});

View file

@ -0,0 +1,33 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { MessageFormatElement } from '@formatjs/icu-messageformat-parser';
import { Location } from '@formatjs/icu-messageformat-parser';
import type { Visitor } from './traverse';
import { traverse } from './traverse';
export type Element = MessageFormatElement;
export { Location };
export type Context = {
messageId: string;
report(message: string, location: Location | void): void;
};
export type RuleFactory = {
(context: Context): Visitor;
};
export type Rule = {
id: string;
run(elements: Array<MessageFormatElement>, context: Context): void;
};
export function rule(id: string, ruleFactory: RuleFactory): Rule {
return {
id,
run(elements, context) {
traverse(elements, ruleFactory(context));
},
};
}

View file

@ -0,0 +1,90 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type {
MessageFormatElement,
LiteralElement,
ArgumentElement,
NumberElement,
DateElement,
TimeElement,
SelectElement,
PluralElement,
PoundElement,
TagElement,
} from '@formatjs/icu-messageformat-parser';
import { TYPE } from '@formatjs/icu-messageformat-parser';
export type VisitorMethod<T extends MessageFormatElement> = (
element: T
) => void;
export type Visitor = {
enterLiteral?: VisitorMethod<LiteralElement>;
exitLiteral?: VisitorMethod<LiteralElement>;
enterArgument?: VisitorMethod<ArgumentElement>;
exitArgument?: VisitorMethod<ArgumentElement>;
enterNumber?: VisitorMethod<NumberElement>;
exitNumber?: VisitorMethod<NumberElement>;
enterDate?: VisitorMethod<DateElement>;
exitDate?: VisitorMethod<DateElement>;
enterTime?: VisitorMethod<TimeElement>;
exitTime?: VisitorMethod<TimeElement>;
enterSelect?: VisitorMethod<SelectElement>;
exitSelect?: VisitorMethod<SelectElement>;
enterPlural?: VisitorMethod<PluralElement>;
exitPlural?: VisitorMethod<PluralElement>;
enterPound?: VisitorMethod<PoundElement>;
exitPound?: VisitorMethod<PoundElement>;
enterTag?: VisitorMethod<TagElement>;
exitTag?: VisitorMethod<TagElement>;
};
export function traverse(
elements: Array<MessageFormatElement>,
visitor: Visitor
): void {
for (const element of elements) {
if (element.type === TYPE.literal) {
visitor.enterLiteral?.(element);
visitor.exitLiteral?.(element);
} else if (element.type === TYPE.argument) {
visitor.enterArgument?.(element);
visitor.exitArgument?.(element);
} else if (element.type === TYPE.number) {
visitor.enterNumber?.(element);
visitor.exitNumber?.(element);
} else if (element.type === TYPE.date) {
visitor.enterDate?.(element);
visitor.exitDate?.(element);
} else if (element.type === TYPE.time) {
visitor.enterTime?.(element);
visitor.exitTime?.(element);
} else if (element.type === TYPE.select) {
visitor.enterSelect?.(element);
for (const node of Object.values(element.options)) {
traverse(node.value, visitor);
}
visitor.exitSelect?.(element);
} else if (element.type === TYPE.plural) {
visitor.enterPlural?.(element);
for (const node of Object.values(element.options)) {
traverse(node.value, visitor);
}
visitor.exitPlural?.(element);
} else if (element.type === TYPE.pound) {
visitor.enterPound?.(element);
visitor.exitPound?.(element);
} else if (element.type === TYPE.tag) {
visitor.enterTag?.(element);
traverse(element.children, visitor);
visitor.exitTag?.(element);
} else {
unreachable(element);
}
}
}
function unreachable(x: never): never {
throw new Error(`unreachable: ${x}`);
}

View file

@ -34,17 +34,19 @@
"prepare-staging-build": "node scripts/prepare_staging_build.js",
"prepare-windows-cert": "node scripts/prepare_windows_cert.js",
"publish-to-apt": "NAME=$npm_package_name VERSION=$npm_package_version ./aptly.sh",
"test": "yarn test-node && yarn test-electron",
"test": "yarn test-node && yarn test-electron && yarn test-lint-intl",
"test-electron": "node ts/scripts/test-electron.js",
"test-release": "node ts/scripts/test-release.js",
"test-node": "electron-mocha --timeout 10000 --file test/setup-test-node.js --recursive test/modules ts/test-node ts/test-both",
"test-mock": "mocha ts/test-mock/**/*_test.js",
"test-node-coverage": "nyc --reporter=lcov --reporter=text mocha --recursive test/modules ts/test-node ts/test-both",
"test-lint-intl": "ts-node ./build/intl-linter/linter.ts --test",
"eslint": "eslint --cache .",
"lint": "run-s --print-label lint-prettier check:types eslint",
"lint-deps": "node ts/util/lint/linter.js",
"lint-license-comments": "ts-node ts/util/lint/license_comments.ts",
"lint-prettier": "pprettier --check '**/*.{ts,tsx,d.ts,js,json,html,scss,md,yml,yaml}' '!node_modules/**'",
"lint-intl": "ts-node ./build/intl-linter/linter.ts",
"danger:local": "./danger/danger.sh local --base main",
"danger:ci": "./danger/danger.sh ci --base origin/main",
"format": "pprettier --write '**/*.{ts,tsx,d.ts,js,json,html,scss,md,yml,yaml}' '!node_modules/**'",
@ -53,7 +55,7 @@
"clean-transpile-once": "rimraf app/**/*.js app/*.js sticker-creator/**/*.js sticker-creator/*.js ts/**/*.js ts/*.js tsconfig.tsbuildinfo",
"clean-transpile": "yarn run clean-transpile-once && yarn run clean-transpile-once",
"open-coverage": "open coverage/lcov-report/index.html",
"ready": "npm-run-all --print-label clean-transpile generate --parallel lint lint-deps test-node test-electron",
"ready": "npm-run-all --print-label clean-transpile generate --parallel lint lint-deps lint-intl test-node test-electron",
"dev": "run-p --print-label dev:*",
"dev:transpile": "run-p \"check:types --watch\" dev:esbuild",
"dev:webpack": "run-p dev:esbuild dev:webpack:sticker-creator",
@ -80,6 +82,7 @@
"fs-xattr": "0.3.0"
},
"dependencies": {
"@formatjs/fast-memoize": "1.2.6",
"@indutny/frameless-titlebar": "2.3.5",
"@popperjs/core": "2.9.2",
"@react-spring/web": "9.4.5",
@ -154,6 +157,7 @@
"react-dom": "17.0.2",
"react-dropzone": "10.2.2",
"react-hot-loader": "4.13.0",
"react-intl": "6.1.1",
"react-measure": "2.3.0",
"react-popper": "2.3.0",
"react-quill": "2.0.0-beta.4",
@ -226,6 +230,7 @@
"@types/intl-tel-input": "17.0.4",
"@types/jquery": "3.5.6",
"@types/js-yaml": "3.12.0",
"@types/json-to-ast": "2.1.2",
"@types/linkify-it": "2.1.0",
"@types/lodash": "4.14.106",
"@types/long": "4.0.1",
@ -271,6 +276,7 @@
"casual": "1.6.2",
"chai": "4.3.4",
"chai-as-promised": "7.1.1",
"chalk": "4.1.2",
"core-js": "2.6.9",
"cross-env": "5.2.0",
"css-loader": "3.2.0",
@ -291,6 +297,7 @@
"eslint-plugin-react": "7.20.6",
"file-loader": "4.2.0",
"html-webpack-plugin": "5.3.1",
"json-to-ast": "2.1.0",
"mocha": "9.1.3",
"mocha-testcheck": "1.0.0-rc.0",
"node-gyp": "9.0.0",

View file

@ -75,9 +75,9 @@ export const AppStage: React.ComponentType<Props> = props => {
) : null}
{addMoreCount > 0 ? (
<Text secondary>
{i18n('StickerCreator--DropStage--addMore', [
addMoreCount.toString(),
])}
{i18n('icu:StickerCreator--DropStage--addMore', {
count: addMoreCount,
})}
</Text>
) : null}
{next || onNext ? (

View file

@ -24,7 +24,7 @@ export const DropStage: React.ComponentType = () => {
return (
<AppStage next="/add-emojis" nextActive={stickersReady}>
<H2>{i18n('StickerCreator--DropStage--title')}</H2>
<H2>{i18n('icu:StickerCreator--DropStage--title')}</H2>
<div className={styles.info}>
<Text className={styles.message}>
{i18n('StickerCreator--DropStage--help')}

View file

@ -52,7 +52,7 @@ export const UploadStage: React.ComponentType = () => {
);
actions.addToast({
key: 'StickerCreator--Toasts--errorUploading',
subs: [e.message],
subs: { message: e.message },
});
history.push('/add-meta');
}

View file

@ -49,7 +49,7 @@ export const setPackMeta = createAction<PackMetaData>('stickers/setPackMeta');
export const addToast = createAction<{
key: string;
subs?: Array<string>;
subs?: Record<string, string>;
}>('stickers/addToast');
export const dismissToast = createAction<void>('stickers/dismissToast');
@ -67,7 +67,7 @@ type StateStickerData = {
type StateToastData = {
key: string;
subs?: Array<string>;
subs?: Record<string, string>;
};
export type State = {
@ -150,7 +150,7 @@ export const reducer = reduceReducers<State>(
if (data && !data.imageData) {
data.imageData = payload;
const key = 'StickerCreator--Toasts--imagesAdded';
const key = 'icu:StickerCreator--Toasts--imagesAdded';
const toast = (() => {
const oldToast = find(state.toasts, { key });
@ -159,21 +159,21 @@ export const reducer = reduceReducers<State>(
return oldToast;
}
const newToast = { key, subs: ['0'] };
const newToast = { key, subs: { count: '0' } };
state.toasts.push(newToast);
return newToast;
})();
const previousSub = toast?.subs?.[0];
const previousSub = toast?.subs?.count;
if (toast && isString(previousSub)) {
const previousCount = parseInt(previousSub, 10);
const newCount = Number.isFinite(previousCount)
? previousCount + 1
: 1;
toast.subs = toast.subs || [];
toast.subs[0] = newCount.toString();
toast.subs = toast.subs || {};
toast.subs.count = newCount.toString();
}
}
}

View file

@ -2,17 +2,29 @@
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import type { LocaleMessagesType } from '../../ts/types/I18N';
import type { LocalizerType, ReplacementValuesType } from '../../ts/types/Util';
import {
classifyMessages,
createCachedIntl,
formatIcuMessage,
} from '../../ts/util/setupI18n';
const placeholder = () => 'NO LOCALE LOADED';
placeholder.getLocale = () => 'none';
placeholder.isLegacyFormat = () => {
throw new Error("Can't call isLegacyFormat on placeholder");
};
placeholder.getIntl = () => {
throw new Error("Can't call getIntl on placeholder");
};
const I18nContext = React.createContext<LocalizerType>(placeholder);
export type I18nProps = {
children: React.ReactNode;
locale: string;
messages: { [key: string]: { message: string } };
messages: LocaleMessagesType;
};
export const I18n = ({
@ -20,6 +32,13 @@ export const I18n = ({
locale,
children,
}: I18nProps): JSX.Element => {
const { icuMessages, legacyMessages } = React.useMemo(() => {
return classifyMessages(messages);
}, [messages]);
const intl = React.useMemo(() => {
return createCachedIntl(locale, icuMessages);
}, [locale, icuMessages]);
const callback = (key: string, substitutions?: ReplacementValuesType) => {
if (Array.isArray(substitutions) && substitutions.length > 1) {
throw new Error(
@ -27,15 +46,18 @@ export const I18n = ({
);
}
const stringInfo = messages[key];
if (!stringInfo) {
const messageformat = icuMessages[key];
if (messageformat != null) {
return formatIcuMessage(intl, key, substitutions);
}
const message = legacyMessages[key];
if (message == null) {
window.SignalContext.log.warn(
`getMessage: No string found for key ${key}`
);
return '';
}
const { message } = stringInfo;
if (!substitutions) {
return message;
}
@ -79,8 +101,16 @@ export const I18n = ({
return builder;
};
callback.getLocale = () => locale;
callback.isLegacyFormat = (key: string) => {
return legacyMessages[key] != null;
};
callback.getIntl = () => intl;
const getMessage = React.useCallback<LocalizerType>(callback, [messages]);
const getMessage = React.useCallback<LocalizerType>(callback, [
icuMessages,
legacyMessages,
intl,
]);
return (
<I18nContext.Provider value={getMessage}>{children}</I18nContext.Provider>

View file

@ -3,11 +3,17 @@
import React from 'react';
import type { FormatXMLElementFn } from 'intl-messageformat';
import type { LocalizerType, RenderTextCallbackType } from '../types/Util';
import type { ReplacementValuesType } from '../types/I18N';
import * as log from '../logging/log';
import { strictAssert } from '../util/assert';
export type FullJSXType = Array<JSX.Element | string> | JSX.Element | string;
export type FullJSXType =
| FormatXMLElementFn<JSX.Element | string>
| Array<JSX.Element | string>
| JSX.Element
| string;
export type IntlComponentsType =
| undefined
| Array<FullJSXType>
@ -32,7 +38,7 @@ export class Intl extends React.Component<Props> {
index: number,
placeholderName: string,
key: number
): FullJSXType | null {
): JSX.Element | null {
const { id, components } = this.props;
if (!components) {
@ -75,6 +81,15 @@ export class Intl extends React.Component<Props> {
return null;
}
if (!i18n.isLegacyFormat(id)) {
strictAssert(
!Array.isArray(components),
`components cannot be an array for ICU message ${id}`
);
const intl = i18n.getIntl();
return intl.formatMessage({ id }, components);
}
const text = i18n(id);
const results: Array<
string | JSX.Element | Array<string | JSX.Element> | null

View file

@ -915,7 +915,7 @@ export const Preferences = ({
onSubmit={onUniversalExpireTimerChange}
/>
)}
<SettingsRow title={i18n('disappearingMessages')}>
<SettingsRow title={i18n('icu:disappearingMessages')}>
<Control
left={
<>

View file

@ -798,7 +798,7 @@ export const ProfileEditor = ({
<div className="ProfileEditor__info">
<Intl
i18n={i18n}
id="ProfileEditor--info"
id="icu:ProfileEditor--info"
components={{
learnMore: (
<a

View file

@ -347,7 +347,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
const muteOptions = getMuteOptions(muteExpiresAt, i18n);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const disappearingTitle = i18n('disappearingMessages') as any;
const disappearingTitle = i18n('icu:disappearingMessages') as any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const muteTitle = i18n('muteNotificationsTitle') as any;
const isGroup = type === 'group';

View file

@ -98,12 +98,12 @@ function InstallScreenQrCode(
<span className={classNames(getQrCodeClassName('__error-message'))}>
<Intl
i18n={i18n}
id="Install__qr-failed"
components={[
<a href={QR_CODE_FAILED_LINK}>
{i18n('Install__qr-failed__learn-more')}
</a>,
]}
id="icu:Install__qr-failed"
components={{
learnMoreLink: children => (
<a href={QR_CODE_FAILED_LINK}>{children}</a>
),
}}
/>
</span>
);

View file

@ -120,15 +120,17 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
<div>
<Intl
i18n={i18n}
id="emptyInboxMessage"
components={[
id="icu:emptyInboxMessage"
components={{
composeIcon: (
<span>
<strong>{i18n('composeIcon')}</strong>
<span className="module-left-pane__empty--composer_icon">
<i className="module-left-pane__empty--composer_icon--icon" />
</span>
</span>,
]}
</span>
),
}}
/>
</div>
</div>

View file

@ -148,7 +148,7 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
if (searchConversationName) {
noResults = (
<Intl
id="noSearchResultsInConversation"
id="icu:noSearchResultsInConversation"
i18n={i18n}
components={{
searchTerm,

View file

@ -199,7 +199,7 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
<section className="module-left-pane__header__form__expire-timer">
<div className="module-left-pane__header__form__expire-timer__label">
{i18n('disappearingMessages')}
{i18n('icu:disappearingMessages')}
</div>
<DisappearingTimerSelect
i18n={i18n}

View file

@ -89,6 +89,10 @@ function manualReconnect(): NoopActionType {
};
}
const intlNotSetup = () => {
throw new Error('i18n not yet set up');
};
// Reducer
export function getEmptyState(): UserStateType {
@ -114,16 +118,11 @@ export function getEmptyState(): UserStateType {
platform: 'unknown',
},
theme: ThemeType.light,
i18n: Object.assign(
() => {
throw new Error('i18n not yet set up');
},
{
getLocale() {
throw new Error('i18n not yet set up');
},
}
),
i18n: Object.assign(intlNotSetup, {
getLocale: intlNotSetup,
getIntl: intlNotSetup,
isLegacyFormat: intlNotSetup,
}),
localeMessages: {},
version: '0.0.0',
};

View file

@ -34,6 +34,15 @@ describe('setupI18n', () => {
'Someone set the disappearing message time to 5 minutes.'
);
});
it('returns a modern icu message formatted', () => {
const actual = i18n('icu:ProfileEditor--info', {
learnMore: 'LEARN MORE',
});
assert.equal(
actual,
'Your profile is encrypted. Your profile and changes to it will be visible to your contacts and when you start or accept new chats. LEARN MORE'
);
});
});
describe('getLocale', () => {
@ -42,4 +51,26 @@ describe('setupI18n', () => {
assert.isAtLeast(locale.trim().length, 2);
});
});
describe('getIntl', () => {
it('returns the intl object to call formatMessage()', () => {
const intl = i18n.getIntl();
assert.isObject(intl);
const result = intl.formatMessage(
{ id: 'icu:emptyInboxMessage' },
{ composeIcon: 'ICONIC' }
);
assert.equal(
result,
'Click the ICONIC above and search for your contacts or groups to message.'
);
});
});
describe('isLegacyFormat', () => {
it('returns false for new format', () => {
assert.isFalse(i18n.isLegacyFormat('icu:ProfileEditor--info'));
assert.isTrue(i18n.isLegacyFormat('softwareAcknowledgments'));
});
});
});

View file

@ -67,7 +67,11 @@ describe('MentionCompletion', () => {
const options: MentionCompletionOptions = {
getPreferredBadge: () => undefined,
i18n: Object.assign(sinon.stub(), { getLocale: sinon.stub() }),
i18n: Object.assign(sinon.stub(), {
getLocale: sinon.stub(),
getIntl: sinon.stub(),
isLegacyFormat: sinon.stub(),
}),
me,
memberRepositoryRef,
setMentionPickerElement: sinon.stub(),

View file

@ -9,6 +9,7 @@ export type { LocalizerType } from './Util';
type SmartlingConfigType = {
placeholder_format_custom: string;
string_format_paths?: string;
translate_paths: Array<{
key: string;
path: string;
@ -16,15 +17,10 @@ type SmartlingConfigType = {
}>;
};
type LocaleMessageType = {
message: string;
export type LocaleMessageType = {
message?: string;
messageformat?: string;
description?: string;
placeholders?: {
[name: string]: {
content: string;
example: string;
};
};
};
export type LocaleMessagesType = {

View file

@ -1,6 +1,7 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { IntlShape } from 'react-intl';
import type { UUIDStringType } from './UUID';
export type BodyRangeType = {
@ -31,6 +32,8 @@ export type ReplacementValuesType =
export type LocalizerType = {
(key: string, values?: ReplacementValuesType): string;
getIntl(): IntlShape;
isLegacyFormat(key: string): boolean;
getLocale(): string;
};

View file

@ -1,9 +1,87 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { LocaleMessagesType } from '../types/I18N';
import type { LocalizerType } from '../types/Util';
import memoize from '@formatjs/fast-memoize';
import type { IntlShape } from 'react-intl';
import { createIntl, createIntlCache } from 'react-intl';
import type { LocaleMessageType, LocaleMessagesType } from '../types/I18N';
import type { LocalizerType, ReplacementValuesType } from '../types/Util';
import * as log from '../logging/log';
import { strictAssert } from './assert';
export const formatters = {
getNumberFormat: memoize((locale, opts) => {
return new Intl.NumberFormat(locale, opts);
}),
getDateTimeFormat: memoize((locale, opts) => {
return new Intl.DateTimeFormat(locale, opts);
}),
getPluralRules: memoize((locale, opts) => {
return new Intl.PluralRules(locale, opts);
}),
};
export function isLocaleMessageType(
value: unknown
): value is LocaleMessageType {
return (
typeof value === 'object' &&
value != null &&
(Object.hasOwn(value, 'message') || Object.hasOwn(value, 'messageformat'))
);
}
export function classifyMessages(messages: LocaleMessagesType): {
icuMessages: Record<string, string>;
legacyMessages: Record<string, string>;
} {
const icuMessages: Record<string, string> = {};
const legacyMessages: Record<string, string> = {};
for (const [key, value] of Object.entries(messages)) {
if (isLocaleMessageType(value)) {
if (value.messageformat != null) {
icuMessages[key] = value.messageformat;
} else if (value.message != null) {
legacyMessages[key] = value.message;
}
}
}
return { icuMessages, legacyMessages };
}
export function createCachedIntl(
locale: string,
icuMessages: Record<string, string>
): IntlShape {
const intlCache = createIntlCache();
const intl = createIntl(
{
locale: locale.replace('_', '-'), // normalize supported locales to browser format
messages: icuMessages,
},
intlCache
);
return intl;
}
export function formatIcuMessage(
intl: IntlShape,
id: string,
substitutions: ReplacementValuesType | undefined
): string {
strictAssert(
!Array.isArray(substitutions),
`substitutions must be an object for ICU message ${id}`
);
const result = intl.formatMessage({ id }, substitutions);
strictAssert(
typeof result === 'string',
'i18n: formatted translation result must be a string, must use <Intl/> component to render JSX'
);
return result;
}
export function setupI18n(
locale: string,
@ -16,14 +94,24 @@ export function setupI18n(
throw new Error('i18n: messages parameter is required');
}
const { icuMessages, legacyMessages } = classifyMessages(messages);
const intl = createCachedIntl(locale, icuMessages);
const getMessage: LocalizerType = (key, substitutions) => {
const entry = messages[key];
if (!entry || !('message' in entry)) {
const messageformat = icuMessages[key];
if (messageformat != null) {
return formatIcuMessage(intl, key, substitutions);
}
const message = legacyMessages[key];
if (message == null) {
log.error(
`i18n: Attempted to get translation for nonexistent key '${key}'`
);
return '';
}
if (Array.isArray(substitutions) && substitutions.length > 1) {
throw new Error(
'Array syntax is not supported with more than one placeholder'
@ -35,8 +123,6 @@ export function setupI18n(
) {
throw new Error('You must provide either a map or an array');
}
const { message } = entry;
if (!substitutions) {
return message;
}
@ -78,6 +164,12 @@ export function setupI18n(
return builder;
};
getMessage.getIntl = () => {
return intl;
};
getMessage.isLegacyFormat = (key: string) => {
return legacyMessages[key] != null;
};
getMessage.getLocale = () => locale;
return getMessage;

View file

@ -61,5 +61,11 @@
// "experimentalDecorators": true, // Enables experimental support for ES7 decorators.
// "emitDecoratorMetadata": true, // Enables experimental support for emitting type metadata for decorators.
},
"include": ["ts/**/*", "app/**/*", "sticker-creator/**/*", "package.json"]
"include": [
"ts/**/*",
"app/**/*",
"sticker-creator/**/*",
"package.json",
"build/intl-linter/**/*"
]
}

149
yarn.lock
View file

@ -1453,6 +1453,14 @@
"@formatjs/intl-localematcher" "0.2.25"
tslib "^2.1.0"
"@formatjs/ecma402-abstract@1.12.0":
version "1.12.0"
resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.12.0.tgz#2fb5e8983d5fae2fad9ec6c77aec1803c2b88d8e"
integrity sha512-0/wm9b7brUD40kx7KSE0S532T8EfH06Zc41rGlinoNyYXnuusR6ull2x63iFJgVXgwahm42hAW7dcYdZ+llZzA==
dependencies:
"@formatjs/intl-localematcher" "0.2.31"
tslib "2.4.0"
"@formatjs/fast-memoize@1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-1.2.1.tgz#e6f5aee2e4fd0ca5edba6eba7668e2d855e0fc21"
@ -1460,6 +1468,13 @@
dependencies:
tslib "^2.1.0"
"@formatjs/fast-memoize@1.2.6":
version "1.2.6"
resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-1.2.6.tgz#a442970db7e9634af556919343261a7bbe5e88c3"
integrity sha512-9CWZ3+wCkClKHX+i5j+NyoBVqGf0pIskTo6Xl6ihGokYM2yqSSS68JIgeo+99UIHc+7vi9L3/SDSz/dWI9SNlA==
dependencies:
tslib "2.4.0"
"@formatjs/icu-messageformat-parser@2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.1.0.tgz#a54293dd7f098d6a6f6a084ab08b6d54a3e8c12d"
@ -1469,6 +1484,23 @@
"@formatjs/icu-skeleton-parser" "1.3.6"
tslib "^2.1.0"
"@formatjs/icu-messageformat-parser@2.1.7":
version "2.1.7"
resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.1.7.tgz#35dc556c13a0544cc730300c8ddb730ba7f44bd4"
integrity sha512-KM4ikG5MloXMulqn39Js3ypuVzpPKq/DDplvl01PE2qD9rAzFO8YtaUCC9vr9j3sRXwdHPeTe8r3J/8IJgvYEQ==
dependencies:
"@formatjs/ecma402-abstract" "1.12.0"
"@formatjs/icu-skeleton-parser" "1.3.13"
tslib "2.4.0"
"@formatjs/icu-skeleton-parser@1.3.13":
version "1.3.13"
resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.13.tgz#f7e186e72ed73c3272d22a3aacb646e77368b099"
integrity sha512-qb1kxnA4ep76rV+d9JICvZBThBpK5X+nh1dLmmIReX72QyglicsaOmKEcdcbp7/giCWfhVs6CXPVA2JJ5/ZvAw==
dependencies:
"@formatjs/ecma402-abstract" "1.12.0"
tslib "2.4.0"
"@formatjs/icu-skeleton-parser@1.3.6":
version "1.3.6"
resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.3.6.tgz#4ce8c0737d6f07b735288177049e97acbf2e8964"
@ -1477,6 +1509,24 @@
"@formatjs/ecma402-abstract" "1.11.4"
tslib "^2.1.0"
"@formatjs/intl-displaynames@6.1.3":
version "6.1.3"
resolved "https://registry.yarnpkg.com/@formatjs/intl-displaynames/-/intl-displaynames-6.1.3.tgz#c9d283db518cd721c0855e9854bfadb9ba304b6a"
integrity sha512-yBB165IH72fweGymRPrq8PQ4R5gKMR8vOj6XmkxGBICyJMhknc+RpG02g9Jsk/4jvO6qw/H0QtXHrHIg+Jv0sw==
dependencies:
"@formatjs/ecma402-abstract" "1.12.0"
"@formatjs/intl-localematcher" "0.2.31"
tslib "2.4.0"
"@formatjs/intl-listformat@7.1.2":
version "7.1.2"
resolved "https://registry.yarnpkg.com/@formatjs/intl-listformat/-/intl-listformat-7.1.2.tgz#3c5145436434795fa834150d0b6b6dc577aa6964"
integrity sha512-WfWkJ8k41jZIhXgBtC2T1SpTSKYig99g9MVqrVRco4kduv/6GUWq1eMjk84qZfbU4rwdwc8qct+/gB6DTS17+w==
dependencies:
"@formatjs/ecma402-abstract" "1.12.0"
"@formatjs/intl-localematcher" "0.2.31"
tslib "2.4.0"
"@formatjs/intl-localematcher@0.2.25":
version "0.2.25"
resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.2.25.tgz#60892fe1b271ec35ba07a2eb018a2dd7bca6ea3a"
@ -1484,6 +1534,26 @@
dependencies:
tslib "^2.1.0"
"@formatjs/intl-localematcher@0.2.31":
version "0.2.31"
resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.2.31.tgz#aada2b1e58211460cedba56889e3c489117eb6eb"
integrity sha512-9QTjdSBpQ7wHShZgsNzNig5qT3rCPvmZogS/wXZzKotns5skbXgs0I7J8cuN0PPqXyynvNVuN+iOKhNS2eb+ZA==
dependencies:
tslib "2.4.0"
"@formatjs/intl@2.4.1":
version "2.4.1"
resolved "https://registry.yarnpkg.com/@formatjs/intl/-/intl-2.4.1.tgz#3e8ae8542e827c55cb1b7298bd72d4a009c2224d"
integrity sha512-lWJ5dhLlkbMeWQOxBCq4MJNkB735TO5rwvcnnFzTx1H9Pkth1OLRH1R1aCAudptbd0Qe1W2hwJiMLumKpl6WCg==
dependencies:
"@formatjs/ecma402-abstract" "1.12.0"
"@formatjs/fast-memoize" "1.2.6"
"@formatjs/icu-messageformat-parser" "2.1.7"
"@formatjs/intl-displaynames" "6.1.3"
"@formatjs/intl-listformat" "7.1.2"
intl-messageformat "10.1.4"
tslib "2.4.0"
"@gar/promisify@^1.0.1", "@gar/promisify@^1.1.3":
version "1.1.3"
resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6"
@ -3220,7 +3290,7 @@
resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.2.tgz#0e670ea254d559241b6eeb3894f8754991e73220"
integrity sha512-ui3WwXmjTaY73fOQ3/m3nnajU/Orhi6cEu5rzX+BrAAJxa3eITXZ5ch9suPqtM03OWhAHhPSyBGCN4UKoxO20Q==
"@types/hoist-non-react-statics@^3.3.0":
"@types/hoist-non-react-statics@^3.3.0", "@types/hoist-non-react-statics@^3.3.1":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz#1124aafe5118cb591977aeb1ceaaed1070eb039f"
integrity sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==
@ -3318,6 +3388,11 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
"@types/json-to-ast@2.1.2":
version "2.1.2"
resolved "https://registry.yarnpkg.com/@types/json-to-ast/-/json-to-ast-2.1.2.tgz#1f6670cfcd44eb745472744a3630b4da563f27dc"
integrity sha512-GEjR5l9wZGS74KhL1a1tZuyRJqdLB7LGgOXzWspJx9xxC/iyCFTwwKv71Lz8fzZyGuVW8FjASQGoYFi6XZJWLQ==
"@types/json5@^0.0.29":
version "0.0.29"
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
@ -3580,7 +3655,7 @@
"@types/prop-types" "*"
"@types/react" "*"
"@types/react@*", "@types/react@17.0.45", "@types/react@^17":
"@types/react@*", "@types/react@16 || 17 || 18", "@types/react@17.0.45", "@types/react@^17":
version "17.0.45"
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.45.tgz#9b3d5b661fd26365fefef0e766a1c6c30ccf7b3f"
integrity sha512-YfhQ22Lah2e3CHPsb93tRwIGNiSwkuz1/blk4e6QrWS0jQzCSNbGLtOEYhPg02W0yGTTmpajp7dCTbBAMN3qsg==
@ -5927,6 +6002,14 @@ chai@4.3.4:
pathval "^1.1.1"
type-detect "^4.0.5"
chalk@4.1.2, chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1:
version "4.1.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
dependencies:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
chalk@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
@ -5955,14 +6038,6 @@ chalk@^3.0.0:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
chalk@^4.0.0, chalk@^4.0.2, chalk@^4.1.0, chalk@^4.1.1:
version "4.1.2"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
dependencies:
ansi-styles "^4.1.0"
supports-color "^7.1.0"
character-entities-legacy@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.1.tgz#f40779df1a101872bb510a3d295e1fccf147202f"
@ -6190,6 +6265,11 @@ clsx@^1.0.4:
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188"
integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==
code-error-fragment@0.0.230:
version "0.0.230"
resolved "https://registry.yarnpkg.com/code-error-fragment/-/code-error-fragment-0.0.230.tgz#d736d75c832445342eca1d1fedbf17d9618b14d7"
integrity sha512-cadkfKp6932H8UkhzE/gcUqhRMNf8jHzkAN7+5Myabswaghu4xABTgPHDCjW+dBAJxj/SpkTYokpzDqY4pCzQw==
code-point-at@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
@ -9600,6 +9680,11 @@ graceful-fs@^4.2.9:
version "1.0.1"
resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725"
grapheme-splitter@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e"
integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==
growl@1.10.5:
version "1.10.5"
resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e"
@ -10345,6 +10430,16 @@ interpret@^2.2.0:
resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9"
integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==
intl-messageformat@10.1.4:
version "10.1.4"
resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-10.1.4.tgz#bf5ad48e357e3f3ab6559599296f54c175b22a92"
integrity sha512-tXCmWCXhbeHOF28aIf5b9ce3kwdwGyIiiSXVZsyDwksMiGn5Tp0MrMvyeuHuz4uN1UL+NfGOztHmE+6aLFp1wQ==
dependencies:
"@formatjs/ecma402-abstract" "1.12.0"
"@formatjs/fast-memoize" "1.2.6"
"@formatjs/icu-messageformat-parser" "2.1.7"
tslib "2.4.0"
intl-messageformat@^9.3.19:
version "9.13.0"
resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-9.13.0.tgz#97360b73bd82212e4f6005c712a4a16053165468"
@ -11146,6 +11241,14 @@ json-stringify-safe@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb"
json-to-ast@2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/json-to-ast/-/json-to-ast-2.1.0.tgz#041a9fcd03c0845036acb670d29f425cea4faaf9"
integrity sha512-W9Lq347r8tA1DfMvAGn9QNcgYm4Wm7Yc+k8e6vezpMnRT+NHbtlxgNBXRVjXe9YM6eTn6+p/MKOlV/aABJcSnQ==
dependencies:
code-error-fragment "0.0.230"
grapheme-splitter "^1.0.4"
json5@0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/json5/-/json5-0.4.0.tgz#054352e4c4c80c86c0923877d449de176a732c8d"
@ -14501,6 +14604,22 @@ react-inspector@^5.1.0:
is-dom "^1.0.0"
prop-types "^15.0.0"
react-intl@6.1.1:
version "6.1.1"
resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-6.1.1.tgz#9c9b613f8de8a7d08311455d6a901806da005f8d"
integrity sha512-nNNHBxivUdNwIcqNR1I4mLDAfDtnh1glEaOa8Sfu2pUDvKzYQyX6+in1PDcIn5RyV6enMgw9I6H+VwtlRDXhRw==
dependencies:
"@formatjs/ecma402-abstract" "1.12.0"
"@formatjs/icu-messageformat-parser" "2.1.7"
"@formatjs/intl" "2.4.1"
"@formatjs/intl-displaynames" "6.1.3"
"@formatjs/intl-listformat" "7.1.2"
"@types/hoist-non-react-statics" "^3.3.1"
"@types/react" "16 || 17 || 18"
hoist-non-react-statics "^3.3.2"
intl-messageformat "10.1.4"
tslib "2.4.0"
react-is@17.0.2, react-is@^17.0.1, react-is@^17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
@ -16873,6 +16992,11 @@ tsconfig-paths@^3.11.0, tsconfig-paths@^3.9.0:
minimist "^1.2.0"
strip-bom "^3.0.0"
tslib@2.4.0, tslib@^2.0.0, tslib@^2.1.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
tslib@^1.8.1, tslib@^1.9.0:
version "1.10.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
@ -16883,11 +17007,6 @@ tslib@^1.9.3:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
tslib@^2.0.0, tslib@^2.1.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3"
integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==
tslib@^2.0.1, tslib@^2.0.3:
version "2.2.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c"