diff --git a/.eslint/rules/valid-i18n-keys.js b/.eslint/rules/valid-i18n-keys.js deleted file mode 100644 index 545f5d9933c..00000000000 --- a/.eslint/rules/valid-i18n-keys.js +++ /dev/null @@ -1,471 +0,0 @@ -// Copyright 2022 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -const crypto = require('crypto'); -const icuParser = require('@formatjs/icu-messageformat-parser'); - -const globalMessages = require('../../_locales/en/messages.json'); -const messageKeys = Object.keys(globalMessages).sort((a, b) => { - return a.localeCompare(b); -}); - -const allIcuParams = messageKeys - .filter(key => { - return isIcuMessageKey(globalMessages, key); - }) - .map(key => { - return Array.from( - getIcuMessageParams(globalMessages[key].messageformat) - ).join('\n'); - }); - -const DEFAULT_RICH_TEXT_ELEMENT_NAMES = ['emojify']; - -const hashSum = crypto.createHash('sha256'); -hashSum.update(messageKeys.join('\n')); -hashSum.update(allIcuParams.join('\n')); -hashSum.update(DEFAULT_RICH_TEXT_ELEMENT_NAMES.join('\n')); -const messagesCacheKey = hashSum.digest('hex'); - -function isI18nCall(node) { - return ( - (node.type === 'CallExpression' && - node.callee.type === 'Identifier' && - node.callee.name === 'i18n') || - (node.callee.type === 'MemberExpression' && - node.callee.property.name === 'i18n') - ); -} - -function isIntlElement(node) { - return ( - node.type === 'JSXOpeningElement' && - node.name.type === 'JSXIdentifier' && - node.name.name === 'Intl' - ); -} - -function isStringLiteral(node) { - return node.type === 'Literal' && typeof node.value === 'string'; -} - -function valueToMessageKey(node) { - if (isStringLiteral(node)) { - return node.value; - } - return null; -} - -function getI18nCallMessageKey(node) { - if (node.arguments.length < 1) { - return null; - } - - let arg1 = node.arguments[0]; - if (arg1 == null) { - return null; - } - - return valueToMessageKey(arg1); -} - -function getI18nCallValues(node) { - // babel-eslint messes with elements arrays in some cases because of TS - if (node.arguments.length < 2) { - return null; - } - return node.arguments[1]; -} - -function getIntlElementMessageKey(node) { - let idAttribute = node.attributes.find(attribute => { - return ( - attribute.type === 'JSXAttribute' && - attribute.name.type === 'JSXIdentifier' && - attribute.name.name === 'id' - ); - }); - - if (idAttribute == null) { - return null; - } - - let value = idAttribute.value; - - return valueToMessageKey(value); -} - -function getIntlElementComponents(node) { - let componentsAttribute = node.attributes.find(attribute => { - return ( - attribute.type === 'JSXAttribute' && - attribute.name.type === 'JSXIdentifier' && - attribute.name.name === 'components' - ); - }); - - if (componentsAttribute == null) { - return null; - } - - let value = componentsAttribute.value; - if (value?.type !== 'JSXExpressionContainer') { - return null; - } - - return value.expression; -} - -function isValidMessageKey(messages, key) { - return Object.hasOwn(messages, key); -} - -function isIcuMessageKey(messages, key) { - if (!key.startsWith('icu:')) { - return false; - } - const message = messages[key]; - return message?.messageformat != null; -} - -function isDeletedMessageKey(messages, key) { - const description = messages[key]?.description; - return description?.toLowerCase().startsWith('(deleted '); -} - -function getIcuMessageParams(message, defaultRichTextElementNames = []) { - const params = new Set(); - - function visitOptions(options) { - for (const option of Object.values(options)) { - visit(option.value); - } - } - - function visit(elements) { - for (const element of elements) { - switch (element.type) { - case icuParser.TYPE.argument: - params.add(element.value); - break; - case icuParser.TYPE.date: - params.add(element.value); - break; - case icuParser.TYPE.literal: - break; - case icuParser.TYPE.number: - params.add(element.value); - break; - case icuParser.TYPE.plural: - params.add(element.value); - visitOptions(element.options); - break; - case icuParser.TYPE.pound: - break; - case icuParser.TYPE.select: - params.add(element.value); - visitOptions(element.options); - break; - case icuParser.TYPE.tag: - params.add(element.value); - visit(element.children); - break; - case icuParser.TYPE.time: - params.add(element.value); - break; - default: - throw new Error(`Unknown element type: ${element.type}`); - } - } - } - - visit(icuParser.parse(message)); - - for (const defaultRichTextElementName of defaultRichTextElementNames) { - params.delete(defaultRichTextElementName); - } - - return params; -} - -function getMissingFromSet(expected, actual) { - const result = new Set(); - for (const item of expected) { - if (!actual.has(item)) { - result.add(item); - } - } - return result; -} - -module.exports = { - messagesCacheKey, - meta: { - type: 'problem', - hasSuggestions: false, - fixable: false, - schema: [ - { - type: 'object', - properties: { - messagesCacheKey: { - type: 'string', - }, - __mockMessages__: { - type: 'object', - patternProperties: { - '.*': { - oneOf: [ - { - type: 'object', - properties: { - message: { type: 'string' }, - description: { type: 'string' }, - }, - required: ['message'], - }, - { - type: 'object', - properties: { - messageformat: { type: 'string' }, - description: { type: 'string' }, - }, - required: ['messageformat'], - }, - ], - }, - }, - }, - }, - required: ['messagesCacheKey'], - additionalProperties: false, - }, - ], - }, - create(context) { - const messagesCacheKeyOption = context.options[0].messagesCacheKey; - if (messagesCacheKeyOption !== messagesCacheKey) { - throw new Error( - `The cache key for the i18n rule does not match the current messages.json file (expected: ${messagesCacheKey}, received: ${messagesCacheKeyOption})` - ); - } - - const mockMessages = context.options[0].__mockMessages__; - const messages = mockMessages ?? globalMessages; - - return { - JSXOpeningElement(node) { - if (!isIntlElement(node)) { - return; - } - - const key = getIntlElementMessageKey(node); - - if (key == null) { - context.report({ - node, - message: - " must always be provided an 'id' attribute with a literal string", - }); - return; - } - - if (!isValidMessageKey(messages, key)) { - context.report({ - node, - message: ` id "${key}" not found in _locales/en/messages.json`, - }); - return; - } - - if (!isIcuMessageKey(messages, key)) { - context.report({ - node, - message: ` id "${key}" is not an ICU message in _locales/en/messages.json`, - }); - return; - } - - if (isDeletedMessageKey(messages, key)) { - context.report({ - node, - message: ` id "${key}" is marked as deleted in _locales/en/messages.json`, - }); - return; - } - - const params = getIcuMessageParams( - messages[key].messageformat, - DEFAULT_RICH_TEXT_ELEMENT_NAMES - ); - const components = getIntlElementComponents(node); - - if (params.size === 0) { - if (components != null) { - context.report({ - node, - message: ` message "${key}" does not have any params, but has a "components" attribute`, - }); - } - return; - } - - if (components == null) { - context.report({ - node, - message: ` message "${key}" has params, but is missing a "components" attribute`, - }); - return; - } - - if (components.type !== 'ObjectExpression') { - context.report({ - node: components, - message: ` "components" attribute must be an object literal`, - }); - return; - } - - const props = new Set(); - for (const property of components.properties) { - if (property.type !== 'Property' || property.computed) { - context.report({ - node: property, - message: ` "components" attribute must only contain literal keys`, - }); - return; - } - props.add(property.key.name); - } - - const missingParams = getMissingFromSet(params, props); - if (missingParams.size > 0) { - for (const param of missingParams) { - context.report({ - node: components, - message: ` message "${key}" has a param "${param}", but no corresponding component`, - }); - } - return; - } - - const extraComponents = getMissingFromSet(props, params); - if (extraComponents.size > 0) { - for (const prop of extraComponents) { - context.report({ - node: components, - message: ` message "${key}" has a component "${prop}", but no corresponding param`, - }); - } - return; - } - }, - CallExpression(node) { - if (!isI18nCall(node)) { - return; - } - - const key = getI18nCallMessageKey(node); - - if (key == null) { - context.report({ - node, - message: - "i18n()'s first argument should always be a literal string", - }); - return; - } - - if (!isValidMessageKey(messages, key)) { - context.report({ - node, - message: `i18n() key "${key}" not found in _locales/en/messages.json`, - }); - return; - } - - if (!isIcuMessageKey(messages, key)) { - context.report({ - node, - message: `i18n() key "${key}" is not an ICU message in _locales/en/messages.json`, - }); - return; - } - - if (isDeletedMessageKey(messages, key)) { - context.report({ - node, - message: `i18n() key "${key}" is marked as deleted in _locales/en/messages.json`, - }); - return; - } - - const params = getIcuMessageParams( - messages[key].messageformat, - DEFAULT_RICH_TEXT_ELEMENT_NAMES - ); - const values = getI18nCallValues(node); - - if (params.size === 0) { - if (values != null) { - context.report({ - node, - message: `i18n() message "${key}" does not have any params, but has a "values" argument`, - }); - } - return; - } - - if (values == null) { - context.report({ - node, - message: `i18n() message "${key}" has params, but is missing a "values" argument`, - }); - return; - } - - if (values.type !== 'ObjectExpression') { - context.report({ - node: values, - message: `i18n() "values" argument must be an object literal`, - }); - return; - } - - const props = new Set(); - for (const property of values.properties) { - if (property.type !== 'Property' || property.computed) { - context.report({ - node: property, - message: `i18n() "values" argument must only contain literal keys`, - }); - return; - } - props.add(property.key.name); - } - - const missingParams = getMissingFromSet(params, props); - if (missingParams.size > 0) { - for (const param of missingParams) { - context.report({ - node: values, - message: `i18n() message "${key}" has a param "${param}", but no corresponding value`, - }); - } - return; - } - - const extraProps = getMissingFromSet(props, params); - if (extraProps.size > 0) { - for (const prop of extraProps) { - context.report({ - node: values, - message: `i18n() message "${key}" has a value "${prop}", but no corresponding param`, - }); - } - return; - } - }, - }; - }, -}; diff --git a/.eslint/rules/valid-i18n-keys.test.js b/.eslint/rules/valid-i18n-keys.test.js deleted file mode 100644 index c64176779f2..00000000000 --- a/.eslint/rules/valid-i18n-keys.test.js +++ /dev/null @@ -1,415 +0,0 @@ -// Copyright 2023 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -const rule = require('./valid-i18n-keys'); -const RuleTester = require('eslint').RuleTester; - -const messagesCacheKey = rule.messagesCacheKey; - -const __mockMessages__ = { - legacy_real_message: { - message: 'Legacy $message$', - }, - 'icu:real_message': { - messageformat: 'ICU {message}', - }, - 'icu:deleted_message': { - messageformat: 'shouldnt use me anymore', - description: '(deleted 01/01/1970)', - }, - 'icu:no_params': { - messageformat: 'ICU message', - }, - 'icu:nested': { - messageformat: '{one, select, other {{two, plural, other {{three}}}}}}', - }, - 'icu:emojify': { - messageformat: '👩', - }, -}; - -// Need to load so mocha doesn't complain about polluting the global namespace -require('@typescript-eslint/parser'); - -const ruleTester = new RuleTester({ - parser: require.resolve('@typescript-eslint/parser'), - parserOptions: { - ecmaVersion: 2018, - sourceType: 'module', - ecmaFeatures: { - jsx: true, - }, - }, -}); - -ruleTester.run('valid-i18n-keys', rule, { - valid: [ - { - code: `i18n("icu:real_message", { message: "foo" })`, - options: [{ messagesCacheKey, __mockMessages__ }], - }, - { - code: `window.i18n("icu:real_message", { message: "foo" })`, - options: [{ messagesCacheKey, __mockMessages__ }], - }, - { - code: `let jsx = `, - options: [{ messagesCacheKey, __mockMessages__ }], - }, - { - code: `i18n("icu:no_params")`, - options: [{ messagesCacheKey, __mockMessages__ }], - }, - { - code: `let jsx = `, - options: [{ messagesCacheKey, __mockMessages__ }], - }, - { - code: `i18n("icu:nested", { one: "1", two: "2", three: "3" })`, - options: [{ messagesCacheKey, __mockMessages__ }], - }, - { - code: `i18n("icu:emojify")`, - options: [{ messagesCacheKey, __mockMessages__ }], - }, - { - code: `let jsx = `, - options: [{ messagesCacheKey, __mockMessages__ }], - }, - ], - invalid: [ - { - code: `i18n("legacy_real_message")`, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - 'i18n() key "legacy_real_message" is not an ICU message in _locales/en/messages.json', - type: 'CallExpression', - }, - ], - }, - { - code: `window.i18n("legacy_real_message")`, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - 'i18n() key "legacy_real_message" is not an ICU message in _locales/en/messages.json', - type: 'CallExpression', - }, - ], - }, - { - code: `let jsx = `, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - ' id "legacy_real_message" is not an ICU message in _locales/en/messages.json', - type: 'JSXOpeningElement', - }, - ], - }, - { - code: 'i18n(`icu:real_${message}`)', - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: "i18n()'s first argument should always be a literal string", - type: 'CallExpression', - }, - ], - }, - { - code: 'window.i18n(`icu:real_${message}`)', - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: "i18n()'s first argument should always be a literal string", - type: 'CallExpression', - }, - ], - }, - { - code: `let jsx = `, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - " must always be provided an 'id' attribute with a literal string", - type: 'JSXOpeningElement', - }, - ], - }, - { - code: 'let jsx = ', - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - " must always be provided an 'id' attribute with a literal string", - type: 'JSXOpeningElement', - }, - ], - }, - { - code: 'let jsx = ', - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - " must always be provided an 'id' attribute with a literal string", - type: 'JSXOpeningElement', - }, - ], - }, - { - code: `i18n("THIS_KEY_SHOULD_NEVER_EXIST")`, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - 'i18n() key "THIS_KEY_SHOULD_NEVER_EXIST" not found in _locales/en/messages.json', - type: 'CallExpression', - }, - ], - }, - { - code: `i18n(cond ? "icu:real_message" : "icu:real_message")`, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: "i18n()'s first argument should always be a literal string", - type: 'CallExpression', - }, - ], - }, - { - code: `i18n(42)`, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: "i18n()'s first argument should always be a literal string", - type: 'CallExpression', - }, - ], - }, - { - code: `let jsx = `, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - ' id "THIS_KEY_SHOULD_NEVER_EXIST" not found in _locales/en/messages.json', - type: 'JSXOpeningElement', - }, - ], - }, - { - code: `let jsx = `, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - " must always be provided an 'id' attribute with a literal string", - type: 'JSXOpeningElement', - }, - ], - }, - { - code: `let jsx = `, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - " must always be provided an 'id' attribute with a literal string", - type: 'JSXOpeningElement', - }, - ], - }, - { - code: `i18n("icu:deleted_message")`, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - 'i18n() key "icu:deleted_message" is marked as deleted in _locales/en/messages.json', - type: 'CallExpression', - }, - ], - }, - { - code: `let jsx = `, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - ' id "icu:deleted_message" is marked as deleted in _locales/en/messages.json', - type: 'JSXOpeningElement', - }, - ], - }, - { - code: `i18n("icu:no_params", { message: "foo" })`, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - 'i18n() message "icu:no_params" does not have any params, but has a "values" argument', - type: 'CallExpression', - }, - ], - }, - { - code: `i18n("icu:real_message")`, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - 'i18n() message "icu:real_message" has params, but is missing a "values" argument', - type: 'CallExpression', - }, - ], - }, - { - code: `i18n("icu:real_message", null)`, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: 'i18n() "values" argument must be an object literal', - type: 'Literal', - }, - ], - }, - { - code: `i18n("icu:real_message", { [foo]: "foo" })`, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: 'i18n() "values" argument must only contain literal keys', - type: 'Property', - }, - ], - }, - { - code: `i18n("icu:real_message", { ...props })`, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: 'i18n() "values" argument must only contain literal keys', - type: 'SpreadElement', - }, - ], - }, - { - code: `i18n("icu:real_message", {})`, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - 'i18n() message "icu:real_message" has a param "message", but no corresponding value', - type: 'ObjectExpression', - }, - ], - }, - { - code: `i18n("icu:real_message", { message: "foo", foo: "bar" })`, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - 'i18n() message "icu:real_message" has a value "foo", but no corresponding param', - type: 'ObjectExpression', - }, - ], - }, - { - code: `let jsx = `, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - ' message "icu:no_params" does not have any params, but has a "components" attribute', - type: 'JSXOpeningElement', - }, - ], - }, - { - code: `let jsx = `, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - ' message "icu:real_message" has params, but is missing a "components" attribute', - type: 'JSXOpeningElement', - }, - ], - }, - { - code: `let jsx = `, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: ' "components" attribute must be an object literal', - type: 'Literal', - }, - ], - }, - { - code: `let jsx = `, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - ' "components" attribute must only contain literal keys', - type: 'Property', - }, - ], - }, - { - code: `let jsx = `, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - ' "components" attribute must only contain literal keys', - type: 'SpreadElement', - }, - ], - }, - { - code: `let jsx = `, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - ' message "icu:real_message" has a param "message", but no corresponding component', - type: 'ObjectExpression', - }, - ], - }, - { - code: `let jsx = `, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - ' message "icu:real_message" has a component "foo", but no corresponding param', - type: 'ObjectExpression', - }, - ], - }, - { - code: `i18n("icu:nested", { one: "1", two: "2" })`, - options: [{ messagesCacheKey, __mockMessages__ }], - errors: [ - { - message: - 'i18n() message "icu:nested" has a param "three", but no corresponding value', - type: 'ObjectExpression', - }, - ], - }, - ], -}); diff --git a/.eslintignore b/.eslintignore index 22a60fa7549..78947d7252e 100644 --- a/.eslintignore +++ b/.eslintignore @@ -15,6 +15,7 @@ libtextsecure/test/test.js test/test.js ts/protobuf/compiled.d.ts storybook-static/** +build/ICUMessageParams.d.ts # Third-party files js/Mp3LameEncoder.min.js diff --git a/.eslintrc.js b/.eslintrc.js index bd5a5d9798a..559b272c05f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,6 +1,5 @@ // Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -const { messagesCacheKey } = require('./.eslint/rules/valid-i18n-keys'); // For reference: https://github.com/airbnb/javascript @@ -245,8 +244,6 @@ const typescriptRules = { // TODO: DESKTOP-4655 'import/no-cycle': 'off', - - 'local-rules/valid-i18n-keys': ['error', { messagesCacheKey }], }; module.exports = { diff --git a/.gitignore b/.gitignore index 66f9af59523..63dad4b65a0 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ stylesheets/*.css preload.bundle.* bundles/ ts/sql/mainWorker.bundle.js.LICENSE.txt +build/ICUMessageParams.d.ts # React / TypeScript app/*.js diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 279e9eb06bc..0c00a68262d 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -9,6 +9,10 @@ Signal Desktop makes use of the following open source projects. License: MIT +## @formatjs/icu-messageformat-parser + + License: MIT + ## @formatjs/intl-localematcher License: MIT diff --git a/_locales/en/messages.json b/_locales/en/messages.json index c4fb10e3025..663bbe55e47 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -15,18 +15,6 @@ } ] }, - "icu:AddCaptionModal__title": { - "messageformat": "Add a message", - "description": "(Deleted 12/14/2023) Shown as the title of the dialog that allows you to add a caption to a story" - }, - "icu:AddCaptionModal__placeholder": { - "messageformat": "Message", - "description": "(Deleted 12/14/2023) Placeholder text for textarea when adding a caption/message (we don't know which yet so we default to message)" - }, - "icu:AddCaptionModal__submit-button": { - "messageformat": "Done", - "description": "(Deleted 12/14/2023) Label on the button that submits changes to a story's caption in the add-caption dialog" - }, "icu:AddUserToAnotherGroupModal__title": { "messageformat": "Add to a group", "description": "Shown as the title of the dialog that allows you to add a contact to an group" @@ -758,26 +746,6 @@ "messageformat": "Accept", "description": "Label for a button to accept a new safety number" }, - "icu:SafetyNumberViewer__migration__text": { - "messageformat": "Safety numbers are being updated.", - "description": "(Deleted 11/01/2023) An explanatory note in SafetyNumberViewer describing the safety number migration process." - }, - "icu:SafetyNumberViewer__migration__learn_more": { - "messageformat": "Learn more", - "description": "(Deleted 11/01/2023) A link text in SafetyNumberViewer describing the safety number migration process." - }, - "icu:SafetyNumberViewer__card__prev": { - "messageformat": "Previous Safety number", - "description": "(Deleted 11/01/2023) An ARIA label for safety number navigation button." - }, - "icu:SafetyNumberViewer__card__next": { - "messageformat": "Next Safety number", - "description": "(Deleted 11/01/2023) An ARIA label for safety number navigation button." - }, - "icu:SafetyNumberViewer__carousel__dot": { - "messageformat": "Safety number version, {index, number} of {total, number}", - "description": "(Deleted 11/01/2023) An ARIA label for safety number carousel button." - }, "icu:SafetyNumberViewer__markAsVerified": { "messageformat": "Mark as verified", "description": "Safety number viewer, verification toggle button, when not verified, sets verified" @@ -794,34 +762,6 @@ "messageformat": "Learn more", "description": "Text of 'Learn more' button of SafetyNumberViewerModal modal" }, - "icu:SafetyNumberViewer__hint--migration": { - "messageformat": "To verify end-to-end encryption with {name}, match the color card above with their device and compare the numbers. If these don’t match, try the other pair of safety numbers. Only one pair needs to match.", - "description": "(Deleted 11/01/2023). Safety number viewer, text of the hint during migration period" - }, - "icu:SafetyNumberViewer__hint--normal": { - "messageformat": "To verify end-to-end encryption with {name}, compare the numbers above with their device. They can also scan your code with their device.", - "description": "(Deleted 11/01/2023). Safety number viewer, text of the hint after migration period" - }, - "icu:SafetyNumberOnboarding__title": { - "messageformat": "Changes to safety numbers", - "description": "(Deleted 11/01/2023) Title of Safety number onboarding modal" - }, - "icu:SafetyNumberOnboarding__p1": { - "messageformat": "Safety numbers are being updated over a transition period to enable upcoming privacy features in Signal.", - "description": "(Deleted 11/01/2023) Paragraph 1 of Safety number onboarding modal" - }, - "icu:SafetyNumberOnboarding__p2": { - "messageformat": "To verify safety numbers, match the color card with your contact’s device. If these don’t match, try the other pair of safety numbers. Only one pair needs to match.", - "description": "(Deleted 11/01/2023) Paragraph 2 of Safety number onboarding modal" - }, - "icu:SafetyNumberOnboarding__help": { - "messageformat": "Need help?", - "description": "(Deleted 11/01/2023) Text of a secondary button in Safety number onboarding modal" - }, - "icu:SafetyNumberOnboarding__close": { - "messageformat": "Got it", - "description": "(Deleted 11/01/2023) Text of a secondary button in Safety number onboarding modal" - }, "icu:SafetyNumberNotReady__body": { "messageformat": "A safety number will be created with this person after you exchange messages with them.", "description": "Body of SafetyNumberNotReady modal" @@ -923,10 +863,6 @@ "messageformat": "Draft image attachment: {path}", "description": "Alt text for staged attachments" }, - "icu:cdsMirroringErrorToast": { - "messageformat": "Desktop ran into a Contact Discovery Service inconsistency.", - "description": "(Deleted 2024/01/22) An error popup when we discovered an inconsistency between mirrored Contact Discovery Service requests." - }, "icu:decryptionErrorToast": { "messageformat": "Desktop ran into a decryption error from {name}, device {deviceId}", "description": "An error popup when we haven't added an in-timeline error for decryption error, only for beta/internal users." @@ -1974,10 +1910,14 @@ }, "icu:calling__in-this-call--one": { "messageformat": "In this call · 1 person", - "description": "Shown in the participants list to describe how many people are in the call" + "description": "(Deleted 2024/02/29) Shown in the participants list to describe how many people are in the call" }, "icu:calling__in-this-call--many": { "messageformat": "In this call · {people} people", + "description": "(Deleted 2024/02/29) Shown in the participants list to describe how many people are in the call" + }, + "icu:calling__in-this-call": { + "messageformat": "In this call · {people, plural, one {# person} other {# people}} people", "description": "Shown in the participants list to describe how many people are in the call" }, "icu:calling__you-have-blocked": { @@ -3785,6 +3725,10 @@ }, "icu:calling__participants": { "messageformat": "{people} in call", + "description": "(Deleted 2024/02/29) Title for participants list toggle" + }, + "icu:calling__participants--pluralized": { + "messageformat": "{people, plural, one {#} other {#}} in call", "description": "Title for participants list toggle" }, "icu:calling__call-notification__ended": { @@ -5335,10 +5279,6 @@ "messageformat": "{count, plural, one {# name conflict was} other {# name conflicts were}} found in this group. Review the members below or choose to take action.", "description": "Description for the group contact spoofing review dialog when there are multiple shared names" }, - "icu:ContactSpoofingReviewDialog__group__members-header": { - "messageformat": "Members", - "description": "(Deleted 01/31/2024) Header in the group contact spoofing review dialog. After this header, there will be a list of members" - }, "icu:ContactSpoofingReviewDialog__group__members__no-shared-groups": { "messageformat": "No other groups in common", "description": "Informational text displayed next to a contact on ContactSpoofingReviewDialog" @@ -7123,22 +7063,6 @@ "messageformat": "Usernames have a unique QR code and link you can share with friends to quickly start a chat with you.", "description": "Body of the third row of username onboarding modal" }, - "icu:UsernameOnboardingModalBody__row__number": { - "messageformat": "Usernames are paired with a set of digits and aren’t shared on your profile", - "description": "(Deleted 01/16/2023) Content of the first row of username onboarding modal" - }, - "icu:UsernameOnboardingModalBody__row__link": { - "messageformat": "Each username has a unique QR code and link you can share with friends to start a chat with you", - "description": "(Deleted 01/16/2023) Content of the second row of username onboarding modal" - }, - "icu:UsernameOnboardingModalBody__row__lock": { - "messageformat": "Turn off phone number discovery under Settings > Privacy > Phone Number > Who can find my number, to use your username as the primary way others can contact you.", - "description": "(Deleted 01/16/2023) Content of the third row of username onboarding modal" - }, - "icu:UsernameOnboardingModalBody__learn-more": { - "messageformat": "Learn More", - "description": "(Deleted 01/16/2023) Text that open a popup with information about username onboarding" - }, "icu:UsernameOnboardingModalBody__continue": { "messageformat": "Set up username", "description": "Text of the primary button on username onboarding modal" diff --git a/eslint-local-rules.js b/eslint-local-rules.js index 619440009a5..1259cee9a2b 100644 --- a/eslint-local-rules.js +++ b/eslint-local-rules.js @@ -3,6 +3,5 @@ /* eslint-disable global-require */ module.exports = { - 'valid-i18n-keys': require('./.eslint/rules/valid-i18n-keys'), 'type-alias-readonlydeep': require('./.eslint/rules/type-alias-readonlydeep'), }; diff --git a/package.json b/package.json index 3789b325663..3db6cb397bf 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "postinstall": "yarn build:acknowledgments && patch-package && yarn electron:install-app-deps", "postuninstall": "yarn build:acknowledgments", "start": "electron .", - "generate": "npm-run-all build-protobuf build:esbuild build:dns-fallback sass get-expire-time copy-components", + "generate": "npm-run-all build-protobuf build:esbuild build:dns-fallback build:icu-types sass get-expire-time copy-components", "build-release": "yarn run build", "sign-release": "node ts/updater/generateSignature.js", "notarize": "echo 'No longer necessary'", @@ -76,6 +76,7 @@ "build-linux": "yarn generate && yarn build:esbuild:prod && yarn build:release -- --publish=never", "build:acknowledgments": "node scripts/generate-acknowledgments.js", "build:dns-fallback": "node ts/scripts/generate-dns-fallback.js", + "build:icu-types": "node ts/scripts/generate-icu-types.js", "build:dev": "run-s --print-label generate build:esbuild:prod", "build:esbuild": "node scripts/esbuild.js", "build:esbuild:prod": "node scripts/esbuild.js --prod", @@ -90,6 +91,7 @@ }, "dependencies": { "@formatjs/fast-memoize": "1.2.6", + "@formatjs/icu-messageformat-parser": "2.3.0", "@formatjs/intl-localematcher": "0.2.32", "@indutny/sneequals": "4.0.0", "@nodert-win10-rs4/windows.data.xml.dom": "0.4.4", diff --git a/ts/components/CallParticipantCount.tsx b/ts/components/CallParticipantCount.tsx index 21440f38744..74016d59390 100644 --- a/ts/components/CallParticipantCount.tsx +++ b/ts/components/CallParticipantCount.tsx @@ -45,7 +45,7 @@ export function CallParticipantCount({ if (!isToggleVisible) { return (
- {!participants.length && i18n('icu:calling__in-this-call--zero')} - {participants.length === 1 && - i18n('icu:calling__in-this-call--one')} - {participants.length > 1 && - i18n('icu:calling__in-this-call--many', { - people: participants.length, - })} + {participants.length + ? i18n('icu:calling__in-this-call', { + people: participants.length, + }) + : i18n('icu:calling__in-this-call--zero')}
- ), + args: { + i18n, + id: 'icu:ok', + components: undefined, }, -}); +} satisfies ComponentMeta>; -export const MultipleStringReplacement = Template.bind({}); -MultipleStringReplacement.args = createProps({ - id: 'icu:changedRightAfterVerify', - components: { - name1: 'Fred', - name2: 'The Fredster', - }, -}); - -export const MultipleTagReplacement = Template.bind({}); -MultipleTagReplacement.args = createProps({ - id: 'icu:changedRightAfterVerify', - components: { - name1: Fred, - name2: The Fredster, - }, -}); - -export function Emoji(): JSX.Element { - const customI18n = setupI18n('en', { - 'icu:emoji': { - messageformat: '👋 Hello, world!', - }, - }); +export function NoReplacements( + args: Props<'icu:deleteAndRestart'> +): JSX.Element { + return ; +} +export function SingleStringReplacement( + args: Props<'icu:leftTheGroup'> +): JSX.Element { return ( - // eslint-disable-next-line local-rules/valid-i18n-keys - + + ); +} + +export function SingleTagReplacement( + args: Props<'icu:leftTheGroup'> +): JSX.Element { + return ( + + Theodora + + ), + }} + /> + ); +} + +export function MultipleStringReplacement( + args: Props<'icu:changedRightAfterVerify'> +): JSX.Element { + return ( + + ); +} + +export function MultipleTagReplacement( + args: Props<'icu:changedRightAfterVerify'> +): JSX.Element { + return ( + Fred, name2: The Fredster }} + /> + ); +} + +export function Emoji( + args: Props<'icu:Message__reaction-emoji-label--you'> +): JSX.Element { + return ( + ); } diff --git a/ts/components/Intl.tsx b/ts/components/Intl.tsx index 4ad2796dd8b..3d3ca8b1a12 100644 --- a/ts/components/Intl.tsx +++ b/ts/components/Intl.tsx @@ -2,34 +2,31 @@ // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; -import type { ReactNode } from 'react'; -import type { FormatXMLElementFn } from 'intl-messageformat'; -import type { LocalizerType } from '../types/Util'; -import type { ReplacementValuesType } from '../types/I18N'; +import type { + LocalizerType, + ICUJSXMessageParamsByKeyType, +} from '../types/Util'; import * as log from '../logging/log'; -export type FullJSXType = - | FormatXMLElementFn - | Array - | ReactNode - | JSX.Element - | string; -export type IntlComponentsType = undefined | ReplacementValuesType; - -export type Props = { +export type Props = { /** The translation string id */ - id: string; + id: Key; i18n: LocalizerType; - components?: IntlComponentsType; -}; +} & (ICUJSXMessageParamsByKeyType[Key] extends undefined + ? { + components?: ICUJSXMessageParamsByKeyType[Key]; + } + : { + components: ICUJSXMessageParamsByKeyType[Key]; + }); -export function Intl({ +export function Intl({ components, id, // Indirection for linter/migration tooling i18n: localizer, -}: Props): JSX.Element | null { +}: Props): JSX.Element | null { if (!id) { log.error('Error: Intl id prop not provided'); return null; diff --git a/ts/components/ProfileEditor.tsx b/ts/components/ProfileEditor.tsx index ccd1a070878..a6835acdbf2 100644 --- a/ts/components/ProfileEditor.tsx +++ b/ts/components/ProfileEditor.tsx @@ -764,7 +764,7 @@ export function ProfileEditor({ ]} > {i18n('icu:ProfileEditor--username--confirm-delete-body-2', { - username, + username: username ?? '', })} )} diff --git a/ts/components/SendStoryModal.tsx b/ts/components/SendStoryModal.tsx index 4ca4fbcaa4c..beed425dd97 100644 --- a/ts/components/SendStoryModal.tsx +++ b/ts/components/SendStoryModal.tsx @@ -580,7 +580,7 @@ export function SendStoryModal({
{i18n('icu:ConversationHero--members', { - count: group.membersCount, + count: group.membersCount ?? 0, })}
@@ -853,7 +853,7 @@ export function SendStoryModal({
{i18n('icu:ConversationHero--members', { - count: group.membersCount, + count: group.membersCount ?? 0, })} diff --git a/ts/components/StoriesSettingsModal.tsx b/ts/components/StoriesSettingsModal.tsx index f45422140b0..70ebd06ceb6 100644 --- a/ts/components/StoriesSettingsModal.tsx +++ b/ts/components/StoriesSettingsModal.tsx @@ -231,7 +231,7 @@ function GroupStoryItem({ {i18n('icu:StoriesSettings__group-story-subtitle')}  ·  {i18n('icu:StoriesSettings__viewers', { - count: groupStory.membersCount, + count: groupStory.membersCount ?? 0, })} diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx index 04e1fe9c395..a358564bf67 100644 --- a/ts/components/ToastManager.tsx +++ b/ts/components/ToastManager.tsx @@ -325,7 +325,7 @@ export function renderToast({ > {i18n('icu:decryptionErrorToast', { name, - deviceId, + deviceId: String(deviceId), })} ); diff --git a/ts/components/UnsupportedOSDialog.tsx b/ts/components/UnsupportedOSDialog.tsx index 86af5c019f5..dec86f137ae 100644 --- a/ts/components/UnsupportedOSDialog.tsx +++ b/ts/components/UnsupportedOSDialog.tsx @@ -3,7 +3,6 @@ import React from 'react'; import moment from 'moment'; -import type { FormatXMLElementFn } from 'intl-messageformat'; import type { LocalizerType } from '../types/Util'; import { UNSUPPORTED_OS_URL } from '../types/support'; @@ -28,14 +27,14 @@ export function UnsupportedOSDialog({ type, OS, }: PropsType): JSX.Element | null { - const learnMoreLink: FormatXMLElementFn = children => ( + const learnMoreLink = (parts: Array) => ( - {children} + {parts} ); diff --git a/ts/components/conversation/GroupNotification.tsx b/ts/components/conversation/GroupNotification.tsx index 4ca7d9996aa..aa4b6a3c165 100644 --- a/ts/components/conversation/GroupNotification.tsx +++ b/ts/components/conversation/GroupNotification.tsx @@ -90,7 +90,7 @@ function GroupNotificationChange({ ) : ( ) : ( { + learnMoreLink: parts => { return ( = ( +const renderContact: SmartContactRendererType = ( conversationId: string ) => ( diff --git a/ts/components/conversation/GroupV2Change.tsx b/ts/components/conversation/GroupV2Change.tsx index 19080f60e6d..0bb96e7aca3 100644 --- a/ts/components/conversation/GroupV2Change.tsx +++ b/ts/components/conversation/GroupV2Change.tsx @@ -6,10 +6,11 @@ import React, { useState } from 'react'; import { get } from 'lodash'; import * as log from '../../logging/log'; -import type { ReplacementValuesType } from '../../types/I18N'; -import type { FullJSXType } from '../Intl'; import { Intl } from '../Intl'; -import type { LocalizerType } from '../../types/Util'; +import type { + LocalizerType, + ICUJSXMessageParamsByKeyType, +} from '../../types/Util'; import type { AciString, PniString, @@ -49,19 +50,18 @@ export type PropsActionsType = { export type PropsHousekeepingType = { i18n: LocalizerType; - renderContact: SmartContactRendererType; + renderContact: SmartContactRendererType; }; export type PropsType = PropsDataType & PropsActionsType & PropsHousekeepingType; -function renderStringToIntl( - id: string, +function renderStringToIntl( + id: Key, i18n: LocalizerType, - components?: ReplacementValuesType -): FullJSXType { - // eslint-disable-next-line local-rules/valid-i18n-keys + components: ICUJSXMessageParamsByKeyType[Key] +): JSX.Element { return ; } @@ -168,8 +168,8 @@ function GroupV2Detail({ i18n: LocalizerType; fromId?: ServiceIdString; ourAci: AciString | undefined; - renderContact: SmartContactRendererType; - text: FullJSXType; + renderContact: SmartContactRendererType; + text: ReactNode; }): JSX.Element { const icon = getIcon(detail, isLastText, fromId); let buttonNode: ReactNode; @@ -305,12 +305,12 @@ export function GroupV2Change(props: PropsType): ReactElement { return ( <> - {renderChange(change, { + {renderChange(change, { i18n, ourAci, ourPni, renderContact, - renderString: renderStringToIntl, + renderIntl: renderStringToIntl, }).map(({ detail, isLastText, text }, index) => { return (
*AudioAttachment*
} - renderContact={() => '*ContactName*'} + renderContact={() =>
*ContactName*
} renderEmojiPicker={() =>
} renderReactionPicker={() =>
} renderUniversalTimerNotification={() => ( diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index d0717eefcc1..b9bd6e03a84 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -18,7 +18,6 @@ import { clearTimeoutIfNecessary } from '../../util/clearTimeoutIfNecessary'; import { WidthBreakpoint } from '../_util'; import { ErrorBoundary } from './ErrorBoundary'; -import type { FullJSXType } from '../Intl'; import { Intl } from '../Intl'; import { TimelineWarning } from './TimelineWarning'; import { TimelineWarnings } from './TimelineWarnings'; @@ -980,7 +979,9 @@ export class Timeline extends React.Component< case ContactSpoofingType.MultipleGroupMembersWithSameTitle: { const { groupNameCollisions } = warning; const numberOfSharedNames = Object.keys(groupNameCollisions).length; - const reviewRequestLink: FullJSXType = parts => ( + const reviewRequestLink = ( + parts: Array + ): JSX.Element => ( {parts} diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index a62441c9332..68944350493 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -53,7 +53,6 @@ import { ConversationMergeNotification } from './ConversationMergeNotification'; import type { PropsDataType as PhoneNumberDiscoveryNotificationPropsType } from './PhoneNumberDiscoveryNotification'; import { PhoneNumberDiscoveryNotification } from './PhoneNumberDiscoveryNotification'; import { SystemMessage } from './SystemMessage'; -import type { FullJSXType } from '../Intl'; import { TimelineMessage } from './TimelineMessage'; type CallHistoryType = { @@ -165,7 +164,7 @@ type PropsLocalType = { targetMessage: (messageId: string, conversationId: string) => unknown; shouldRenderDateHeader: boolean; platform: string; - renderContact: SmartContactRendererType; + renderContact: SmartContactRendererType; renderUniversalTimerNotification: () => JSX.Element; i18n: LocalizerType; interactionMode: InteractionModeType; diff --git a/ts/components/conversationList/MessageSearchResult.tsx b/ts/components/conversationList/MessageSearchResult.tsx index 13a346cd3fc..dd508a84a90 100644 --- a/ts/components/conversationList/MessageSearchResult.tsx +++ b/ts/components/conversationList/MessageSearchResult.tsx @@ -72,8 +72,12 @@ const renderPerson = ( isMe?: boolean; title: string; }> -): ReactNode => - person.isMe ? i18n('icu:you') : ; +): JSX.Element => + person.isMe ? ( + + ) : ( + + ); export const MessageSearchResult: FunctionComponent = React.memo( function MessageSearchResult({ diff --git a/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.tsx b/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.tsx index ee60864839a..0433249c05b 100644 --- a/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.tsx +++ b/ts/components/installScreen/InstallScreenQrCodeNotScannedStep.tsx @@ -135,7 +135,7 @@ function InstallScreenQrCode( id="icu:Install__qr-failed-load" components={{ // eslint-disable-next-line react/no-unstable-nested-components - retry: children => ( + retry: (parts: Array) => ( ), }} diff --git a/ts/components/installScreen/InstallScreenUpdateDialog.tsx b/ts/components/installScreen/InstallScreenUpdateDialog.tsx index 66f975e8f00..7abe583316f 100644 --- a/ts/components/installScreen/InstallScreenUpdateDialog.tsx +++ b/ts/components/installScreen/InstallScreenUpdateDialog.tsx @@ -3,7 +3,6 @@ import React from 'react'; import { noop } from 'lodash'; -import type { FormatXMLElementFn } from 'intl-messageformat'; import formatFileSize from 'filesize'; import { DialogType } from '../../types/Dialogs'; @@ -36,14 +35,14 @@ export function InstallScreenUpdateDialog({ currentVersion, OS, }: PropsType): JSX.Element | null { - const learnMoreLink: FormatXMLElementFn = children => ( + const learnMoreLink = (parts: Array) => ( - {children} + {parts} ); diff --git a/ts/groupChange.ts b/ts/groupChange.ts index 9ecf6db051c..fa7782766b0 100644 --- a/ts/groupChange.ts +++ b/ts/groupChange.ts @@ -1,8 +1,11 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { LocalizerType } from './types/Util'; -import type { ReplacementValuesType } from './types/I18N'; +import type { + LocalizerType, + ICUStringMessageParamsByKeyType, + ICUJSXMessageParamsByKeyType, +} from './types/Util'; import type { ServiceIdString, AciString, PniString } from './types/ServiceId'; import { missingCaseError } from './util/missingCaseError'; @@ -10,40 +13,49 @@ import type { GroupV2ChangeDetailType, GroupV2ChangeType } from './groups'; import { SignalService as Proto } from './protobuf'; import * as log from './logging/log'; -export type SmartContactRendererType = ( - serviceId: ServiceIdString -) => T | string; -export type StringRendererType = ( - id: string, - i18n: LocalizerType, - components?: ReplacementValuesType -) => T | string; +type SelectParamsByKeyType = T extends string + ? ICUStringMessageParamsByKeyType + : ICUJSXMessageParamsByKeyType; -export type RenderOptionsType = { +export type SmartContactRendererType = ( + serviceId: ServiceIdString +) => T extends string ? string : JSX.Element; + +type StringRendererType< + T extends string | JSX.Element, + ParamsByKeyType extends SelectParamsByKeyType = SelectParamsByKeyType +> = ( + id: Key, + i18n: LocalizerType, + components: ParamsByKeyType[Key] +) => T; + +export type RenderOptionsType = { // `from` will be a PNI when the change is "declining a PNI invite". from?: ServiceIdString; i18n: LocalizerType; ourAci: AciString | undefined; ourPni: PniString | undefined; renderContact: SmartContactRendererType; - renderString: StringRendererType; + renderIntl: StringRendererType; }; const AccessControlEnum = Proto.AccessControl.AccessRequired; const RoleEnum = Proto.Member.Role; -export type RenderChangeResultType = ReadonlyArray< - Readonly<{ - detail: GroupV2ChangeDetailType; - text: T | string; +export type RenderChangeResultType = + ReadonlyArray< + Readonly<{ + detail: GroupV2ChangeDetailType; + text: T extends string ? string : JSX.Element; - // Used to differentiate between the multiple texts produced by - // 'admin-approval-bounce' - isLastText: boolean; - }> ->; + // Used to differentiate between the multiple texts produced by + // 'admin-approval-bounce' + isLastText: boolean; + }> + >; -export function renderChange( +export function renderChange( change: GroupV2ChangeType, options: RenderOptionsType ): RenderChangeResultType { @@ -66,25 +78,32 @@ export function renderChange( }); } -export function renderChangeDetail( +function renderChangeDetail( detail: GroupV2ChangeDetailType, options: RenderOptionsType -): T | string | ReadonlyArray { +): string | T | ReadonlyArray { const { from, i18n: localizer, ourAci, ourPni, renderContact, - renderString, + renderIntl, } = options; - function i18n( - id: string, - components?: ReplacementValuesType - ) { - return renderString(id, localizer, components); - } + type JSXLocalizerType = ( + key: Key, + ...values: ICUJSXMessageParamsByKeyType[Key] extends undefined + ? [undefined?] + : [ICUJSXMessageParamsByKeyType[Key]] + ) => string; + + const i18n = (>( + id: Key, + components: SelectParamsByKeyType[Key] + ): T => { + return renderIntl(id, localizer, components); + }) as JSXLocalizerType; const isOurServiceId = (serviceId?: ServiceIdString): boolean => { if (!serviceId) { diff --git a/ts/scripts/generate-icu-types.ts b/ts/scripts/generate-icu-types.ts new file mode 100644 index 00000000000..4feba64cfc2 --- /dev/null +++ b/ts/scripts/generate-icu-types.ts @@ -0,0 +1,250 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import fs from 'fs/promises'; +import path from 'path'; +import ts from 'typescript'; +import prettier from 'prettier'; + +import { getICUMessageParams } from '../util/getICUMessageParams'; +import type { ICUMessageParamType } from '../util/getICUMessageParams'; +import { missingCaseError } from '../util/missingCaseError'; +import globalMessages from '../../_locales/en/messages.json'; + +import { DELETED_REGEXP } from './remove-strings'; + +function translateParamType( + param: ICUMessageParamType, + stringType: ts.TypeNode, + componentType: ts.TypeNode +): ts.TypeNode { + switch (param.type) { + case 'string': + return stringType; + case 'number': + return ts.factory.createToken(ts.SyntaxKind.NumberKeyword); + case 'date': + case 'time': + return ts.factory.createTypeReferenceNode('Date'); + case 'jsx': + return componentType; + case 'select': + return ts.factory.createUnionTypeNode( + param.validOptions.map(option => { + if (option === 'other') { + return stringType; + } + + return ts.factory.createLiteralTypeNode( + ts.factory.createStringLiteral(option, true) + ); + }) + ); + default: + throw missingCaseError(param); + } +} + +const messageKeys = Object.keys(globalMessages).sort((a, b) => { + return a.localeCompare(b); +}) as Array; + +function generateType( + name: string, + stringType: ts.TypeNode, + componentType: ts.TypeNode +): ts.Statement { + const props = new Array(); + for (const key of messageKeys) { + if (key === 'smartling') { + continue; + } + + const message = globalMessages[key]; + + // Skip deleted strings + if ('description' in message && DELETED_REGEXP.test(message.description)) { + continue; + } + + const { messageformat } = message; + + const params = getICUMessageParams(messageformat); + + let paramType: ts.TypeNode; + if (params.size === 0) { + paramType = ts.factory.createToken(ts.SyntaxKind.UndefinedKeyword); + } else { + const subTypes = new Array(); + + for (const [paramName, value] of params) { + subTypes.push( + ts.factory.createPropertySignature( + undefined, + ts.factory.createStringLiteral(paramName, true), + undefined, + translateParamType(value, stringType, componentType) + ) + ); + } + + paramType = ts.factory.createTypeLiteralNode(subTypes); + } + + props.push( + ts.factory.createPropertySignature( + undefined, + ts.factory.createStringLiteral(key, true), + undefined, + paramType + ) + ); + } + + return ts.factory.createTypeAliasDeclaration( + [ts.factory.createToken(ts.SyntaxKind.ExportKeyword)], + name, + undefined, + ts.factory.createTypeLiteralNode(props) + ); +} + +const statements = new Array(); + +let top = ts.factory.createImportDeclaration( + undefined, + ts.factory.createImportClause( + true, + undefined, + ts.factory.createNamedImports([ + ts.factory.createImportSpecifier( + false, + undefined, + ts.factory.createIdentifier('ReactNode') + ), + ]) + ), + ts.factory.createStringLiteral('react') +); + +top = ts.addSyntheticLeadingComment( + top, + ts.SyntaxKind.SingleLineCommentTrivia, + ` Copyright ${new Date().getFullYear()} Signal Messenger, LLC` +); + +top = ts.addSyntheticLeadingComment( + top, + ts.SyntaxKind.SingleLineCommentTrivia, + ' SPDX-License-Identifier: AGPL-3.0-only' +); + +statements.push(top); + +const JSXElement = ts.factory.createTypeReferenceNode( + ts.factory.createQualifiedName(ts.factory.createIdentifier('JSX'), 'Element') +); + +statements.push( + ts.factory.createTypeAliasDeclaration( + undefined, + 'Component', + undefined, + ts.factory.createUnionTypeNode([ + JSXElement, + ts.factory.createFunctionTypeNode( + undefined, + [ + ts.factory.createParameterDeclaration( + undefined, + undefined, + 'parts', + undefined, + ts.factory.createTypeReferenceNode('Array', [ + ts.factory.createUnionTypeNode([ + ts.factory.createToken(ts.SyntaxKind.StringKeyword), + JSXElement, + ]), + ]) + ), + ], + JSXElement + ), + ]) + ) +); + +statements.push( + ts.factory.createTypeAliasDeclaration( + undefined, + 'ComponentOrString', + undefined, + ts.factory.createUnionTypeNode([ + ts.factory.createToken(ts.SyntaxKind.StringKeyword), + ts.factory.createTypeReferenceNode('ReadonlyArray', [ + ts.factory.createUnionTypeNode([ + ts.factory.createToken(ts.SyntaxKind.StringKeyword), + JSXElement, + ]), + ]), + ts.factory.createTypeReferenceNode('Component'), + ]) + ) +); + +statements.push( + generateType( + 'ICUJSXMessageParamsByKeyType', + ts.factory.createTypeReferenceNode('ComponentOrString'), + ts.factory.createTypeReferenceNode('Component') + ) +); + +statements.push( + generateType( + 'ICUStringMessageParamsByKeyType', + ts.factory.createToken(ts.SyntaxKind.StringKeyword), + ts.factory.createToken(ts.SyntaxKind.NeverKeyword) + ) +); + +const root = ts.factory.createSourceFile( + statements, + ts.factory.createToken(ts.SyntaxKind.EndOfFileToken), + ts.NodeFlags.None +); + +const resultFile = ts.createSourceFile( + 'icuTypes.d.ts', + '', + ts.ScriptTarget.Latest, + false, + ts.ScriptKind.TS +); +const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed }); +const unformattedOutput = printer.printNode( + ts.EmitHint.Unspecified, + root, + resultFile +); + +async function main() { + const destinationPath = path.join( + __dirname, + '..', + '..', + 'build', + 'ICUMessageParams.d.ts' + ); + const prettierConfig = await prettier.resolveConfig(destinationPath); + const output = prettier.format(unformattedOutput, { + ...prettierConfig, + filepath: destinationPath, + }); + + await fs.writeFile(destinationPath, output); +} +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/ts/scripts/remove-strings.ts b/ts/scripts/remove-strings.ts index 807da0adee0..734ebe2e67e 100644 --- a/ts/scripts/remove-strings.ts +++ b/ts/scripts/remove-strings.ts @@ -15,6 +15,8 @@ const MESSAGES_FILE = path.join(ROOT_DIR, '_locales', 'en', 'messages.json'); const limitter = pLimit(10); +export const DELETED_REGEXP = /\(\s*deleted\s+(\d{2,4}\/\d{2}\/\d{2,4})\s*\)/i; + async function main() { const messages = JSON.parse(await fs.readFile(MESSAGES_FILE, 'utf-8')); @@ -26,7 +28,7 @@ async function main() { const value = messages[key]; const match = (value as Record).description?.match( - /\(\s*deleted\s+(\d{2,4}\/\d{2}\/\d{2,4})\s*\)/ + DELETED_REGEXP ); if (!match) { return; diff --git a/ts/test-both/types/setupI18n_test.ts b/ts/test-both/types/setupI18n_test.ts index 45ee544c04a..ad762bbc95b 100644 --- a/ts/test-both/types/setupI18n_test.ts +++ b/ts/test-both/types/setupI18n_test.ts @@ -14,12 +14,6 @@ describe('setupI18n', () => { }); describe('i18n', () => { - it('throws an error for unknown string', () => { - assert.throws(() => { - // eslint-disable-next-line local-rules/valid-i18n-keys - assert.strictEqual(i18n('icu:random'), ''); - }, /missing translation/); - }); it('returns message for given string', () => { assert.strictEqual(i18n('icu:reportIssue'), 'Contact Support'); }); diff --git a/ts/types/I18N.ts b/ts/types/I18N.ts index 225483657b0..abcc6737832 100644 --- a/ts/types/I18N.ts +++ b/ts/types/I18N.ts @@ -28,10 +28,6 @@ export type LocaleMessagesType = { [key: string]: LocaleMessageType | SmartlingConfigType; }; -export type ReplacementValuesType = { - [key: string]: T; -}; - export type LocaleType = { i18n: LocalizerType; messages: LocaleMessagesType; diff --git a/ts/types/Util.ts b/ts/types/Util.ts index ecc7a8c72d9..a5132b44272 100644 --- a/ts/types/Util.ts +++ b/ts/types/Util.ts @@ -4,6 +4,10 @@ import type { IntlShape } from 'react-intl'; import type { AciString } from './ServiceId'; import type { LocaleDirection } from '../../app/locale'; +import type { + ICUJSXMessageParamsByKeyType, + ICUStringMessageParamsByKeyType, +} from '../../build/ICUMessageParams.d'; import type { HourCyclePreference, LocaleMessagesType } from './I18N'; @@ -17,12 +21,15 @@ export type RenderTextCallbackType = (options: { key: number; }) => JSX.Element | string; -export type ReplacementValuesType = { - [key: string]: string | number | undefined; -}; +export { ICUJSXMessageParamsByKeyType, ICUStringMessageParamsByKeyType }; export type LocalizerType = { - (key: string, values?: ReplacementValuesType): string; + ( + key: Key, + ...values: ICUStringMessageParamsByKeyType[Key] extends undefined + ? [undefined?] + : [ICUStringMessageParamsByKeyType[Key]] + ): string; getIntl(): IntlShape; getLocale(): string; getLocaleMessages(): LocaleMessagesType; diff --git a/ts/util/getICUMessageParams.ts b/ts/util/getICUMessageParams.ts new file mode 100644 index 00000000000..fe2b60a5d80 --- /dev/null +++ b/ts/util/getICUMessageParams.ts @@ -0,0 +1,83 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { TYPE, parse } from '@formatjs/icu-messageformat-parser'; +import type { + MessageFormatElement, + PluralOrSelectOption, +} from '@formatjs/icu-messageformat-parser'; +import { missingCaseError } from './missingCaseError'; + +export type ICUMessageParamType = Readonly< + | { + type: 'string' | 'date' | 'number' | 'jsx' | 'time'; + } + | { + type: 'select'; + validOptions: ReadonlyArray; + } +>; + +export function getICUMessageParams( + message: string, + defaultRichTextElementNames: Array = [] +): Map { + const params = new Map(); + + function visitOptions(options: Record) { + for (const option of Object.values(options)) { + visit(option.value); + } + } + + function visit(elements: ReadonlyArray) { + for (const element of elements) { + switch (element.type) { + case TYPE.argument: + params.set(element.value, { type: 'string' }); + break; + case TYPE.date: + params.set(element.value, { type: 'Date' }); + break; + case TYPE.literal: + break; + case TYPE.number: + params.set(element.value, { type: 'number' }); + break; + case TYPE.plural: + params.set(element.value, { type: 'number' }); + visitOptions(element.options); + break; + case TYPE.pound: + break; + case TYPE.select: { + const validOptions = Object.entries(element.options) + // We use empty {other ...} to satisfy smartling, but don't allow + // it in the app. + .filter(([key, { value }]) => key !== 'other' || value.length) + .map(([key]) => key); + params.set(element.value, { type: 'select', validOptions }); + visitOptions(element.options); + break; + } + case TYPE.tag: + params.set(element.value, { type: 'jsx' }); + visit(element.children); + break; + case TYPE.time: + params.set(element.value, { type: 'time' }); + break; + default: + throw missingCaseError(element); + } + } + } + + visit(parse(message)); + + for (const defaultRichTextElementName of defaultRichTextElementNames) { + params.delete(defaultRichTextElementName); + } + + return params; +} diff --git a/ts/util/getNotificationDataForMessage.ts b/ts/util/getNotificationDataForMessage.ts index 562dc0e928e..da651dc99b8 100644 --- a/ts/util/getNotificationDataForMessage.ts +++ b/ts/util/getNotificationDataForMessage.ts @@ -3,7 +3,7 @@ import type { RawBodyRange } from '../types/BodyRange'; import type { MessageAttributesType } from '../model-types.d'; -import type { ReplacementValuesType } from '../types/I18N'; +import type { ICUStringMessageParamsByKeyType } from '../types/Util'; import * as Attachment from '../types/Attachment'; import * as EmbeddedContact from '../types/EmbeddedContact'; import * as GroupChange from '../groupChange'; @@ -149,13 +149,13 @@ export function getNotificationDataForMessage( ? conversation.getTitle() : window.i18n('icu:unknownContact'); }, - renderString: ( - key: string, + renderIntl: ( + key: Key, _i18n: unknown, - components: ReplacementValuesType | undefined + components: ICUStringMessageParamsByKeyType[Key] ) => { - // eslint-disable-next-line local-rules/valid-i18n-keys - return window.i18n(key, components); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return window.i18n(key, components as any); }, }); diff --git a/ts/util/getNotificationTextForMessage.ts b/ts/util/getNotificationTextForMessage.ts index da161bf2bc5..ccc7b523216 100644 --- a/ts/util/getNotificationTextForMessage.ts +++ b/ts/util/getNotificationTextForMessage.ts @@ -77,7 +77,7 @@ export function getNotificationTextForMessage( if (shouldIncludeEmoji) { return window.i18n('icu:message--getNotificationText--text-with-emoji', { text: result.body, - emoji, + emoji: emoji ?? '', }); } diff --git a/ts/util/setupI18n.tsx b/ts/util/setupI18n.tsx index 0689847a509..10e5c3c7252 100644 --- a/ts/util/setupI18n.tsx +++ b/ts/util/setupI18n.tsx @@ -5,7 +5,10 @@ import React from 'react'; 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 type { + LocalizerType, + ICUStringMessageParamsByKeyType, +} from '../types/Util'; import { strictAssert } from './assert'; import { Emojify } from '../components/conversation/Emojify'; import * as log from '../logging/log'; @@ -77,27 +80,25 @@ export function createCachedIntl( return intl; } -function normalizeSubstitutions( - substitutions?: ReplacementValuesType -): ReplacementValuesType | undefined { +function normalizeSubstitutions< + Substitutions extends Record | undefined +>(substitutions?: Substitutions): Substitutions | undefined { if (!substitutions) { return; } - const normalized: ReplacementValuesType = {}; - const keys = Object.keys(substitutions); - if (keys.length === 0) { + const normalized: Record = {}; + const entries = Object.entries(substitutions); + if (entries.length === 0) { return; } - for (let i = 0; i < keys.length; i += 1) { - const key = keys[i]; - const value = substitutions[key]; + for (const [key, value] of entries) { if (typeof value === 'string') { normalized[key] = bidiIsolate(value); } else { normalized[key] = value; } } - return normalized; + return normalized as Substitutions; } export function setupI18n( @@ -113,7 +114,12 @@ export function setupI18n( const intl = createCachedIntl(locale, filterLegacyMessages(messages)); - const localizer: LocalizerType = (key, substitutions) => { + const localizer: LocalizerType = (< + Key extends keyof ICUStringMessageParamsByKeyType + >( + key: Key, + substitutions: ICUStringMessageParamsByKeyType[Key] + ) => { const result = intl.formatMessage( { id: key }, normalizeSubstitutions(substitutions) @@ -122,7 +128,7 @@ export function setupI18n( strictAssert(result !== key, `i18n: missing translation for "${key}"`); return result; - }; + }) as LocalizerType; localizer.getIntl = () => { return intl;