support icu messageformat for translations
This commit is contained in:
parent
b5c514e1d1
commit
6d56f8b8aa
35 changed files with 839 additions and 104 deletions
|
@ -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
|
||||
|
|
|
@ -915,7 +915,7 @@ export const Preferences = ({
|
|||
onSubmit={onUniversalExpireTimerChange}
|
||||
/>
|
||||
)}
|
||||
<SettingsRow title={i18n('disappearingMessages')}>
|
||||
<SettingsRow title={i18n('icu:disappearingMessages')}>
|
||||
<Control
|
||||
left={
|
||||
<>
|
||||
|
|
|
@ -798,7 +798,7 @@ export const ProfileEditor = ({
|
|||
<div className="ProfileEditor__info">
|
||||
<Intl
|
||||
i18n={i18n}
|
||||
id="ProfileEditor--info"
|
||||
id="icu:ProfileEditor--info"
|
||||
components={{
|
||||
learnMore: (
|
||||
<a
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -148,7 +148,7 @@ export class LeftPaneSearchHelper extends LeftPaneHelper<LeftPaneSearchPropsType
|
|||
if (searchConversationName) {
|
||||
noResults = (
|
||||
<Intl
|
||||
id="noSearchResultsInConversation"
|
||||
id="icu:noSearchResultsInConversation"
|
||||
i18n={i18n}
|
||||
components={{
|
||||
searchTerm,
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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',
|
||||
};
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue