support icu messageformat for translations

This commit is contained in:
Jamie Kyle 2022-10-03 14:19:54 -07:00 committed by GitHub
parent b5c514e1d1
commit 6d56f8b8aa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 839 additions and 104 deletions

View file

@ -3,11 +3,17 @@
import React from 'react';
import type { FormatXMLElementFn } from 'intl-messageformat';
import type { LocalizerType, RenderTextCallbackType } from '../types/Util';
import type { ReplacementValuesType } from '../types/I18N';
import * as log from '../logging/log';
import { strictAssert } from '../util/assert';
export type FullJSXType = Array<JSX.Element | string> | JSX.Element | string;
export type FullJSXType =
| FormatXMLElementFn<JSX.Element | string>
| Array<JSX.Element | string>
| JSX.Element
| string;
export type IntlComponentsType =
| undefined
| Array<FullJSXType>
@ -32,7 +38,7 @@ export class Intl extends React.Component<Props> {
index: number,
placeholderName: string,
key: number
): FullJSXType | null {
): JSX.Element | null {
const { id, components } = this.props;
if (!components) {
@ -75,6 +81,15 @@ export class Intl extends React.Component<Props> {
return null;
}
if (!i18n.isLegacyFormat(id)) {
strictAssert(
!Array.isArray(components),
`components cannot be an array for ICU message ${id}`
);
const intl = i18n.getIntl();
return intl.formatMessage({ id }, components);
}
const text = i18n(id);
const results: Array<
string | JSX.Element | Array<string | JSX.Element> | null

View file

@ -915,7 +915,7 @@ export const Preferences = ({
onSubmit={onUniversalExpireTimerChange}
/>
)}
<SettingsRow title={i18n('disappearingMessages')}>
<SettingsRow title={i18n('icu:disappearingMessages')}>
<Control
left={
<>

View file

@ -798,7 +798,7 @@ export const ProfileEditor = ({
<div className="ProfileEditor__info">
<Intl
i18n={i18n}
id="ProfileEditor--info"
id="icu:ProfileEditor--info"
components={{
learnMore: (
<a

View file

@ -347,7 +347,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
const muteOptions = getMuteOptions(muteExpiresAt, i18n);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const disappearingTitle = i18n('disappearingMessages') as any;
const disappearingTitle = i18n('icu:disappearingMessages') as any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const muteTitle = i18n('muteNotificationsTitle') as any;
const isGroup = type === 'group';

View file

@ -98,12 +98,12 @@ function InstallScreenQrCode(
<span className={classNames(getQrCodeClassName('__error-message'))}>
<Intl
i18n={i18n}
id="Install__qr-failed"
components={[
<a href={QR_CODE_FAILED_LINK}>
{i18n('Install__qr-failed__learn-more')}
</a>,
]}
id="icu:Install__qr-failed"
components={{
learnMoreLink: children => (
<a href={QR_CODE_FAILED_LINK}>{children}</a>
),
}}
/>
</span>
);

View file

@ -120,15 +120,17 @@ export class LeftPaneInboxHelper extends LeftPaneHelper<LeftPaneInboxPropsType>
<div>
<Intl
i18n={i18n}
id="emptyInboxMessage"
components={[
<span>
<strong>{i18n('composeIcon')}</strong>
<span className="module-left-pane__empty--composer_icon">
<i className="module-left-pane__empty--composer_icon--icon" />
id="icu:emptyInboxMessage"
components={{
composeIcon: (
<span>
<strong>{i18n('composeIcon')}</strong>
<span className="module-left-pane__empty--composer_icon">
<i className="module-left-pane__empty--composer_icon--icon" />
</span>
</span>
</span>,
]}
),
}}
/>
</div>
</div>

View file

@ -148,7 +148,7 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
if (searchConversationName) {
noResults = (
<Intl
id="noSearchResultsInConversation"
id="icu:noSearchResultsInConversation"
i18n={i18n}
components={{
searchTerm,

View file

@ -199,7 +199,7 @@ export class LeftPaneSetGroupMetadataHelper extends LeftPaneHelper<LeftPaneSetGr
<section className="module-left-pane__header__form__expire-timer">
<div className="module-left-pane__header__form__expire-timer__label">
{i18n('disappearingMessages')}
{i18n('icu:disappearingMessages')}
</div>
<DisappearingTimerSelect
i18n={i18n}

View file

@ -89,6 +89,10 @@ function manualReconnect(): NoopActionType {
};
}
const intlNotSetup = () => {
throw new Error('i18n not yet set up');
};
// Reducer
export function getEmptyState(): UserStateType {
@ -114,16 +118,11 @@ export function getEmptyState(): UserStateType {
platform: 'unknown',
},
theme: ThemeType.light,
i18n: Object.assign(
() => {
throw new Error('i18n not yet set up');
},
{
getLocale() {
throw new Error('i18n not yet set up');
},
}
),
i18n: Object.assign(intlNotSetup, {
getLocale: intlNotSetup,
getIntl: intlNotSetup,
isLegacyFormat: intlNotSetup,
}),
localeMessages: {},
version: '0.0.0',
};

View file

@ -34,6 +34,15 @@ describe('setupI18n', () => {
'Someone set the disappearing message time to 5 minutes.'
);
});
it('returns a modern icu message formatted', () => {
const actual = i18n('icu:ProfileEditor--info', {
learnMore: 'LEARN MORE',
});
assert.equal(
actual,
'Your profile is encrypted. Your profile and changes to it will be visible to your contacts and when you start or accept new chats. LEARN MORE'
);
});
});
describe('getLocale', () => {
@ -42,4 +51,26 @@ describe('setupI18n', () => {
assert.isAtLeast(locale.trim().length, 2);
});
});
describe('getIntl', () => {
it('returns the intl object to call formatMessage()', () => {
const intl = i18n.getIntl();
assert.isObject(intl);
const result = intl.formatMessage(
{ id: 'icu:emptyInboxMessage' },
{ composeIcon: 'ICONIC' }
);
assert.equal(
result,
'Click the ICONIC above and search for your contacts or groups to message.'
);
});
});
describe('isLegacyFormat', () => {
it('returns false for new format', () => {
assert.isFalse(i18n.isLegacyFormat('icu:ProfileEditor--info'));
assert.isTrue(i18n.isLegacyFormat('softwareAcknowledgments'));
});
});
});

View file

@ -67,7 +67,11 @@ describe('MentionCompletion', () => {
const options: MentionCompletionOptions = {
getPreferredBadge: () => undefined,
i18n: Object.assign(sinon.stub(), { getLocale: sinon.stub() }),
i18n: Object.assign(sinon.stub(), {
getLocale: sinon.stub(),
getIntl: sinon.stub(),
isLegacyFormat: sinon.stub(),
}),
me,
memberRepositoryRef,
setMentionPickerElement: sinon.stub(),

View file

@ -9,6 +9,7 @@ export type { LocalizerType } from './Util';
type SmartlingConfigType = {
placeholder_format_custom: string;
string_format_paths?: string;
translate_paths: Array<{
key: string;
path: string;
@ -16,15 +17,10 @@ type SmartlingConfigType = {
}>;
};
type LocaleMessageType = {
message: string;
export type LocaleMessageType = {
message?: string;
messageformat?: string;
description?: string;
placeholders?: {
[name: string]: {
content: string;
example: string;
};
};
};
export type LocaleMessagesType = {

View file

@ -1,6 +1,7 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { IntlShape } from 'react-intl';
import type { UUIDStringType } from './UUID';
export type BodyRangeType = {
@ -31,6 +32,8 @@ export type ReplacementValuesType =
export type LocalizerType = {
(key: string, values?: ReplacementValuesType): string;
getIntl(): IntlShape;
isLegacyFormat(key: string): boolean;
getLocale(): string;
};

View file

@ -1,9 +1,87 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { LocaleMessagesType } from '../types/I18N';
import type { LocalizerType } from '../types/Util';
import memoize from '@formatjs/fast-memoize';
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 * as log from '../logging/log';
import { strictAssert } from './assert';
export const formatters = {
getNumberFormat: memoize((locale, opts) => {
return new Intl.NumberFormat(locale, opts);
}),
getDateTimeFormat: memoize((locale, opts) => {
return new Intl.DateTimeFormat(locale, opts);
}),
getPluralRules: memoize((locale, opts) => {
return new Intl.PluralRules(locale, opts);
}),
};
export function isLocaleMessageType(
value: unknown
): value is LocaleMessageType {
return (
typeof value === 'object' &&
value != null &&
(Object.hasOwn(value, 'message') || Object.hasOwn(value, 'messageformat'))
);
}
export function classifyMessages(messages: LocaleMessagesType): {
icuMessages: Record<string, string>;
legacyMessages: Record<string, string>;
} {
const icuMessages: Record<string, string> = {};
const legacyMessages: Record<string, string> = {};
for (const [key, value] of Object.entries(messages)) {
if (isLocaleMessageType(value)) {
if (value.messageformat != null) {
icuMessages[key] = value.messageformat;
} else if (value.message != null) {
legacyMessages[key] = value.message;
}
}
}
return { icuMessages, legacyMessages };
}
export function createCachedIntl(
locale: string,
icuMessages: Record<string, string>
): IntlShape {
const intlCache = createIntlCache();
const intl = createIntl(
{
locale: locale.replace('_', '-'), // normalize supported locales to browser format
messages: icuMessages,
},
intlCache
);
return intl;
}
export function formatIcuMessage(
intl: IntlShape,
id: string,
substitutions: ReplacementValuesType | undefined
): string {
strictAssert(
!Array.isArray(substitutions),
`substitutions must be an object for ICU message ${id}`
);
const result = intl.formatMessage({ id }, substitutions);
strictAssert(
typeof result === 'string',
'i18n: formatted translation result must be a string, must use <Intl/> component to render JSX'
);
return result;
}
export function setupI18n(
locale: string,
@ -16,14 +94,24 @@ export function setupI18n(
throw new Error('i18n: messages parameter is required');
}
const { icuMessages, legacyMessages } = classifyMessages(messages);
const intl = createCachedIntl(locale, icuMessages);
const getMessage: LocalizerType = (key, substitutions) => {
const entry = messages[key];
if (!entry || !('message' in entry)) {
const messageformat = icuMessages[key];
if (messageformat != null) {
return formatIcuMessage(intl, key, substitutions);
}
const message = legacyMessages[key];
if (message == null) {
log.error(
`i18n: Attempted to get translation for nonexistent key '${key}'`
);
return '';
}
if (Array.isArray(substitutions) && substitutions.length > 1) {
throw new Error(
'Array syntax is not supported with more than one placeholder'
@ -35,8 +123,6 @@ export function setupI18n(
) {
throw new Error('You must provide either a map or an array');
}
const { message } = entry;
if (!substitutions) {
return message;
}
@ -78,6 +164,12 @@ export function setupI18n(
return builder;
};
getMessage.getIntl = () => {
return intl;
};
getMessage.isLegacyFormat = (key: string) => {
return legacyMessages[key] != null;
};
getMessage.getLocale = () => locale;
return getMessage;