ICU types
This commit is contained in:
parent
38adef4233
commit
78f4e96297
42 changed files with 583 additions and 1182 deletions
|
@ -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:
|
||||
"<Intl> must always be provided an 'id' attribute with a literal string",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidMessageKey(messages, key)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `<Intl> id "${key}" not found in _locales/en/messages.json`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isIcuMessageKey(messages, key)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `<Intl> id "${key}" is not an ICU message in _locales/en/messages.json`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDeletedMessageKey(messages, key)) {
|
||||
context.report({
|
||||
node,
|
||||
message: `<Intl> 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: `<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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
|
@ -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: '<emojify>👩</emojify>',
|
||||
},
|
||||
};
|
||||
|
||||
// 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 = <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__ }],
|
||||
},
|
||||
{
|
||||
code: `i18n("icu:emojify")`,
|
||||
options: [{ messagesCacheKey, __mockMessages__ }],
|
||||
},
|
||||
{
|
||||
code: `let jsx = <Intl id="icu:emojify"/>`,
|
||||
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 = <Intl id="legacy_real_message"/>`,
|
||||
options: [{ messagesCacheKey, __mockMessages__ }],
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
'<Intl> 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 = <Intl id={"icu:real_message"}/>`,
|
||||
options: [{ messagesCacheKey, __mockMessages__ }],
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
"<Intl> must always be provided an 'id' attribute with a literal string",
|
||||
type: 'JSXOpeningElement',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'let jsx = <Intl id={`icu:real_message`}/>',
|
||||
options: [{ messagesCacheKey, __mockMessages__ }],
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
"<Intl> must always be provided an 'id' attribute with a literal string",
|
||||
type: 'JSXOpeningElement',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'let jsx = <Intl id={`icu:real_${message}`}/>',
|
||||
options: [{ messagesCacheKey, __mockMessages__ }],
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
"<Intl> 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 = <Intl id="THIS_KEY_SHOULD_NEVER_EXIST"/>`,
|
||||
options: [{ messagesCacheKey, __mockMessages__ }],
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
'<Intl> id "THIS_KEY_SHOULD_NEVER_EXIST" not found in _locales/en/messages.json',
|
||||
type: 'JSXOpeningElement',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: `let jsx = <Intl id={cond ? "icu:real_message" : "icu:real_message"}/>`,
|
||||
options: [{ messagesCacheKey, __mockMessages__ }],
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
"<Intl> must always be provided an 'id' attribute with a literal string",
|
||||
type: 'JSXOpeningElement',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
code: `let jsx = <Intl id={42}/>`,
|
||||
options: [{ messagesCacheKey, __mockMessages__ }],
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
"<Intl> 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 = <Intl id="icu:deleted_message"/>`,
|
||||
options: [{ messagesCacheKey, __mockMessages__ }],
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
'<Intl> 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 = <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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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 = {
|
||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -30,6 +30,7 @@ stylesheets/*.css
|
|||
preload.bundle.*
|
||||
bundles/
|
||||
ts/sql/mainWorker.bundle.js.LICENSE.txt
|
||||
build/ICUMessageParams.d.ts
|
||||
|
||||
# React / TypeScript
|
||||
app/*.js
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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'),
|
||||
};
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -45,7 +45,7 @@ export function CallParticipantCount({
|
|||
if (!isToggleVisible) {
|
||||
return (
|
||||
<span
|
||||
aria-label={i18n('icu:calling__participants', {
|
||||
aria-label={i18n('icu:calling__participants--pluralized', {
|
||||
people: count,
|
||||
})}
|
||||
className="CallControls__Status--InactiveCallParticipantCount"
|
||||
|
@ -57,7 +57,7 @@ export function CallParticipantCount({
|
|||
|
||||
return (
|
||||
<button
|
||||
aria-label={i18n('icu:calling__participants', {
|
||||
aria-label={i18n('icu:calling__participants--pluralized', {
|
||||
people: count,
|
||||
})}
|
||||
className="CallControls__Status--ParticipantCount"
|
||||
|
|
|
@ -570,20 +570,20 @@ export function CallScreen({
|
|||
);
|
||||
} else {
|
||||
message = i18n('icu:CallControls__RaiseHandsToast--one', {
|
||||
name: names[0],
|
||||
name: names[0] ?? '',
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
message = i18n('icu:CallControls__RaiseHandsToast--two', {
|
||||
name: names[0],
|
||||
otherName: names[1],
|
||||
name: names[0] ?? '',
|
||||
otherName: names[1] ?? '',
|
||||
});
|
||||
break;
|
||||
default:
|
||||
message = i18n('icu:CallControls__RaiseHandsToast--more', {
|
||||
name: names[0],
|
||||
otherName: names[1],
|
||||
name: names[0] ?? '',
|
||||
otherName: names[1] ?? '',
|
||||
overflowCount: names.length - 2,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -54,13 +54,11 @@ export function CallingAdhocCallInfo({
|
|||
<div className="CallingAdhocCallInfo module-calling-participants-list">
|
||||
<div className="module-calling-participants-list__header">
|
||||
<div className="module-calling-participants-list__title">
|
||||
{!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')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
@ -82,14 +82,11 @@ export const CallingParticipantsList = React.memo(
|
|||
<div className="module-calling-participants-list">
|
||||
<div className="module-calling-participants-list__header">
|
||||
<div className="module-calling-participants-list__title">
|
||||
{!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')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
@ -406,7 +406,7 @@ export function ConversationList({
|
|||
get(lastMessage, 'text') ||
|
||||
i18n('icu:ConversationList__last-message-undefined'),
|
||||
title,
|
||||
unreadCount,
|
||||
unreadCount: unreadCount ?? 0,
|
||||
})}
|
||||
key={key}
|
||||
badge={getPreferredBadge(badges)}
|
||||
|
|
|
@ -414,7 +414,7 @@ export function EditUsernameModalBody({
|
|||
}}
|
||||
>
|
||||
{i18n('icu:ProfileEditor--username--reservation-gone', {
|
||||
username: reservation?.username ?? nickname,
|
||||
username: reservation?.username ?? nickname ?? '',
|
||||
})}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
// Copyright 2020 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { Meta, StoryFn } from '@storybook/react';
|
||||
import * as React from 'react';
|
||||
|
||||
import type { ComponentMeta } from '../storybook/types';
|
||||
import type { Props } from './Intl';
|
||||
import { Intl } from './Intl';
|
||||
import { setupI18n } from '../util/setupI18n';
|
||||
|
@ -14,68 +14,77 @@ const i18n = setupI18n('en', enMessages);
|
|||
export default {
|
||||
title: 'Components/Intl',
|
||||
component: Intl,
|
||||
} satisfies Meta<Props>;
|
||||
|
||||
const createProps = (overrideProps: Partial<Props> = {}): Props => ({
|
||||
i18n,
|
||||
id: overrideProps.id || '',
|
||||
components: overrideProps.components,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line max-len
|
||||
// eslint-disable-next-line react/function-component-definition, local-rules/valid-i18n-keys
|
||||
const Template: StoryFn<Props> = args => <Intl {...args} />;
|
||||
|
||||
export const NoReplacements = Template.bind({});
|
||||
NoReplacements.args = createProps({
|
||||
id: 'icu:deleteAndRestart',
|
||||
});
|
||||
|
||||
export const SingleStringReplacement = Template.bind({});
|
||||
SingleStringReplacement.args = createProps({
|
||||
id: 'icu:leftTheGroup',
|
||||
components: { name: 'Theodora' },
|
||||
});
|
||||
|
||||
export const SingleTagReplacement = Template.bind({});
|
||||
SingleTagReplacement.args = createProps({
|
||||
id: 'icu:leftTheGroup',
|
||||
components: {
|
||||
name: (
|
||||
<button type="button" key="a-button">
|
||||
Theodora
|
||||
</button>
|
||||
),
|
||||
args: {
|
||||
i18n,
|
||||
id: 'icu:ok',
|
||||
components: undefined,
|
||||
},
|
||||
});
|
||||
} satisfies ComponentMeta<Props<'icu:ok'>>;
|
||||
|
||||
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: <b>Fred</b>,
|
||||
name2: <b>The Fredster</b>,
|
||||
},
|
||||
});
|
||||
|
||||
export function Emoji(): JSX.Element {
|
||||
const customI18n = setupI18n('en', {
|
||||
'icu:emoji': {
|
||||
messageformat: '<emojify>👋</emojify> Hello, world!',
|
||||
},
|
||||
});
|
||||
export function NoReplacements(
|
||||
args: Props<'icu:deleteAndRestart'>
|
||||
): JSX.Element {
|
||||
return <Intl {...args} id="icu:deleteAndRestart" />;
|
||||
}
|
||||
|
||||
export function SingleStringReplacement(
|
||||
args: Props<'icu:leftTheGroup'>
|
||||
): JSX.Element {
|
||||
return (
|
||||
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||
<Intl i18n={customI18n} id="icu:emoji" />
|
||||
<Intl {...args} id="icu:leftTheGroup" components={{ name: 'Theodora' }} />
|
||||
);
|
||||
}
|
||||
|
||||
export function SingleTagReplacement(
|
||||
args: Props<'icu:leftTheGroup'>
|
||||
): JSX.Element {
|
||||
return (
|
||||
<Intl
|
||||
{...args}
|
||||
id="icu:leftTheGroup"
|
||||
components={{
|
||||
name: (
|
||||
<button type="button" key="a-button">
|
||||
Theodora
|
||||
</button>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultipleStringReplacement(
|
||||
args: Props<'icu:changedRightAfterVerify'>
|
||||
): JSX.Element {
|
||||
return (
|
||||
<Intl
|
||||
{...args}
|
||||
id="icu:changedRightAfterVerify"
|
||||
components={{ name1: 'Fred', name2: 'The Fredster' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultipleTagReplacement(
|
||||
args: Props<'icu:changedRightAfterVerify'>
|
||||
): JSX.Element {
|
||||
return (
|
||||
<Intl
|
||||
{...args}
|
||||
id="icu:changedRightAfterVerify"
|
||||
components={{ name1: <b>Fred</b>, name2: <b>The Fredster</b> }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Emoji(
|
||||
args: Props<'icu:Message__reaction-emoji-label--you'>
|
||||
): JSX.Element {
|
||||
return (
|
||||
<Intl
|
||||
{...args}
|
||||
id="icu:Message__reaction-emoji-label--you"
|
||||
components={{ emoji: '😛' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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<JSX.Element | string>
|
||||
| Array<JSX.Element | string>
|
||||
| ReactNode
|
||||
| JSX.Element
|
||||
| string;
|
||||
export type IntlComponentsType = undefined | ReplacementValuesType<FullJSXType>;
|
||||
|
||||
export type Props = {
|
||||
export type Props<Key extends keyof ICUJSXMessageParamsByKeyType> = {
|
||||
/** 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<Key extends keyof ICUJSXMessageParamsByKeyType>({
|
||||
components,
|
||||
id,
|
||||
// Indirection for linter/migration tooling
|
||||
i18n: localizer,
|
||||
}: Props): JSX.Element | null {
|
||||
}: Props<Key>): JSX.Element | null {
|
||||
if (!id) {
|
||||
log.error('Error: Intl id prop not provided');
|
||||
return null;
|
||||
|
|
|
@ -764,7 +764,7 @@ export function ProfileEditor({
|
|||
]}
|
||||
>
|
||||
{i18n('icu:ProfileEditor--username--confirm-delete-body-2', {
|
||||
username,
|
||||
username: username ?? '',
|
||||
})}
|
||||
</ConfirmationDialog>
|
||||
)}
|
||||
|
|
|
@ -580,7 +580,7 @@ export function SendStoryModal({
|
|||
|
||||
<div className="SendStoryModal__distribution-list__description">
|
||||
{i18n('icu:ConversationHero--members', {
|
||||
count: group.membersCount,
|
||||
count: group.membersCount ?? 0,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -853,7 +853,7 @@ export function SendStoryModal({
|
|||
</span>
|
||||
<span className="SendStoryModal__rtl-span">
|
||||
{i18n('icu:ConversationHero--members', {
|
||||
count: group.membersCount,
|
||||
count: group.membersCount ?? 0,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
@ -231,7 +231,7 @@ function GroupStoryItem({
|
|||
{i18n('icu:StoriesSettings__group-story-subtitle')}
|
||||
·
|
||||
{i18n('icu:StoriesSettings__viewers', {
|
||||
count: groupStory.membersCount,
|
||||
count: groupStory.membersCount ?? 0,
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
|
|
|
@ -325,7 +325,7 @@ export function renderToast({
|
|||
>
|
||||
{i18n('icu:decryptionErrorToast', {
|
||||
name,
|
||||
deviceId,
|
||||
deviceId: String(deviceId),
|
||||
})}
|
||||
</Toast>
|
||||
);
|
||||
|
|
|
@ -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<JSX.Element | string> = children => (
|
||||
const learnMoreLink = (parts: Array<string | JSX.Element>) => (
|
||||
<a
|
||||
key="signal-support"
|
||||
href={UNSUPPORTED_OS_URL}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{children}
|
||||
{parts}
|
||||
</a>
|
||||
);
|
||||
|
||||
|
|
|
@ -90,7 +90,7 @@ function GroupNotificationChange({
|
|||
<Intl
|
||||
i18n={i18n}
|
||||
id="icu:joinedTheGroup"
|
||||
components={{ name: otherPeopleWithCommas }}
|
||||
components={{ name: otherPeople[0] }}
|
||||
/>
|
||||
) : (
|
||||
<Intl
|
||||
|
@ -121,7 +121,7 @@ function GroupNotificationChange({
|
|||
<Intl
|
||||
id="icu:multipleLeftTheGroup"
|
||||
i18n={i18n}
|
||||
components={{ name: otherPeopleWithCommas }}
|
||||
components={{ name: otherPeople[0] }}
|
||||
/>
|
||||
) : (
|
||||
<Intl
|
||||
|
|
|
@ -25,7 +25,7 @@ export function GroupV1DisabledActions({
|
|||
components={{
|
||||
// This is a render prop, not a component
|
||||
// eslint-disable-next-line react/no-unstable-nested-components
|
||||
learnMoreLink: (...parts) => {
|
||||
learnMoreLink: parts => {
|
||||
return (
|
||||
<a
|
||||
href="https://support.signal.org/hc/articles/360007319331"
|
||||
|
|
|
@ -13,7 +13,6 @@ import { SignalService as Proto } from '../../protobuf';
|
|||
import type { SmartContactRendererType } from '../../groupChange';
|
||||
import type { PropsType } from './GroupV2Change';
|
||||
import { GroupV2Change } from './GroupV2Change';
|
||||
import type { FullJSXType } from '../Intl';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
|
@ -28,7 +27,7 @@ const INVITEE_A = generateAci();
|
|||
const AccessControlEnum = Proto.AccessControl.AccessRequired;
|
||||
const RoleEnum = Proto.Member.Role;
|
||||
|
||||
const renderContact: SmartContactRendererType<FullJSXType> = (
|
||||
const renderContact: SmartContactRendererType<JSX.Element> = (
|
||||
conversationId: string
|
||||
) => (
|
||||
<React.Fragment key={conversationId}>
|
||||
|
|
|
@ -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<FullJSXType>;
|
||||
renderContact: SmartContactRendererType<JSX.Element>;
|
||||
};
|
||||
|
||||
export type PropsType = PropsDataType &
|
||||
PropsActionsType &
|
||||
PropsHousekeepingType;
|
||||
|
||||
function renderStringToIntl(
|
||||
id: string,
|
||||
function renderStringToIntl<Key extends keyof ICUJSXMessageParamsByKeyType>(
|
||||
id: Key,
|
||||
i18n: LocalizerType,
|
||||
components?: ReplacementValuesType<FullJSXType>
|
||||
): FullJSXType {
|
||||
// eslint-disable-next-line local-rules/valid-i18n-keys
|
||||
components: ICUJSXMessageParamsByKeyType[Key]
|
||||
): JSX.Element {
|
||||
return <Intl id={id} i18n={i18n} components={components} />;
|
||||
}
|
||||
|
||||
|
@ -168,8 +168,8 @@ function GroupV2Detail({
|
|||
i18n: LocalizerType;
|
||||
fromId?: ServiceIdString;
|
||||
ourAci: AciString | undefined;
|
||||
renderContact: SmartContactRendererType<FullJSXType>;
|
||||
text: FullJSXType;
|
||||
renderContact: SmartContactRendererType<JSX.Element>;
|
||||
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<FullJSXType>(change, {
|
||||
{renderChange<JSX.Element>(change, {
|
||||
i18n,
|
||||
ourAci,
|
||||
ourPni,
|
||||
renderContact,
|
||||
renderString: renderStringToIntl,
|
||||
renderIntl: renderStringToIntl,
|
||||
}).map(({ detail, isLastText, text }, index) => {
|
||||
return (
|
||||
<GroupV2Detail
|
||||
|
|
|
@ -360,7 +360,7 @@ const renderItem = ({
|
|||
conversationId=""
|
||||
item={items[messageId]}
|
||||
renderAudioAttachment={() => <div>*AudioAttachment*</div>}
|
||||
renderContact={() => '*ContactName*'}
|
||||
renderContact={() => <div>*ContactName*</div>}
|
||||
renderEmojiPicker={() => <div />}
|
||||
renderReactionPicker={() => <div />}
|
||||
renderUniversalTimerNotification={() => (
|
||||
|
|
|
@ -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<string | JSX.Element>
|
||||
): JSX.Element => (
|
||||
<TimelineWarning.Link onClick={reviewConversationNameCollision}>
|
||||
{parts}
|
||||
</TimelineWarning.Link>
|
||||
|
|
|
@ -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<FullJSXType>;
|
||||
renderContact: SmartContactRendererType<JSX.Element>;
|
||||
renderUniversalTimerNotification: () => JSX.Element;
|
||||
i18n: LocalizerType;
|
||||
interactionMode: InteractionModeType;
|
||||
|
|
|
@ -72,8 +72,12 @@ const renderPerson = (
|
|||
isMe?: boolean;
|
||||
title: string;
|
||||
}>
|
||||
): ReactNode =>
|
||||
person.isMe ? i18n('icu:you') : <ContactName title={person.title} />;
|
||||
): JSX.Element =>
|
||||
person.isMe ? (
|
||||
<Intl i18n={i18n} id="icu:you" />
|
||||
) : (
|
||||
<ContactName title={person.title} />
|
||||
);
|
||||
|
||||
export const MessageSearchResult: FunctionComponent<PropsType> = React.memo(
|
||||
function MessageSearchResult({
|
||||
|
|
|
@ -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<string | JSX.Element>) => (
|
||||
<button
|
||||
className={getQrCodeClassName('__link')}
|
||||
onClick={props.retryGetQrCode}
|
||||
|
@ -148,7 +148,7 @@ function InstallScreenQrCode(
|
|||
}}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
{parts}
|
||||
</button>
|
||||
),
|
||||
}}
|
||||
|
|
|
@ -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<JSX.Element | string> = children => (
|
||||
const learnMoreLink = (parts: Array<string | JSX.Element>) => (
|
||||
<a
|
||||
key="signal-support"
|
||||
href={UNSUPPORTED_OS_URL}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{children}
|
||||
{parts}
|
||||
</a>
|
||||
);
|
||||
|
||||
|
|
|
@ -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<T> = (
|
||||
serviceId: ServiceIdString
|
||||
) => T | string;
|
||||
export type StringRendererType<T> = (
|
||||
id: string,
|
||||
i18n: LocalizerType,
|
||||
components?: ReplacementValuesType<T | string | number>
|
||||
) => T | string;
|
||||
type SelectParamsByKeyType<T extends string | JSX.Element> = T extends string
|
||||
? ICUStringMessageParamsByKeyType
|
||||
: ICUJSXMessageParamsByKeyType;
|
||||
|
||||
export type RenderOptionsType<T> = {
|
||||
export type SmartContactRendererType<T extends string | JSX.Element> = (
|
||||
serviceId: ServiceIdString
|
||||
) => T extends string ? string : JSX.Element;
|
||||
|
||||
type StringRendererType<
|
||||
T extends string | JSX.Element,
|
||||
ParamsByKeyType extends SelectParamsByKeyType<T> = SelectParamsByKeyType<T>
|
||||
> = <Key extends keyof ParamsByKeyType>(
|
||||
id: Key,
|
||||
i18n: LocalizerType,
|
||||
components: ParamsByKeyType[Key]
|
||||
) => T;
|
||||
|
||||
export type RenderOptionsType<T extends string | JSX.Element> = {
|
||||
// `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<T>;
|
||||
renderString: StringRendererType<T>;
|
||||
renderIntl: StringRendererType<T>;
|
||||
};
|
||||
|
||||
const AccessControlEnum = Proto.AccessControl.AccessRequired;
|
||||
const RoleEnum = Proto.Member.Role;
|
||||
|
||||
export type RenderChangeResultType<T> = ReadonlyArray<
|
||||
Readonly<{
|
||||
detail: GroupV2ChangeDetailType;
|
||||
text: T | string;
|
||||
export type RenderChangeResultType<T extends string | JSX.Element> =
|
||||
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<T>(
|
||||
export function renderChange<T extends string | JSX.Element>(
|
||||
change: GroupV2ChangeType,
|
||||
options: RenderOptionsType<T>
|
||||
): RenderChangeResultType<T> {
|
||||
|
@ -66,25 +78,32 @@ export function renderChange<T>(
|
|||
});
|
||||
}
|
||||
|
||||
export function renderChangeDetail<T>(
|
||||
function renderChangeDetail<T extends string | JSX.Element>(
|
||||
detail: GroupV2ChangeDetailType,
|
||||
options: RenderOptionsType<T>
|
||||
): T | string | ReadonlyArray<T | string> {
|
||||
): string | T | ReadonlyArray<string | T> {
|
||||
const {
|
||||
from,
|
||||
i18n: localizer,
|
||||
ourAci,
|
||||
ourPni,
|
||||
renderContact,
|
||||
renderString,
|
||||
renderIntl,
|
||||
} = options;
|
||||
|
||||
function i18n(
|
||||
id: string,
|
||||
components?: ReplacementValuesType<T | number | string>
|
||||
) {
|
||||
return renderString(id, localizer, components);
|
||||
}
|
||||
type JSXLocalizerType = <Key extends keyof ICUJSXMessageParamsByKeyType>(
|
||||
key: Key,
|
||||
...values: ICUJSXMessageParamsByKeyType[Key] extends undefined
|
||||
? [undefined?]
|
||||
: [ICUJSXMessageParamsByKeyType[Key]]
|
||||
) => string;
|
||||
|
||||
const i18n = (<Key extends keyof SelectParamsByKeyType<T>>(
|
||||
id: Key,
|
||||
components: SelectParamsByKeyType<T>[Key]
|
||||
): T => {
|
||||
return renderIntl(id, localizer, components);
|
||||
}) as JSXLocalizerType;
|
||||
|
||||
const isOurServiceId = (serviceId?: ServiceIdString): boolean => {
|
||||
if (!serviceId) {
|
||||
|
|
250
ts/scripts/generate-icu-types.ts
Normal file
250
ts/scripts/generate-icu-types.ts
Normal file
|
@ -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<keyof typeof globalMessages>;
|
||||
|
||||
function generateType(
|
||||
name: string,
|
||||
stringType: ts.TypeNode,
|
||||
componentType: ts.TypeNode
|
||||
): ts.Statement {
|
||||
const props = new Array<ts.TypeElement>();
|
||||
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<ts.TypeElement>();
|
||||
|
||||
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<ts.Statement>();
|
||||
|
||||
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);
|
||||
});
|
|
@ -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<string, string>).description?.match(
|
||||
/\(\s*deleted\s+(\d{2,4}\/\d{2}\/\d{2,4})\s*\)/
|
||||
DELETED_REGEXP
|
||||
);
|
||||
if (!match) {
|
||||
return;
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -28,10 +28,6 @@ export type LocaleMessagesType = {
|
|||
[key: string]: LocaleMessageType | SmartlingConfigType;
|
||||
};
|
||||
|
||||
export type ReplacementValuesType<T> = {
|
||||
[key: string]: T;
|
||||
};
|
||||
|
||||
export type LocaleType = {
|
||||
i18n: LocalizerType;
|
||||
messages: LocaleMessagesType;
|
||||
|
|
|
@ -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 extends keyof ICUStringMessageParamsByKeyType>(
|
||||
key: Key,
|
||||
...values: ICUStringMessageParamsByKeyType[Key] extends undefined
|
||||
? [undefined?]
|
||||
: [ICUStringMessageParamsByKeyType[Key]]
|
||||
): string;
|
||||
getIntl(): IntlShape;
|
||||
getLocale(): string;
|
||||
getLocaleMessages(): LocaleMessagesType;
|
||||
|
|
83
ts/util/getICUMessageParams.ts
Normal file
83
ts/util/getICUMessageParams.ts
Normal file
|
@ -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<string>;
|
||||
}
|
||||
>;
|
||||
|
||||
export function getICUMessageParams(
|
||||
message: string,
|
||||
defaultRichTextElementNames: Array<string> = []
|
||||
): Map<string, ICUMessageParamType> {
|
||||
const params = new Map();
|
||||
|
||||
function visitOptions(options: Record<string, PluralOrSelectOption>) {
|
||||
for (const option of Object.values(options)) {
|
||||
visit(option.value);
|
||||
}
|
||||
}
|
||||
|
||||
function visit(elements: ReadonlyArray<MessageFormatElement>) {
|
||||
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;
|
||||
}
|
|
@ -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 extends keyof ICUStringMessageParamsByKeyType>(
|
||||
key: Key,
|
||||
_i18n: unknown,
|
||||
components: ReplacementValuesType<string | number> | 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);
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -77,7 +77,7 @@ export function getNotificationTextForMessage(
|
|||
if (shouldIncludeEmoji) {
|
||||
return window.i18n('icu:message--getNotificationText--text-with-emoji', {
|
||||
text: result.body,
|
||||
emoji,
|
||||
emoji: emoji ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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<string, string | number | Date> | undefined
|
||||
>(substitutions?: Substitutions): Substitutions | undefined {
|
||||
if (!substitutions) {
|
||||
return;
|
||||
}
|
||||
const normalized: ReplacementValuesType = {};
|
||||
const keys = Object.keys(substitutions);
|
||||
if (keys.length === 0) {
|
||||
const normalized: Record<string, string | number | Date> = {};
|
||||
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;
|
||||
|
|
Loading…
Reference in a new issue