From 6d56f8b8aafd58b864db8f4a62004b760f9267d4 Mon Sep 17 00:00:00 2001 From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Mon, 3 Oct 2022 14:19:54 -0700 Subject: [PATCH] support icu messageformat for translations --- .eslintignore | 1 - .eslintrc.js | 8 +- ACKNOWLEDGMENTS.md | 8 + _locales/en/messages.json | 42 +++-- build/intl-linter/linter.ts | 177 ++++++++++++++++++ build/intl-linter/rules/icuPrefix.ts | 11 ++ build/intl-linter/rules/noLegacyVariables.ts | 17 ++ build/intl-linter/rules/noNestedChoice.ts | 35 ++++ build/intl-linter/rules/noOffset.ts | 17 ++ build/intl-linter/rules/noOrdinal.ts | 17 ++ build/intl-linter/rules/onePlural.ts | 19 ++ build/intl-linter/utils/rule.ts | 33 ++++ build/intl-linter/utils/traverse.ts | 90 +++++++++ package.json | 11 +- sticker-creator/app/stages/AppStage.tsx | 6 +- sticker-creator/app/stages/DropStage.tsx | 2 +- sticker-creator/app/stages/UploadStage.tsx | 2 +- sticker-creator/store/ducks/stickers.ts | 14 +- sticker-creator/util/i18n.tsx | 42 ++++- ts/components/Intl.tsx | 19 +- ts/components/Preferences.tsx | 2 +- ts/components/ProfileEditor.tsx | 2 +- .../conversation/ConversationHeader.tsx | 2 +- .../InstallScreenQrCodeNotScannedStep.tsx | 12 +- .../leftPane/LeftPaneInboxHelper.tsx | 18 +- .../leftPane/LeftPaneSearchHelper.tsx | 2 +- .../LeftPaneSetGroupMetadataHelper.tsx | 2 +- ts/state/ducks/user.ts | 19 +- ts/test-both/types/setupI18n_test.ts | 31 +++ .../quill/mentions/completion_test.tsx | 6 +- ts/types/I18N.ts | 12 +- ts/types/Util.ts | 3 + ts/util/setupI18n.ts | 104 +++++++++- tsconfig.json | 8 +- yarn.lock | 149 +++++++++++++-- 35 files changed, 839 insertions(+), 104 deletions(-) create mode 100644 build/intl-linter/linter.ts create mode 100644 build/intl-linter/rules/icuPrefix.ts create mode 100644 build/intl-linter/rules/noLegacyVariables.ts create mode 100644 build/intl-linter/rules/noNestedChoice.ts create mode 100644 build/intl-linter/rules/noOffset.ts create mode 100644 build/intl-linter/rules/noOrdinal.ts create mode 100644 build/intl-linter/rules/onePlural.ts create mode 100644 build/intl-linter/utils/rule.ts create mode 100644 build/intl-linter/utils/traverse.ts diff --git a/.eslintignore b/.eslintignore index 87ca3b3c1e65..c6631e5f6873 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,3 @@ -build/** components/** coverage/** dist/** diff --git a/.eslintrc.js b/.eslintrc.js index 1e4509d9b497..147cc94227a4 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -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', diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 16e88d233d1e..b4b8465643b7 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -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) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 235974afc2a2..dc2031cc0846 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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. Learn more", "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": { diff --git a/build/intl-linter/linter.ts b/build/intl-linter/linter.ts new file mode 100644 index 000000000000..9639ffa4ab17 --- /dev/null +++ b/build/intl-linter/linter.ts @@ -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; +}; + +const tests: Record = { + 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, + rules: Array +) { + const reports: Array = []; + 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); +}); diff --git a/build/intl-linter/rules/icuPrefix.ts b/build/intl-linter/rules/icuPrefix.ts new file mode 100644 index 000000000000..3ca40fede983 --- /dev/null +++ b/build/intl-linter/rules/icuPrefix.ts @@ -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 {}; +}); diff --git a/build/intl-linter/rules/noLegacyVariables.ts b/build/intl-linter/rules/noLegacyVariables.ts new file mode 100644 index 000000000000..452fe47b0f26 --- /dev/null +++ b/build/intl-linter/rules/noLegacyVariables.ts @@ -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 + ); + } + }, + }; +}); diff --git a/build/intl-linter/rules/noNestedChoice.ts b/build/intl-linter/rules/noNestedChoice.ts new file mode 100644 index 000000000000..ed4e43b53ec5 --- /dev/null +++ b/build/intl-linter/rules/noNestedChoice.ts @@ -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; + }, + }; +}); diff --git a/build/intl-linter/rules/noOffset.ts b/build/intl-linter/rules/noOffset.ts new file mode 100644 index 000000000000..5262f0058e54 --- /dev/null +++ b/build/intl-linter/rules/noOffset.ts @@ -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 + ); + } + }, + }; +}); diff --git a/build/intl-linter/rules/noOrdinal.ts b/build/intl-linter/rules/noOrdinal.ts new file mode 100644 index 000000000000..c8d6926bea04 --- /dev/null +++ b/build/intl-linter/rules/noOrdinal.ts @@ -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 + ); + } + }, + }; +}); diff --git a/build/intl-linter/rules/onePlural.ts b/build/intl-linter/rules/onePlural.ts new file mode 100644 index 000000000000..74f6232dfa2b --- /dev/null +++ b/build/intl-linter/rules/onePlural.ts @@ -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 + ); + } + }, + }; +}); diff --git a/build/intl-linter/utils/rule.ts b/build/intl-linter/utils/rule.ts new file mode 100644 index 000000000000..f444165f00f0 --- /dev/null +++ b/build/intl-linter/utils/rule.ts @@ -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, context: Context): void; +}; + +export function rule(id: string, ruleFactory: RuleFactory): Rule { + return { + id, + run(elements, context) { + traverse(elements, ruleFactory(context)); + }, + }; +} diff --git a/build/intl-linter/utils/traverse.ts b/build/intl-linter/utils/traverse.ts new file mode 100644 index 000000000000..9ad7f5ba53ff --- /dev/null +++ b/build/intl-linter/utils/traverse.ts @@ -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 = ( + element: T +) => void; + +export type Visitor = { + enterLiteral?: VisitorMethod; + exitLiteral?: VisitorMethod; + enterArgument?: VisitorMethod; + exitArgument?: VisitorMethod; + enterNumber?: VisitorMethod; + exitNumber?: VisitorMethod; + enterDate?: VisitorMethod; + exitDate?: VisitorMethod; + enterTime?: VisitorMethod; + exitTime?: VisitorMethod; + enterSelect?: VisitorMethod; + exitSelect?: VisitorMethod; + enterPlural?: VisitorMethod; + exitPlural?: VisitorMethod; + enterPound?: VisitorMethod; + exitPound?: VisitorMethod; + enterTag?: VisitorMethod; + exitTag?: VisitorMethod; +}; + +export function traverse( + elements: Array, + 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}`); +} diff --git a/package.json b/package.json index 38a16c664e2b..ceeaad3e2c03 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/sticker-creator/app/stages/AppStage.tsx b/sticker-creator/app/stages/AppStage.tsx index 5df128012fda..e95bc001e6d5 100644 --- a/sticker-creator/app/stages/AppStage.tsx +++ b/sticker-creator/app/stages/AppStage.tsx @@ -75,9 +75,9 @@ export const AppStage: React.ComponentType = props => { ) : null} {addMoreCount > 0 ? ( - {i18n('StickerCreator--DropStage--addMore', [ - addMoreCount.toString(), - ])} + {i18n('icu:StickerCreator--DropStage--addMore', { + count: addMoreCount, + })} ) : null} {next || onNext ? ( diff --git a/sticker-creator/app/stages/DropStage.tsx b/sticker-creator/app/stages/DropStage.tsx index 15b7bc21a0ee..e252630818de 100644 --- a/sticker-creator/app/stages/DropStage.tsx +++ b/sticker-creator/app/stages/DropStage.tsx @@ -24,7 +24,7 @@ export const DropStage: React.ComponentType = () => { return ( - {i18n('StickerCreator--DropStage--title')} + {i18n('icu:StickerCreator--DropStage--title')} {i18n('StickerCreator--DropStage--help')} diff --git a/sticker-creator/app/stages/UploadStage.tsx b/sticker-creator/app/stages/UploadStage.tsx index 9f034a046ac7..50fbbf0c9c35 100644 --- a/sticker-creator/app/stages/UploadStage.tsx +++ b/sticker-creator/app/stages/UploadStage.tsx @@ -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'); } diff --git a/sticker-creator/store/ducks/stickers.ts b/sticker-creator/store/ducks/stickers.ts index ec2ec4c61761..29d7d1f6793a 100644 --- a/sticker-creator/store/ducks/stickers.ts +++ b/sticker-creator/store/ducks/stickers.ts @@ -49,7 +49,7 @@ export const setPackMeta = createAction('stickers/setPackMeta'); export const addToast = createAction<{ key: string; - subs?: Array; + subs?: Record; }>('stickers/addToast'); export const dismissToast = createAction('stickers/dismissToast'); @@ -67,7 +67,7 @@ type StateStickerData = { type StateToastData = { key: string; - subs?: Array; + subs?: Record; }; export type State = { @@ -150,7 +150,7 @@ export const reducer = reduceReducers( 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( 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(); } } } diff --git a/sticker-creator/util/i18n.tsx b/sticker-creator/util/i18n.tsx index bb25e86b317a..03ddf70d5f92 100644 --- a/sticker-creator/util/i18n.tsx +++ b/sticker-creator/util/i18n.tsx @@ -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(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(callback, [messages]); + const getMessage = React.useCallback(callback, [ + icuMessages, + legacyMessages, + intl, + ]); return ( {children} diff --git a/ts/components/Intl.tsx b/ts/components/Intl.tsx index 3b1767a9c20c..29bc22264555 100644 --- a/ts/components/Intl.tsx +++ b/ts/components/Intl.tsx @@ -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; +export type FullJSXType = + | FormatXMLElementFn + | Array + | JSX.Element + | string; export type IntlComponentsType = | undefined | Array @@ -32,7 +38,7 @@ export class Intl extends React.Component { 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 { 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 | null diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index 897c68dafd9e..f16a2357a787 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -915,7 +915,7 @@ export const Preferences = ({ onSubmit={onUniversalExpireTimerChange} /> )} - + diff --git a/ts/components/ProfileEditor.tsx b/ts/components/ProfileEditor.tsx index b8b251a978f4..17b1198c91e8 100644 --- a/ts/components/ProfileEditor.tsx +++ b/ts/components/ProfileEditor.tsx @@ -798,7 +798,7 @@ export const ProfileEditor = ({ { 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'; diff --git a/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.tsx b/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.tsx index 360a19e97b21..d82c6341618d 100644 --- a/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.tsx +++ b/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.tsx @@ -98,12 +98,12 @@ function InstallScreenQrCode( - {i18n('Install__qr-failed__learn-more')} - , - ]} + id="icu:Install__qr-failed" + components={{ + learnMoreLink: children => ( + {children} + ), + }} /> ); diff --git a/ts/components/leftPane/LeftPaneInboxHelper.tsx b/ts/components/leftPane/LeftPaneInboxHelper.tsx index 332405144033..ce8858b577f0 100644 --- a/ts/components/leftPane/LeftPaneInboxHelper.tsx +++ b/ts/components/leftPane/LeftPaneInboxHelper.tsx @@ -120,15 +120,17 @@ export class LeftPaneInboxHelper extends LeftPaneHelper - {i18n('composeIcon')} - - + id="icu:emptyInboxMessage" + components={{ + composeIcon: ( + + {i18n('composeIcon')} + + + - , - ]} + ), + }} /> diff --git a/ts/components/leftPane/LeftPaneSearchHelper.tsx b/ts/components/leftPane/LeftPaneSearchHelper.tsx index 7083f95aabbf..d630c14e9a4e 100644 --- a/ts/components/leftPane/LeftPaneSearchHelper.tsx +++ b/ts/components/leftPane/LeftPaneSearchHelper.tsx @@ -148,7 +148,7 @@ export class LeftPaneSearchHelper extends LeftPaneHelper - {i18n('disappearingMessages')} + {i18n('icu:disappearingMessages')} { + 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', }; diff --git a/ts/test-both/types/setupI18n_test.ts b/ts/test-both/types/setupI18n_test.ts index 82674c0dc7ec..3e6d452b9b98 100644 --- a/ts/test-both/types/setupI18n_test.ts +++ b/ts/test-both/types/setupI18n_test.ts @@ -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')); + }); + }); }); diff --git a/ts/test-electron/quill/mentions/completion_test.tsx b/ts/test-electron/quill/mentions/completion_test.tsx index 4636a2a0ea60..f322860422e2 100644 --- a/ts/test-electron/quill/mentions/completion_test.tsx +++ b/ts/test-electron/quill/mentions/completion_test.tsx @@ -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(), diff --git a/ts/types/I18N.ts b/ts/types/I18N.ts index 68194263852d..36a707e22179 100644 --- a/ts/types/I18N.ts +++ b/ts/types/I18N.ts @@ -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 = { diff --git a/ts/types/Util.ts b/ts/types/Util.ts index 8ed15eb82e4f..e12c7ca4182d 100644 --- a/ts/types/Util.ts +++ b/ts/types/Util.ts @@ -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; }; diff --git a/ts/util/setupI18n.ts b/ts/util/setupI18n.ts index 93c0c9b024e9..47383cb48a20 100644 --- a/ts/util/setupI18n.ts +++ b/ts/util/setupI18n.ts @@ -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; + legacyMessages: Record; +} { + const icuMessages: Record = {}; + const legacyMessages: Record = {}; + + 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 +): 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 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; diff --git a/tsconfig.json b/tsconfig.json index 060877353dff..5466415bc029 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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/**/*" + ] } diff --git a/yarn.lock b/yarn.lock index d50133921eb9..8f7c107629fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"