From 8ca192a48d94b8a419a82448cf94ad4e7b213f18 Mon Sep 17 00:00:00 2001
From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com>
Date: Tue, 4 Apr 2023 11:41:14 -0700
Subject: [PATCH] Update i18n eslint rule to validate missing/extra icu params

---
 .eslint/rules/valid-i18n-keys.js              | 232 +++++++++++++++++-
 .eslint/rules/valid-i18n-keys.test.js         | 185 +++++++++++++-
 _locales/en/messages.json                     |   4 +-
 ts/components/StoriesAddStoryButton.tsx       |   5 +-
 ts/components/StoryViewer.tsx                 |   5 +-
 ts/components/ToastManager.tsx                |  19 +-
 ts/components/WhatsNewModal.tsx               |   7 +-
 .../conversation/DeliveryIssueDialog.tsx      |   8 +-
 .../MandatoryProfileSharingActions.tsx        |   2 +-
 ts/components/conversation/Message.tsx        |   2 +-
 .../conversation/MessageRequestActions.tsx    |  12 +-
 .../MessageRequestActionsConfirmation.tsx     |   3 -
 .../RemoveGroupMemberConfirmationDialog.tsx   |   8 +-
 .../conversation/SafetyNumberNotification.tsx |   6 +-
 .../conversation/UnsupportedMessage.tsx       |  15 +-
 ts/services/LinkPreview.ts                    |   2 +-
 16 files changed, 449 insertions(+), 66 deletions(-)

diff --git a/.eslint/rules/valid-i18n-keys.js b/.eslint/rules/valid-i18n-keys.js
index 73007a331b3..6013d0650c0 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: `<Intl> message "${key}" does not have any params, but has a "components" attribute`,
+            });
+          }
+          return;
+        }
+
+        if (components == null) {
+          context.report({
+            node,
+            message: `<Intl> message "${key}" has params, but is missing a "components" attribute`,
+          });
+          return;
+        }
+
+        if (components.type !== 'ObjectExpression') {
+          context.report({
+            node: components,
+            message: `<Intl> "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: `<Intl> "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: `<Intl> 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: `<Intl> 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 0661a8d9f0d..ae60794705e 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 = <Intl id="icu:real_message"/>`,
+      code: `let jsx = <Intl id="icu:real_message" components={{ message: "foo" }}/>`,
+      options: [{ messagesCacheKey, __mockMessages__ }],
+    },
+    {
+      code: `i18n("icu:no_params")`,
+      options: [{ messagesCacheKey, __mockMessages__ }],
+    },
+    {
+      code: `let jsx = <Intl id="icu:no_params"/>`,
+      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 = <Intl id="icu:no_params" components={{ message: "foo" }}/>`,
+      options: [{ messagesCacheKey, __mockMessages__ }],
+      errors: [
+        {
+          message:
+            '<Intl> message "icu:no_params" does not have any params, but has a "components" attribute',
+          type: 'JSXOpeningElement',
+        },
+      ],
+    },
+    {
+      code: `let jsx = <Intl id="icu:real_message"/>`,
+      options: [{ messagesCacheKey, __mockMessages__ }],
+      errors: [
+        {
+          message:
+            '<Intl> message "icu:real_message" has params, but is missing a "components" attribute',
+          type: 'JSXOpeningElement',
+        },
+      ],
+    },
+    {
+      code: `let jsx = <Intl id="icu:real_message" components={null}/>`,
+      options: [{ messagesCacheKey, __mockMessages__ }],
+      errors: [
+        {
+          message: '<Intl> "components" attribute must be an object literal',
+          type: 'Literal',
+        },
+      ],
+    },
+    {
+      code: `let jsx = <Intl id="icu:real_message" components={{ [foo]: "foo" }}/>`,
+      options: [{ messagesCacheKey, __mockMessages__ }],
+      errors: [
+        {
+          message:
+            '<Intl> "components" attribute must only contain literal keys',
+          type: 'Property',
+        },
+      ],
+    },
+    {
+      code: `let jsx = <Intl id="icu:real_message" components={{ ...props }}/>`,
+      options: [{ messagesCacheKey, __mockMessages__ }],
+      errors: [
+        {
+          message:
+            '<Intl> "components" attribute must only contain literal keys',
+          type: 'SpreadElement',
+        },
+      ],
+    },
+    {
+      code: `let jsx = <Intl id="icu:real_message" components={{}}/>`,
+      options: [{ messagesCacheKey, __mockMessages__ }],
+      errors: [
+        {
+          message:
+            '<Intl> message "icu:real_message" has a param "message", but no corresponding component',
+          type: 'ObjectExpression',
+        },
+      ],
+    },
+    {
+      code: `let jsx = <Intl id="icu:real_message" components={{ message: "foo", foo: "bar" }}/>`,
+      options: [{ messagesCacheKey, __mockMessages__ }],
+      errors: [
+        {
+          message:
+            '<Intl> 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 f0b5fb0f678..34a451862e9 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. <learnMoreLink>{learnMore}</learnMoreLink>",
+    "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. <learnMoreLink>Learn More</learnMoreLink>",
     "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 12a8cce952a..20daac2cdda 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 7f909b919d3..002d4928958 100644
--- a/ts/components/StoryViewer.tsx
+++ b/ts/components/StoryViewer.tsx
@@ -861,7 +861,10 @@ export function StoryViewer({
                         <Intl
                           i18n={i18n}
                           id="icu:MyStories__views--strong"
-                          components={{ viewCount, strong: renderStrong }}
+                          components={{
+                            views: viewCount,
+                            strong: renderStrong,
+                          }}
                         />
                       )}
                       {(isSent || viewCount > 0) && replyCount > 0 && ' '}
diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx
index e158c9230a1..3aea4c4ab63 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 (
       <Toast onClose={hideToast} timeout={SHORT_TIMEOUT}>
         {i18n('icu:AddUserToAnotherGroupModal__toast--adding-user-to-group', {
-          ...toast.parameters,
+          contact: toast.parameters?.contact,
         })}
       </Toast>
     );
@@ -106,9 +105,7 @@ export function ToastManager({
   if (toastType === ToastType.CannotStartGroupCall) {
     return (
       <Toast onClose={hideToast}>
-        {i18n('icu:GroupV2--cannot-start-group-call', {
-          ...toast.parameters,
-        })}
+        {i18n('icu:GroupV2--cannot-start-group-call')}
       </Toast>
     );
   }
@@ -219,7 +216,10 @@ export function ToastManager({
   if (toastType === ToastType.FileSize) {
     return (
       <Toast onClose={hideToast}>
-        {i18n('icu:fileSizeWarning', toast?.parameters)}
+        {i18n('icu:fileSizeWarning', {
+          limit: toast.parameters?.limit,
+          units: toast.parameters?.units,
+        })}
       </Toast>
     );
   }
@@ -323,9 +323,7 @@ export function ToastManager({
   if (toastType === ToastType.TooManyMessagesToForward) {
     return (
       <Toast onClose={hideToast}>
-        {i18n('icu:SelectModeActions__toast--TooManyMessagesToForward', {
-          count: get(toast.parameters, 'count'),
-        })}
+        {i18n('icu:SelectModeActions__toast--TooManyMessagesToForward')}
       </Toast>
     );
   }
@@ -356,7 +354,8 @@ export function ToastManager({
     return (
       <Toast onClose={hideToast}>
         {i18n('icu:AddUserToAnotherGroupModal__toast--user-added-to-group', {
-          ...toast.parameters,
+          contact: toast.parameters?.contact,
+          group: toast.parameters?.group,
         })}
       </Toast>
     );
diff --git a/ts/components/WhatsNewModal.tsx b/ts/components/WhatsNewModal.tsx
index d21c3ef6280..d92cbe4a1f7 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: [
-      <Intl
-        i18n={i18n}
-        id="icu:WhatsNew__v6.13--0"
-        renderText={renderText}
-        components={{}}
-      />,
+      <Intl i18n={i18n} id="icu:WhatsNew__v6.13--0" renderText={renderText} />,
       <Intl
         i18n={i18n}
         id="icu:WhatsNew__v6.13--1"
diff --git a/ts/components/conversation/DeliveryIssueDialog.tsx b/ts/components/conversation/DeliveryIssueDialog.tsx
index 111db674b38..445b12a50f9 100644
--- a/ts/components/conversation/DeliveryIssueDialog.tsx
+++ b/ts/components/conversation/DeliveryIssueDialog.tsx
@@ -52,9 +52,7 @@ export function DeliveryIssueDialog(props: PropsType): React.ReactElement {
     </>
   );
 
-  const intlComponents = {
-    sender: <Emojify text={sender.title} />,
-  };
+  const senderTitle = <Emojify text={sender.title} />;
 
   return (
     <Modal
@@ -80,13 +78,13 @@ export function DeliveryIssueDialog(props: PropsType): React.ReactElement {
           {inGroup ? (
             <Intl
               id="icu:DeliveryIssue--summary--group"
-              components={intlComponents}
+              components={{ sender: senderTitle }}
               i18n={i18n}
             />
           ) : (
             <Intl
               id="icu:DeliveryIssue--summary"
-              components={intlComponents}
+              components={{ sender: senderTitle }}
               i18n={i18n}
             />
           )}
diff --git a/ts/components/conversation/MandatoryProfileSharingActions.tsx b/ts/components/conversation/MandatoryProfileSharingActions.tsx
index cf92ba6fd1f..ddfef7df11b 100644
--- a/ts/components/conversation/MandatoryProfileSharingActions.tsx
+++ b/ts/components/conversation/MandatoryProfileSharingActions.tsx
@@ -92,7 +92,7 @@ export function MandatoryProfileSharingActions({
             <Intl
               i18n={i18n}
               id="icu:MessageRequests--profile-sharing--group--link"
-              components={{ firstName: firstNameContact, learnMoreLink }}
+              components={{ learnMoreLink }}
             />
           )}
         </p>
diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx
index c508c9b679f..d912607871b 100644
--- a/ts/components/conversation/Message.tsx
+++ b/ts/components/conversation/Message.tsx
@@ -2555,7 +2555,7 @@ export class Message extends React.PureComponent<Props, State> {
       <span className="module-message__alt-accessibility-tree">
         <span id={`message-accessibility-label:${id}`}>
           {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 5c8e969f603..a7dd60c8877 100644
--- a/ts/components/conversation/MessageRequestActions.tsx
+++ b/ts/components/conversation/MessageRequestActions.tsx
@@ -77,18 +77,10 @@ export function MessageRequestActions({
             />
           )}
           {conversationType === 'group' && isBlocked && (
-            <Intl
-              i18n={i18n}
-              id="icu:MessageRequests--message-group-blocked"
-              components={{ name }}
-            />
+            <Intl i18n={i18n} id="icu:MessageRequests--message-group-blocked" />
           )}
           {conversationType === 'group' && !isBlocked && (
-            <Intl
-              i18n={i18n}
-              id="icu:MessageRequests--message-group"
-              components={{ name }}
-            />
+            <Intl i18n={i18n} id="icu:MessageRequests--message-group" />
           )}
         </p>
         <div className="module-message-request-actions__buttons">
diff --git a/ts/components/conversation/MessageRequestActionsConfirmation.tsx b/ts/components/conversation/MessageRequestActionsConfirmation.tsx
index c4a992f8c28..1d5192f6268 100644
--- a/ts/components/conversation/MessageRequestActionsConfirmation.tsx
+++ b/ts/components/conversation/MessageRequestActionsConfirmation.tsx
@@ -136,9 +136,6 @@ export function MessageRequestActionsConfirmation({
             <Intl
               i18n={i18n}
               id="icu:MessageRequests--delete-direct-confirm-title"
-              components={{
-                title: <ContactName key="name" title={title} />,
-              }}
             />
           ) : (
             <Intl
diff --git a/ts/components/conversation/RemoveGroupMemberConfirmationDialog.tsx b/ts/components/conversation/RemoveGroupMemberConfirmationDialog.tsx
index 0ba29518da5..5c0e67c0705 100644
--- a/ts/components/conversation/RemoveGroupMemberConfirmationDialog.tsx
+++ b/ts/components/conversation/RemoveGroupMemberConfirmationDialog.tsx
@@ -30,9 +30,7 @@ export function RemoveGroupMemberConfirmationDialog({
     group.accessControlAddFromInviteLink
   );
 
-  const intlComponents = {
-    name: <ContactName title={conversation.title} />,
-  };
+  const contactName = <ContactName title={conversation.title} />;
 
   return (
     <ConfirmationDialog
@@ -51,13 +49,13 @@ export function RemoveGroupMemberConfirmationDialog({
           <Intl
             i18n={i18n}
             id="icu:RemoveGroupMemberConfirmation__description__with-link"
-            components={intlComponents}
+            components={{ name: contactName }}
           />
         ) : (
           <Intl
             i18n={i18n}
             id="icu:RemoveGroupMemberConfirmation__description"
-            components={intlComponents}
+            components={{ name: contactName }}
           />
         )
       }
diff --git a/ts/components/conversation/SafetyNumberNotification.tsx b/ts/components/conversation/SafetyNumberNotification.tsx
index 6eddc5fe153..7ac6e1bee8c 100644
--- a/ts/components/conversation/SafetyNumberNotification.tsx
+++ b/ts/components/conversation/SafetyNumberNotification.tsx
@@ -57,11 +57,7 @@ export function SafetyNumberNotification({
             i18n={i18n}
           />
         ) : (
-          <Intl
-            id="icu:safetyNumberChanged"
-            components={{ name }}
-            i18n={i18n}
-          />
+          <Intl id="icu:safetyNumberChanged" i18n={i18n} />
         )
       }
       button={
diff --git a/ts/components/conversation/UnsupportedMessage.tsx b/ts/components/conversation/UnsupportedMessage.tsx
index dae17b4201a..2581b4ec756 100644
--- a/ts/components/conversation/UnsupportedMessage.tsx
+++ b/ts/components/conversation/UnsupportedMessage.tsx
@@ -50,28 +50,23 @@ function UnsupportedMessageContents({ canProcessNow, contact, i18n }: Props) {
         />
       );
     }
-    return (
-      <Intl
-        id="icu:Message--from-me-unsupported-message"
-        components={{ contact: contactName }}
-        i18n={i18n}
-      />
-    );
+    return <Intl id="icu:Message--from-me-unsupported-message" i18n={i18n} />;
   }
   if (canProcessNow) {
     return (
       <Intl
         id="icu:Message--from-me-unsupported-message-ask-to-resend"
-        components={{ contact: contactName }}
         i18n={i18n}
       />
     );
   }
   return (
     <Intl
-      id="icu:Message--from-me-unsupported-message"
-      components={{ contact: contactName }}
+      id="icu:Message--unsupported-message"
       i18n={i18n}
+      components={{
+        contact: contactName,
+      }}
     />
   );
 }
diff --git a/ts/services/LinkPreview.ts b/ts/services/LinkPreview.ts
index 2dc348c5436..9c176b76f14 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;