diff --git a/.eslint/rules/valid-i18n-keys.js b/.eslint/rules/valid-i18n-keys.js
index 73007a331..6013d0650 100644
--- a/.eslint/rules/valid-i18n-keys.js
+++ b/.eslint/rules/valid-i18n-keys.js
@@ -2,14 +2,25 @@
// 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 hashSum = crypto.createHash('sha256');
hashSum.update(messageKeys.join('\n'));
+hashSum.update(allIcuParams.join('\n'));
const messagesCacheKey = hashSum.digest('hex');
function isI18nCall(node) {
@@ -54,6 +65,14 @@ function getI18nCallMessageKey(node) {
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 (
@@ -72,6 +91,27 @@ function getIntlElementMessageKey(node) {
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);
}
@@ -89,6 +129,67 @@ function isDeletedMessageKey(messages, key) {
return description?.toLowerCase().startsWith('(deleted ');
}
+function getIcuMessageParams(message) {
+ 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));
+
+ 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: {
@@ -150,7 +251,7 @@ module.exports = {
return;
}
- let key = getIntlElementMessageKey(node);
+ const key = getIntlElementMessageKey(node);
if (key == null) {
context.report({
@@ -184,13 +285,76 @@ module.exports = {
});
return;
}
+
+ const params = getIcuMessageParams(messages[key].messageformat);
+ 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;
}
- let key = getI18nCallMessageKey(node);
+ const key = getI18nCallMessageKey(node);
if (key == null) {
context.report({
@@ -222,6 +386,70 @@ module.exports = {
node,
message: `i18n() key "${key}" is marked as deleted in _locales/en/messages.json`,
});
+ return;
+ }
+
+ const params = getIcuMessageParams(messages[key].messageformat);
+ 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
index 0661a8d9f..ae6079470 100644
--- a/.eslint/rules/valid-i18n-keys.test.js
+++ b/.eslint/rules/valid-i18n-keys.test.js
@@ -17,6 +17,12 @@ const __mockMessages__ = {
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}}}}}}',
+ },
};
// Need to load so mocha doesn't complain about polluting the global namespace
@@ -36,15 +42,27 @@ const ruleTester = new RuleTester({
ruleTester.run('valid-i18n-keys', rule, {
valid: [
{
- code: `i18n("icu:real_message")`,
+ code: `i18n("icu:real_message", { message: "foo" })`,
options: [{ messagesCacheKey, __mockMessages__ }],
},
{
- code: `window.i18n("icu:real_message")`,
+ code: `window.i18n("icu:real_message", { message: "foo" })`,
options: [{ messagesCacheKey, __mockMessages__ }],
},
{
- code: `let jsx = `,
+ 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__ }],
},
],
@@ -221,5 +239,166 @@ ruleTester.run('valid-i18n-keys', rule, {
},
],
},
+ {
+ 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/_locales/en/messages.json b/_locales/en/messages.json
index f0b5fb0f6..34a451862 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -10084,7 +10084,7 @@
"description": "(deleted 03/29/2023) Information shown at the bottom of the profile editor section"
},
"icu:ProfileEditor--info--link": {
- "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}",
+ "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. Learn More",
"description": "Information shown at the bottom of the profile editor section"
},
"ProfileEditor--learnMore": {
@@ -10093,7 +10093,7 @@
},
"icu:ProfileEditor--learnMore": {
"messageformat": "Learn More",
- "description": "Text that links to a support article"
+ "description": "(deleted 04/03/2023) Text that links to a support article"
},
"Bio--speak-freely": {
"message": "Speak Freely",
diff --git a/ts/components/StoriesAddStoryButton.tsx b/ts/components/StoriesAddStoryButton.tsx
index 12a8cce95..20daac2cd 100644
--- a/ts/components/StoriesAddStoryButton.tsx
+++ b/ts/components/StoriesAddStoryButton.tsx
@@ -70,7 +70,10 @@ export function StoriesAddStoryButton({
if (result.reason === ReasonVideoNotGood.TooBig) {
setError(
- i18n('icu:StoryCreator__error--video-too-big', result.renderDetails)
+ i18n('icu:StoryCreator__error--video-too-big', {
+ limit: result.renderDetails.limit,
+ units: result.renderDetails.units,
+ })
);
return;
}
diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx
index 7f909b919..002d49289 100644
--- a/ts/components/StoryViewer.tsx
+++ b/ts/components/StoryViewer.tsx
@@ -861,7 +861,10 @@ export function StoryViewer({
)}
{(isSent || viewCount > 0) && replyCount > 0 && ' '}
diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx
index e158c9230..3aea4c4ab 100644
--- a/ts/components/ToastManager.tsx
+++ b/ts/components/ToastManager.tsx
@@ -2,7 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
-import { get } from 'lodash';
import type { LocalizerType, ReplacementValuesType } from '../types/Util';
import { SECOND } from '../util/durations';
import { Toast } from './Toast';
@@ -41,7 +40,7 @@ export function ToastManager({
return (
{i18n('icu:AddUserToAnotherGroupModal__toast--adding-user-to-group', {
- ...toast.parameters,
+ contact: toast.parameters?.contact,
})}
);
@@ -106,9 +105,7 @@ export function ToastManager({
if (toastType === ToastType.CannotStartGroupCall) {
return (
- {i18n('icu:GroupV2--cannot-start-group-call', {
- ...toast.parameters,
- })}
+ {i18n('icu:GroupV2--cannot-start-group-call')}
);
}
@@ -219,7 +216,10 @@ export function ToastManager({
if (toastType === ToastType.FileSize) {
return (
- {i18n('icu:fileSizeWarning', toast?.parameters)}
+ {i18n('icu:fileSizeWarning', {
+ limit: toast.parameters?.limit,
+ units: toast.parameters?.units,
+ })}
);
}
@@ -323,9 +323,7 @@ export function ToastManager({
if (toastType === ToastType.TooManyMessagesToForward) {
return (
- {i18n('icu:SelectModeActions__toast--TooManyMessagesToForward', {
- count: get(toast.parameters, 'count'),
- })}
+ {i18n('icu:SelectModeActions__toast--TooManyMessagesToForward')}
);
}
@@ -356,7 +354,8 @@ export function ToastManager({
return (
{i18n('icu:AddUserToAnotherGroupModal__toast--user-added-to-group', {
- ...toast.parameters,
+ contact: toast.parameters?.contact,
+ group: toast.parameters?.group,
})}
);
diff --git a/ts/components/WhatsNewModal.tsx b/ts/components/WhatsNewModal.tsx
index d21c3ef62..d92cbe4a1 100644
--- a/ts/components/WhatsNewModal.tsx
+++ b/ts/components/WhatsNewModal.tsx
@@ -35,12 +35,7 @@ export function WhatsNewModal({
date: new Date(window.getBuildCreation?.() || Date.now()),
version: window.getVersion?.(),
features: [
- ,
+ ,
);
- const intlComponents = {
- sender: ,
- };
+ const senderTitle = ;
return (
) : (
)}
diff --git a/ts/components/conversation/MandatoryProfileSharingActions.tsx b/ts/components/conversation/MandatoryProfileSharingActions.tsx
index cf92ba6fd..ddfef7df1 100644
--- a/ts/components/conversation/MandatoryProfileSharingActions.tsx
+++ b/ts/components/conversation/MandatoryProfileSharingActions.tsx
@@ -92,7 +92,7 @@ export function MandatoryProfileSharingActions({
)}
diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx
index c508c9b67..d91260787 100644
--- a/ts/components/conversation/Message.tsx
+++ b/ts/components/conversation/Message.tsx
@@ -2555,7 +2555,7 @@ export class Message extends React.PureComponent {
{author.isMe
- ? i18n('icu:messageAccessibilityLabel--outgoing', {})
+ ? i18n('icu:messageAccessibilityLabel--outgoing')
: i18n('icu:messageAccessibilityLabel--incoming', {
author: author.title,
})}
diff --git a/ts/components/conversation/MessageRequestActions.tsx b/ts/components/conversation/MessageRequestActions.tsx
index 5c8e969f6..a7dd60c88 100644
--- a/ts/components/conversation/MessageRequestActions.tsx
+++ b/ts/components/conversation/MessageRequestActions.tsx
@@ -77,18 +77,10 @@ export function MessageRequestActions({
/>
)}
{conversationType === 'group' && isBlocked && (
-
+
)}
{conversationType === 'group' && !isBlocked && (
-
+
)}
diff --git a/ts/components/conversation/MessageRequestActionsConfirmation.tsx b/ts/components/conversation/MessageRequestActionsConfirmation.tsx
index c4a992f8c..1d5192f62 100644
--- a/ts/components/conversation/MessageRequestActionsConfirmation.tsx
+++ b/ts/components/conversation/MessageRequestActionsConfirmation.tsx
@@ -136,9 +136,6 @@ export function MessageRequestActionsConfirmation({
,
- }}
/>
) : (
,
- };
+ const contactName = ;
return (
) : (
)
}
diff --git a/ts/components/conversation/SafetyNumberNotification.tsx b/ts/components/conversation/SafetyNumberNotification.tsx
index 6eddc5fe1..7ac6e1bee 100644
--- a/ts/components/conversation/SafetyNumberNotification.tsx
+++ b/ts/components/conversation/SafetyNumberNotification.tsx
@@ -57,11 +57,7 @@ export function SafetyNumberNotification({
i18n={i18n}
/>
) : (
-
+
)
}
button={
diff --git a/ts/components/conversation/UnsupportedMessage.tsx b/ts/components/conversation/UnsupportedMessage.tsx
index dae17b420..2581b4ec7 100644
--- a/ts/components/conversation/UnsupportedMessage.tsx
+++ b/ts/components/conversation/UnsupportedMessage.tsx
@@ -50,28 +50,23 @@ function UnsupportedMessageContents({ canProcessNow, contact, i18n }: Props) {
/>
);
}
- return (
-
- );
+ return ;
}
if (canProcessNow) {
return (
);
}
return (
);
}
diff --git a/ts/services/LinkPreview.ts b/ts/services/LinkPreview.ts
index 2dc348c54..9c176b76f 100644
--- a/ts/services/LinkPreview.ts
+++ b/ts/services/LinkPreview.ts
@@ -519,7 +519,7 @@ async function getGroupPreview(
window.Signal.Groups.decryptGroupTitle(result.title, secretParams) ||
window.i18n('icu:unknownGroup');
const description = window.i18n('icu:GroupV2--join--group-metadata--full', {
- count: result?.memberCount ?? 0,
+ memberCount: result?.memberCount ?? 0,
});
let image: undefined | LinkPreviewImage;