Add new eslint plugin to check for valid i18n keys

This commit is contained in:
Jamie Kyle 2023-01-05 14:43:33 -08:00 committed by GitHub
parent 465b4cb0fb
commit 569b6e14a6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 447 additions and 78 deletions

View file

@ -0,0 +1,183 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const crypto = require('crypto');
const messages = require('../../_locales/en/messages.json');
const messageKeys = Object.keys(messages).sort((a, b) => {
return a.localeCompare(b);
});
const hashSum = crypto.createHash('sha256');
hashSum.update(messageKeys.join('\n'));
const messagesCacheKey = hashSum.digest('hex');
function isI18nCall(node) {
return (
node.type === 'CallExpression' &&
node.callee.type === 'Identifier' &&
node.callee.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;
}
if (node.type !== 'TemplateLiteral') {
return null;
}
if (node.quasis.length === 1) {
return node.quasis[0].value.cooked;
}
const parts = node.quasis.map(element => {
return element.value.cooked;
});
return new RegExp(`^${parts.join('(.*)')}$`);
}
function getI18nCallMessageKey(node) {
if (node.arguments.length < 1) {
return null;
}
let arg1 = node.arguments[0];
if (arg1 == null) {
return null;
}
return valueToMessageKey(arg1);
}
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;
if (value.type === 'JSXExpressionContainer') {
value = value.expression;
}
return valueToMessageKey(value);
}
function isValidMessageKey(key) {
if (typeof key === 'string') {
if (Object.hasOwn(messages, key)) {
return true;
}
} else if (key instanceof RegExp) {
if (messageKeys.some(k => key.test(k))) {
return true;
}
}
return false;
}
module.exports = {
messagesCacheKey,
meta: {
type: 'problem',
hasSuggestions: false,
fixable: false,
schema: [
{
type: 'object',
properties: {
messagesCacheKey: {
type: 'string',
},
},
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})`
);
}
return {
JSXOpeningElement(node) {
if (!isIntlElement(node)) {
return;
}
let key = getIntlElementMessageKey(node);
if (key == null) {
context.report({
node,
message:
"<Intl> must always be provided an 'id' attribute with a literal string",
});
return;
}
if (isValidMessageKey(key)) {
return;
}
context.report({
node,
message: `<Intl> id "${key}" not found in _locales/en/messages.json`,
});
},
CallExpression(node) {
if (!isI18nCall(node)) {
return;
}
let key = getI18nCallMessageKey(node);
if (key == null) {
context.report({
node,
message:
"i18n()'s first argument should always be a literal string",
});
return;
}
if (isValidMessageKey(key)) {
return;
}
context.report({
node,
message: `i18n() key "${key}" not found in _locales/en/messages.json`,
});
},
};
},
};

View file

@ -0,0 +1,116 @@
// 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;
// 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("AddCaptionModal__title")`,
options: [{ messagesCacheKey }],
},
{
code: 'i18n(`AddCaptionModal__${title}`)',
options: [{ messagesCacheKey }],
},
{
code: `let jsx = <Intl id="AddCaptionModal__title"/>`,
options: [{ messagesCacheKey }],
},
{
code: `let jsx = <Intl id={"AddCaptionModal__title"}/>`,
options: [{ messagesCacheKey }],
},
{
code: 'let jsx = <Intl id={`AddCaptionModal__title`}/>',
options: [{ messagesCacheKey }],
},
{
code: 'let jsx = <Intl id={`AddCaptionModal__${title}`}/>',
options: [{ messagesCacheKey }],
},
],
invalid: [
{
code: `i18n("THIS_KEY_SHOULD_NEVER_EXIST")`,
options: [{ messagesCacheKey }],
errors: [
{
message:
'i18n() key "THIS_KEY_SHOULD_NEVER_EXIST" not found in _locales/en/messages.json',
type: 'CallExpression',
},
],
},
{
code: `i18n(cond ? "AddCaptionModal__title" : "AddCaptionModal__title")`,
options: [{ messagesCacheKey }],
errors: [
{
message: "i18n()'s first argument should always be a literal string",
type: 'CallExpression',
},
],
},
{
code: `i18n(42)`,
options: [{ messagesCacheKey }],
errors: [
{
message: "i18n()'s first argument should always be a literal string",
type: 'CallExpression',
},
],
},
{
code: `let jsx = <Intl id="THIS_KEY_SHOULD_NEVER_EXIST"/>`,
options: [{ messagesCacheKey }],
errors: [
{
message:
'<Intl> id "THIS_KEY_SHOULD_NEVER_EXIST" not found in _locales/en/messages.json',
type: 'JSXOpeningElement',
},
],
},
{
code: `let jsx = <Intl id={cond ? "AddCaptionModal__title" : "AddCaptionModal__title"}/>`,
options: [{ messagesCacheKey }],
errors: [
{
message:
"<Intl> must always be provided an 'id' attribute with a literal string",
type: 'JSXOpeningElement',
},
],
},
{
code: `let jsx = <Intl id={42}/>`,
options: [{ messagesCacheKey }],
errors: [
{
message:
"<Intl> must always be provided an 'id' attribute with a literal string",
type: 'JSXOpeningElement',
},
],
},
],
});

View file

@ -1,5 +1,6 @@
// Copyright 2018 Signal Messenger, LLC // Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
const { messagesCacheKey } = require('./.eslint/rules/valid-i18n-keys');
// For reference: https://github.com/airbnb/javascript // For reference: https://github.com/airbnb/javascript
@ -215,6 +216,8 @@ const typescriptRules = {
// TODO: DESKTOP-4655 // TODO: DESKTOP-4655
'import/no-cycle': 'off', 'import/no-cycle': 'off',
'local-rules/valid-i18n-keys': ['error', { messagesCacheKey }],
}; };
module.exports = { module.exports = {
@ -228,7 +231,7 @@ module.exports = {
extends: ['airbnb-base', 'prettier'], extends: ['airbnb-base', 'prettier'],
plugins: ['mocha', 'more'], plugins: ['mocha', 'more', 'local-rules'],
overrides: [ overrides: [
{ {

View file

@ -81,6 +81,7 @@ jobs:
- run: yarn test-release - run: yarn test-release
env: env:
NODE_ENV: production NODE_ENV: production
- run: yarn test-eslint
linux: linux:
needs: lint needs: lint

View file

@ -2276,6 +2276,10 @@
"message": "This message was deleted.", "message": "This message was deleted.",
"description": "Shown in a message's bubble when the message has been deleted for everyone." "description": "Shown in a message's bubble when the message has been deleted for everyone."
}, },
"giftBadge--missing": {
"message": "Unable to fetch gift badge details",
"description": "Aria label for gift badge when we can't fetch the details"
},
"message--giftBadge--unopened--incoming": { "message--giftBadge--unopened--incoming": {
"message": "View this message on mobile to open it", "message": "View this message on mobile to open it",
"description": "Shown in a message's bubble when you've received a gift badge from a contact" "description": "Shown in a message's bubble when you've received a gift badge from a contact"

7
eslint-local-rules.js Normal file
View file

@ -0,0 +1,7 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
/* eslint-disable global-require */
module.exports = {
'valid-i18n-keys': require('./.eslint/rules/valid-i18n-keys'),
};

View file

@ -34,11 +34,12 @@
"prepare-staging-build": "node scripts/prepare_staging_build.js", "prepare-staging-build": "node scripts/prepare_staging_build.js",
"prepare-windows-cert": "node scripts/prepare_windows_cert.js", "prepare-windows-cert": "node scripts/prepare_windows_cert.js",
"publish-to-apt": "NAME=$npm_package_name VERSION=$npm_package_version ./aptly.sh", "publish-to-apt": "NAME=$npm_package_name VERSION=$npm_package_version ./aptly.sh",
"test": "yarn test-node && yarn test-electron && yarn test-lint-intl", "test": "yarn test-node && yarn test-electron && yarn test-lint-intl && yarn test-eslint",
"test-electron": "node ts/scripts/test-electron.js", "test-electron": "node ts/scripts/test-electron.js",
"test-release": "node ts/scripts/test-release.js", "test-release": "node ts/scripts/test-release.js",
"test-node": "electron-mocha --timeout 10000 --file test/setup-test-node.js --recursive test/modules ts/test-node ts/test-both", "test-node": "electron-mocha --timeout 10000 --file test/setup-test-node.js --recursive test/modules ts/test-node ts/test-both",
"test-mock": "mocha ts/test-mock/**/*_test.js", "test-mock": "mocha ts/test-mock/**/*_test.js",
"test-eslint": "mocha .eslint/rules/**/*.test.js",
"test-node-coverage": "nyc --reporter=lcov --reporter=text mocha --recursive test/modules ts/test-node ts/test-both", "test-node-coverage": "nyc --reporter=lcov --reporter=text mocha --recursive test/modules ts/test-node ts/test-both",
"test-lint-intl": "ts-node ./build/intl-linter/linter.ts --test", "test-lint-intl": "ts-node ./build/intl-linter/linter.ts --test",
"eslint": "eslint --cache . --max-warnings 0", "eslint": "eslint --cache . --max-warnings 0",
@ -276,6 +277,7 @@
"eslint-config-airbnb-typescript-prettier": "5.0.0", "eslint-config-airbnb-typescript-prettier": "5.0.0",
"eslint-config-prettier": "8.5.0", "eslint-config-prettier": "8.5.0",
"eslint-plugin-import": "2.26.0", "eslint-plugin-import": "2.26.0",
"eslint-plugin-local-rules": "1.3.2",
"eslint-plugin-mocha": "10.1.0", "eslint-plugin-mocha": "10.1.0",
"eslint-plugin-more": "1.0.5", "eslint-plugin-more": "1.0.5",
"eslint-plugin-react": "7.31.10", "eslint-plugin-react": "7.31.10",

View file

@ -95,6 +95,7 @@ export function AppStage(props: Props): JSX.Element {
className={styles.toaster} className={styles.toaster}
loaf={toasts.map((slice, id) => ({ loaf={toasts.map((slice, id) => ({
id, id,
// eslint-disable-next-line local-rules/valid-i18n-keys
text: i18n(slice.key, slice.subs), text: i18n(slice.key, slice.subs),
}))} }))}
onDismiss={dismissToast} onDismiss={dismissToast}

View file

@ -91,19 +91,19 @@ export function CallingHeader({
{isGroupCall && participantCount > 2 && toggleSpeakerView && ( {isGroupCall && participantCount > 2 && toggleSpeakerView && (
<div className="module-calling-tools__button"> <div className="module-calling-tools__button">
<Tooltip <Tooltip
content={i18n( content={
isInSpeakerView isInSpeakerView
? 'calling__switch-view--to-grid' ? i18n('calling__switch-view--to-grid')
: 'calling__switch-view--to-speaker' : i18n('calling__switch-view--to-speaker')
)} }
theme={Theme.Dark} theme={Theme.Dark}
> >
<button <button
aria-label={i18n( aria-label={
isInSpeakerView isInSpeakerView
? 'calling__switch-view--to-grid' ? i18n('calling__switch-view--to-grid')
: 'calling__switch-view--to-speaker' : i18n('calling__switch-view--to-speaker')
)} }
className={ className={
isInSpeakerView isInSpeakerView
? 'CallingButton__grid-view' ? 'CallingButton__grid-view'

View file

@ -374,8 +374,10 @@ export function ConversationList({
result = ( result = (
<div <div
className="module-conversation-list__item--header" className="module-conversation-list__item--header"
// eslint-disable-next-line local-rules/valid-i18n-keys
aria-label={i18n(row.i18nKey)} aria-label={i18n(row.i18nKey)}
> >
{/* eslint-disable-next-line local-rules/valid-i18n-keys */}
{i18n(row.i18nKey)} {i18n(row.i18nKey)}
</div> </div>
); );

View file

@ -65,11 +65,9 @@ export function DisappearingTimerSelect(props: Props): JSX.Element {
...expirationTimerOptions, ...expirationTimerOptions,
{ {
value: DurationInSeconds.fromSeconds(-1), value: DurationInSeconds.fromSeconds(-1),
text: i18n( text: isCustomTimeSelected
isCustomTimeSelected ? i18n('selectedCustomDisappearingTimeOption')
? 'selectedCustomDisappearingTimeOption' : i18n('customDisappearingTimeOption'),
: 'customDisappearingTimeOption'
),
}, },
]; ];

View file

@ -154,6 +154,7 @@ function renderMembers({
return ( return (
<> <>
{/* eslint-disable-next-line local-rules/valid-i18n-keys */}
<GroupDialog.Paragraph>{i18n(key)}</GroupDialog.Paragraph> <GroupDialog.Paragraph>{i18n(key)}</GroupDialog.Paragraph>
<GroupDialog.Contacts <GroupDialog.Contacts
contacts={sortByTitle(members)} contacts={sortByTitle(members)}

View file

@ -188,9 +188,9 @@ export function IncomingCallBar(props: PropsType): JSX.Element | null {
case CallMode.Direct: case CallMode.Direct:
({ isVideoCall } = props); ({ isVideoCall } = props);
headerNode = <ContactName title={title} />; headerNode = <ContactName title={title} />;
messageNode = i18n( messageNode = isVideoCall
isVideoCall ? 'incomingVideoCall' : 'incomingAudioCall' ? i18n('incomingVideoCall')
); : i18n('incomingAudioCall');
break; break;
case CallMode.Group: { case CallMode.Group: {
const { otherMembersRung, ringer } = props; const { otherMembersRung, ringer } = props;

View file

@ -23,7 +23,8 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
renderText: overrideProps.renderText, renderText: overrideProps.renderText,
}); });
// eslint-disable-next-line react/function-component-definition // eslint-disable-next-line max-len
// eslint-disable-next-line react/function-component-definition, local-rules/valid-i18n-keys
const Template: Story<Props> = args => <Intl {...args} />; const Template: Story<Props> = args => <Intl {...args} />;
export const NoReplacements = Template.bind({}); export const NoReplacements = Template.bind({});

View file

@ -88,6 +88,7 @@ export class Intl extends React.Component<Props> {
return intl.formatMessage({ id }, components); return intl.formatMessage({ id }, components);
} }
// eslint-disable-next-line local-rules/valid-i18n-keys
const text = i18n(id); const text = i18n(id);
const results: Array< const results: Array<
string | JSX.Element | Array<string | JSX.Element> | null string | JSX.Element | Array<string | JSX.Element> | null

View file

@ -87,7 +87,7 @@ export function LeftPaneSearchInput({
inputRef.current?.focus(); inputRef.current?.focus();
}; };
const label = i18n(searchConversation ? 'searchIn' : 'search'); const label = searchConversation ? i18n('searchIn') : i18n('search');
return ( return (
<SearchInput <SearchInput

View file

@ -418,6 +418,7 @@ export function ProfileEditor({
<Emoji shortName={defaultBio.shortName} size={24} /> <Emoji shortName={defaultBio.shortName} size={24} />
</div> </div>
} }
// eslint-disable-next-line local-rules/valid-i18n-keys
label={i18n(defaultBio.i18nLabel)} label={i18n(defaultBio.i18nLabel)}
onClick={() => { onClick={() => {
const emojiData = getEmojiData(defaultBio.shortName, skinTone); const emojiData = getEmojiData(defaultBio.shortName, skinTone);
@ -425,6 +426,7 @@ export function ProfileEditor({
setStagedProfile(profileData => ({ setStagedProfile(profileData => ({
...profileData, ...profileData,
aboutEmoji: unifiedToEmoji(emojiData.unified), aboutEmoji: unifiedToEmoji(emojiData.unified),
// eslint-disable-next-line local-rules/valid-i18n-keys
aboutText: i18n(defaultBio.i18nLabel), aboutText: i18n(defaultBio.i18nLabel),
})); }));
}} }}

View file

@ -64,7 +64,6 @@ export function SafetyNumberViewer({
); );
const { isVerified } = contact; const { isVerified } = contact;
const verifiedStatusKey = isVerified ? 'isVerified' : 'isNotVerified';
const verifyButtonText = isVerified ? i18n('unverify') : i18n('verify'); const verifyButtonText = isVerified ? i18n('unverify') : i18n('verify');
return ( return (
@ -79,7 +78,12 @@ export function SafetyNumberViewer({
) : ( ) : (
<span className="module-SafetyNumberViewer__icon--shield" /> <span className="module-SafetyNumberViewer__icon--shield" />
)} )}
<Intl i18n={i18n} id={verifiedStatusKey} components={[boldName]} /> {}
{isVerified ? (
<Intl i18n={i18n} id="isVerified" components={[boldName]} />
) : (
<Intl i18n={i18n} id="isNotVerified" components={[boldName]} />
)}
</div> </div>
<div className="module-SafetyNumberViewer__button"> <div className="module-SafetyNumberViewer__button">
<Button <Button

View file

@ -319,6 +319,7 @@ function renderShortcut(
return ( return (
<div key={index} className="module-shortcut-guide__shortcut"> <div key={index} className="module-shortcut-guide__shortcut">
<div className="module-shortcut-guide__shortcut__description"> <div className="module-shortcut-guide__shortcut__description">
{/* eslint-disable-next-line local-rules/valid-i18n-keys */}
{i18n(shortcut.description)} {i18n(shortcut.description)}
</div> </div>
<div className="module-shortcut-guide__shortcut__key-container"> <div className="module-shortcut-guide__shortcut__key-container">

View file

@ -815,6 +815,7 @@ export function EditMyStoryPrivacy({
}: EditMyStoryPrivacyPropsType): JSX.Element { }: EditMyStoryPrivacyPropsType): JSX.Element {
const disclaimerElement = ( const disclaimerElement = (
<div className="StoriesSettingsModal__disclaimer"> <div className="StoriesSettingsModal__disclaimer">
{/* eslint-disable-next-line local-rules/valid-i18n-keys */}
<Intl <Intl
components={{ components={{
learnMore: ( learnMore: (

View file

@ -114,6 +114,7 @@ export function StoryDetailsModal({
return ( return (
<div key={i18nKey} className="StoryDetailsModal__contact-group"> <div key={i18nKey} className="StoryDetailsModal__contact-group">
<div className="StoryDetailsModal__contact-group__header"> <div className="StoryDetailsModal__contact-group__header">
{/* eslint-disable-next-line local-rules/valid-i18n-keys */}
{i18n(i18nKey)} {i18n(i18nKey)}
</div> </div>
{sortedContacts.map(status => { {sortedContacts.map(status => {

View file

@ -47,6 +47,7 @@ export function WhatsNewModal({
const { key, components } = releaseNotes.features[0]; const { key, components } = releaseNotes.features[0];
contentNode = ( contentNode = (
<p> <p>
{/* eslint-disable-next-line local-rules/valid-i18n-keys */}
<Intl <Intl
i18n={i18n} i18n={i18n}
id={key} id={key}
@ -60,6 +61,7 @@ export function WhatsNewModal({
<ul> <ul>
{releaseNotes.features.map(({ key, components }) => ( {releaseNotes.features.map(({ key, components }) => (
<li key={key}> <li key={key}>
{/* eslint-disable-next-line local-rules/valid-i18n-keys */}
<Intl <Intl
i18n={i18n} i18n={i18n}
id={key} id={key}

View file

@ -24,10 +24,6 @@ export type PropsType = {
export function DeliveryIssueDialog(props: PropsType): React.ReactElement { export function DeliveryIssueDialog(props: PropsType): React.ReactElement {
const { i18n, inGroup, sender, onClose } = props; const { i18n, inGroup, sender, onClose } = props;
const key = inGroup
? 'DeliveryIssue--summary--group'
: 'DeliveryIssue--summary';
// Focus first button after initial render, restore focus on teardown // Focus first button after initial render, restore focus on teardown
const [focusRef] = useRestoreFocus(); const [focusRef] = useRestoreFocus();
@ -56,6 +52,10 @@ export function DeliveryIssueDialog(props: PropsType): React.ReactElement {
</> </>
); );
const intlComponents = {
sender: <Emojify text={sender.title} />,
};
return ( return (
<Modal <Modal
modalName="DeliveryIssueDialog" modalName="DeliveryIssueDialog"
@ -77,13 +77,19 @@ export function DeliveryIssueDialog(props: PropsType): React.ReactElement {
{i18n('DeliveryIssue--title')} {i18n('DeliveryIssue--title')}
</div> </div>
<div className="module-delivery-issue-dialog__description"> <div className="module-delivery-issue-dialog__description">
<Intl {inGroup ? (
id={key} <Intl
components={{ id="DeliveryIssue--summary--group"
sender: <Emojify text={sender.title} />, components={intlComponents}
}} i18n={i18n}
i18n={i18n} />
/> ) : (
<Intl
id="DeliveryIssue--summary"
components={intlComponents}
i18n={i18n}
/>
)}
</div> </div>
</section> </section>
</Modal> </Modal>

View file

@ -75,20 +75,24 @@ export class GroupNotification extends React.Component<Props> {
throw new Error('Group update is missing contacts'); throw new Error('Group update is missing contacts');
} }
// eslint-disable-next-line no-case-declarations
const otherPeopleNotifMsg =
otherPeople.length === 1
? 'joinedTheGroup'
: 'multipleJoinedTheGroup';
return ( return (
<> <>
{otherPeople.length > 0 && ( {otherPeople.length > 0 && (
<Intl <>
i18n={i18n} {otherPeople.length === 1 ? (
id={otherPeopleNotifMsg} <Intl
components={[otherPeopleWithCommas]} i18n={i18n}
/> id="joinedTheGroup"
components={[otherPeopleWithCommas]}
/>
) : (
<Intl
i18n={i18n}
id="multipleJoinedTheGroup"
components={[otherPeopleWithCommas]}
/>
)}
</>
)} )}
{contactsIncludesMe && ( {contactsIncludesMe && (
<div className="module-group-notification__change"> <div className="module-group-notification__change">
@ -106,12 +110,18 @@ export class GroupNotification extends React.Component<Props> {
throw new Error('Group update is missing contacts'); throw new Error('Group update is missing contacts');
} }
// eslint-disable-next-line no-case-declarations return contacts.length > 1 ? (
const leftKey = <Intl
contacts.length > 1 ? 'multipleLeftTheGroup' : 'leftTheGroup'; id="multipleLeftTheGroup"
i18n={i18n}
return ( components={[otherPeopleWithCommas]}
<Intl i18n={i18n} id={leftKey} components={[otherPeopleWithCommas]} /> />
) : (
<Intl
id="leftTheGroup"
i18n={i18n}
components={[otherPeopleWithCommas]}
/>
); );
case 'general': case 'general':
return; return;

View file

@ -57,6 +57,7 @@ function renderStringToIntl(
i18n: LocalizerType, i18n: LocalizerType,
components?: Array<FullJSXType> | ReplacementValuesType<FullJSXType> components?: Array<FullJSXType> | ReplacementValuesType<FullJSXType>
): FullJSXType { ): FullJSXType {
// eslint-disable-next-line local-rules/valid-i18n-keys
return <Intl id={id} i18n={i18n} components={components} />; return <Intl id={id} i18n={i18n} components={components} />;
} }

View file

@ -189,6 +189,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
onMouseUp={() => setIsDown(false)} onMouseUp={() => setIsDown(false)}
onMouseLeave={() => setIsDown(false)} onMouseLeave={() => setIsDown(false)}
tabIndex={0} tabIndex={0}
// eslint-disable-next-line local-rules/valid-i18n-keys
aria-label={i18n(label)} aria-label={i18n(label)}
> >
{children} {children}

View file

@ -225,6 +225,7 @@ export class MessageDetail extends React.Component<Props> {
`module-message-detail__contact-group__header--${sendStatus}` `module-message-detail__contact-group__header--${sendStatus}`
)} )}
> >
{/* eslint-disable-next-line local-rules/valid-i18n-keys */}
{i18n(i18nKey)} {i18n(i18nKey)}
</div> </div>
{sortedContacts.map(contact => this.renderContact(contact))} {sortedContacts.map(contact => this.renderContact(contact))}

View file

@ -26,11 +26,13 @@ export function RemoveGroupMemberConfirmationDialog({
onClose, onClose,
onRemove, onRemove,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const descriptionKey = isAccessControlEnabled( const accessControlEnabled = isAccessControlEnabled(
group.accessControlAddFromInviteLink group.accessControlAddFromInviteLink
) );
? 'RemoveGroupMemberConfirmation__description__with-link'
: 'RemoveGroupMemberConfirmation__description'; const intlComponents = {
name: <ContactName title={conversation.title} />,
};
return ( return (
<ConfirmationDialog <ConfirmationDialog
@ -45,13 +47,19 @@ export function RemoveGroupMemberConfirmationDialog({
i18n={i18n} i18n={i18n}
onClose={onClose} onClose={onClose}
title={ title={
<Intl accessControlEnabled ? (
i18n={i18n} <Intl
id={descriptionKey} i18n={i18n}
components={{ id="RemoveGroupMemberConfirmation__description__with-link"
name: <ContactName title={conversation.title} />, components={intlComponents}
}} />
/> ) : (
<Intl
i18n={i18n}
id="RemoveGroupMemberConfirmation__description"
components={intlComponents}
/>
)
} }
/> />
); );

View file

@ -43,6 +43,7 @@ export function SafetyNumberNotification({
<SystemMessage <SystemMessage
icon="safety-number" icon="safety-number"
contents={ contents={
// eslint-disable-next-line local-rules/valid-i18n-keys
<Intl <Intl
id={changeKey} id={changeKey}
components={[ components={[

View file

@ -55,6 +55,7 @@ export function TimerNotification(props: Props): JSX.Element {
switch (type) { switch (type) {
case 'fromOther': case 'fromOther':
message = ( message = (
// eslint-disable-next-line local-rules/valid-i18n-keys
<Intl <Intl
i18n={i18n} i18n={i18n}
id={changeKey} id={changeKey}

View file

@ -50,6 +50,7 @@ export function UnsupportedMessage({
<SystemMessage <SystemMessage
icon={icon} icon={icon}
contents={ contents={
// eslint-disable-next-line local-rules/valid-i18n-keys
<Intl <Intl
id={stringId} id={stringId}
components={[ components={[

View file

@ -47,6 +47,7 @@ export class VerificationNotification extends React.Component<Props> {
const id = this.getStringId(); const id = this.getStringId();
return ( return (
// eslint-disable-next-line local-rules/valid-i18n-keys
<Intl <Intl
id={id} id={id}
components={[ components={[

View file

@ -415,11 +415,13 @@ export function ConversationDetails({
icon={IconType.timer} icon={IconType.timer}
/> />
} }
info={i18n( info={
isGroup isGroup
? 'ConversationDetails--disappearing-messages-info--group' ? i18n('ConversationDetails--disappearing-messages-info--group')
: 'ConversationDetails--disappearing-messages-info--direct' : i18n(
)} 'ConversationDetails--disappearing-messages-info--direct'
)
}
label={i18n('ConversationDetails--disappearing-messages-label')} label={i18n('ConversationDetails--disappearing-messages-label')}
right={ right={
<DisappearingTimerSelect <DisappearingTimerSelect
@ -621,6 +623,7 @@ function ConversationDetailsCallButton({
onClick={onClick} onClick={onClick}
variant={ButtonVariant.Details} variant={ButtonVariant.Details}
> >
{/* eslint-disable-next-line local-rules/valid-i18n-keys */}
{i18n(type)} {i18n(type)}
</Button> </Button>
); );

View file

@ -285,14 +285,12 @@ function getConfirmationMessage({
// Requesting a membership since they weren't added by anyone // Requesting a membership since they weren't added by anyone
if (membershipType === StageType.DENY_REQUEST) { if (membershipType === StageType.DENY_REQUEST) {
const key = isAccessControlEnabled( const params = {
conversation.accessControlAddFromInviteLink
)
? 'PendingRequests--deny-for--with-link'
: 'PendingRequests--deny-for';
return i18n(key, {
name: firstMembership.member.title, name: firstMembership.member.title,
}); };
return isAccessControlEnabled(conversation.accessControlAddFromInviteLink)
? i18n('PendingRequests--deny-for--with-link', params)
: i18n('PendingRequests--deny-for', params);
} }
if (membershipType === StageType.APPROVE_REQUEST) { if (membershipType === StageType.APPROVE_REQUEST) {

View file

@ -74,7 +74,8 @@ function MediaSection({
const header = const header =
section.type === 'yearMonth' section.type === 'yearMonth'
? date.format(MONTH_FORMAT) ? date.format(MONTH_FORMAT)
: i18n(section.type); : // eslint-disable-next-line local-rules/valid-i18n-keys
i18n(section.type);
return ( return (
<AttachmentSection <AttachmentSection

View file

@ -15,6 +15,7 @@ describe('setupI18n', () => {
describe('i18n', () => { describe('i18n', () => {
it('returns empty string for unknown string', () => { it('returns empty string for unknown string', () => {
// eslint-disable-next-line local-rules/valid-i18n-keys
assert.strictEqual(i18n('random'), ''); assert.strictEqual(i18n('random'), '');
}); });
it('returns message for given string', () => { it('returns message for given string', () => {

View file

@ -35,7 +35,7 @@ export function format(
): string { ): string {
let seconds = Math.abs(dirtySeconds || 0); let seconds = Math.abs(dirtySeconds || 0);
if (!seconds) { if (!seconds) {
return i18n(capitalizeOff ? 'off' : 'disappearingMessages__off'); return capitalizeOff ? i18n('off') : i18n('disappearingMessages__off');
} }
seconds = Math.max(Math.floor(seconds), 1); seconds = Math.max(Math.floor(seconds), 1);

View file

@ -141,11 +141,10 @@ export function formatDate(
const m = moment(rawTimestamp); const m = moment(rawTimestamp);
const formatI18nKey = const rawFormatString =
Math.abs(m.diff(Date.now())) < 6 * MONTH Math.abs(m.diff(Date.now())) < 6 * MONTH
? 'TimelineDateHeader--date-in-last-6-months' ? i18n('TimelineDateHeader--date-in-last-6-months')
: 'TimelineDateHeader--date-older-than-6-months'; : i18n('TimelineDateHeader--date-older-than-6-months');
const rawFormatString = i18n(formatI18nKey);
const formatString = sanitizeFormatString(rawFormatString, 'LL'); const formatString = sanitizeFormatString(rawFormatString, 'LL');
return m.format(formatString); return m.format(formatString);

View file

@ -8262,6 +8262,11 @@ eslint-plugin-jsx-a11y@^6.5.1:
minimatch "^3.1.2" minimatch "^3.1.2"
semver "^6.3.0" semver "^6.3.0"
eslint-plugin-local-rules@1.3.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/eslint-plugin-local-rules/-/eslint-plugin-local-rules-1.3.2.tgz#b9c9522915faeb9e430309fb909fc1dbcd7aedb3"
integrity sha512-X4ziX+cjlCYnZa+GB1ly3mmj44v2PeIld3tQVAxelY6AMrhHSjz6zsgsT6nt0+X5b7eZnvL/O7Q3pSSK2kF/+Q==
eslint-plugin-mocha@10.1.0: eslint-plugin-mocha@10.1.0:
version "10.1.0" version "10.1.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-mocha/-/eslint-plugin-mocha-10.1.0.tgz#69325414f875be87fb2cb00b2ef33168d4eb7c8d" resolved "https://registry.yarnpkg.com/eslint-plugin-mocha/-/eslint-plugin-mocha-10.1.0.tgz#69325414f875be87fb2cb00b2ef33168d4eb7c8d"