ICU types

This commit is contained in:
Fedor Indutny 2024-03-04 10:03:11 -08:00 committed by GitHub
parent 38adef4233
commit 78f4e96297
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 583 additions and 1182 deletions

View file

@ -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"

View file

@ -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,
});
}

View file

@ -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"

View file

@ -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"

View file

@ -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)}

View file

@ -414,7 +414,7 @@ export function EditUsernameModalBody({
}}
>
{i18n('icu:ProfileEditor--username--reservation-gone', {
username: reservation?.username ?? nickname,
username: reservation?.username ?? nickname ?? '',
})}
</ConfirmationDialog>
)}

View file

@ -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: '😛' }}
/>
);
}

View file

@ -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;

View file

@ -764,7 +764,7 @@ export function ProfileEditor({
]}
>
{i18n('icu:ProfileEditor--username--confirm-delete-body-2', {
username,
username: username ?? '',
})}
</ConfirmationDialog>
)}

View file

@ -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>

View file

@ -231,7 +231,7 @@ function GroupStoryItem({
{i18n('icu:StoriesSettings__group-story-subtitle')}
&nbsp;&middot;&nbsp;
{i18n('icu:StoriesSettings__viewers', {
count: groupStory.membersCount,
count: groupStory.membersCount ?? 0,
})}
</span>
</span>

View file

@ -325,7 +325,7 @@ export function renderToast({
>
{i18n('icu:decryptionErrorToast', {
name,
deviceId,
deviceId: String(deviceId),
})}
</Toast>
);

View file

@ -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>
);

View file

@ -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

View file

@ -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"

View file

@ -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}>

View file

@ -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

View file

@ -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={() => (

View file

@ -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>

View file

@ -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;

View file

@ -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({

View file

@ -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>
),
}}

View file

@ -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>
);

View file

@ -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) {

View 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);
});

View file

@ -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;

View file

@ -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');
});

View file

@ -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;

View file

@ -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;

View 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;
}

View file

@ -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);
},
});

View file

@ -77,7 +77,7 @@ export function getNotificationTextForMessage(
if (shouldIncludeEmoji) {
return window.i18n('icu:message--getNotificationText--text-with-emoji', {
text: result.body,
emoji,
emoji: emoji ?? '',
});
}

View file

@ -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;