diff --git a/ts/components/basic/I18n.tsx b/ts/components/basic/I18n.tsx index be6ba33a9..d58a5153d 100644 --- a/ts/components/basic/I18n.tsx +++ b/ts/components/basic/I18n.tsx @@ -17,18 +17,75 @@ import { } from './SessionCustomTagRenderer'; /** An array of supported html tags to render if found in a string */ -const supportedFormattingTags = ['b', 'i', 'u', 's', 'br', 'span']; +export const supportedFormattingTags = ['b', 'i', 'u', 's', 'br', 'span']; +/** NOTE: self-closing tags must also be listed in {@link supportedFormattingTags} */ +const supportedSelfClosingFormattingTags = ['br']; -/** A regex to match supported formatting tags */ -const formattingTagRegex = new RegExp( - `<(?:${supportedFormattingTags.join('|')})>.*?`, - 'g' -); +function createSupportedFormattingTagsRegex() { + return new RegExp( + `<(?:${supportedFormattingTags.join('|')})>.*?|<(?:${supportedSelfClosingFormattingTags.join('|')})\\/>`, + 'g' + ); +} + +function createSupportedCustomTagsRegex() { + return new RegExp(`<(${supportedCustomTags.join('|')})/>`, 'g'); +} + +/** + * Replaces all html tag identifiers with their escaped equivalents + * @param str The string to sanitize + * @param identifier The identifier to use for the args. Use this if you want to de-sanitize the args later. + * @returns The sanitized string + */ +export function sanitizeHtmlTags(str: string, identifier: string = ''): string { + if (identifier && /[a-zA-Z0-9>/g, `${identifier}>${identifier}`); +} + +/** + * Replaces all sanitized html tags with their real equivalents + * @param str The string to de-sanitize + * @param identifier The identifier used when the args were sanitized + * @returns The de-sanitized string + */ +export function deSanitizeHtmlTags(str: string, identifier: string): string { + if (!identifier || /[a-zA-Z0-9>`, 'g'); + return str + .replace(new RegExp(`${identifier}&${identifier}`, 'g'), '&') + .replace(new RegExp(`${identifier}<${identifier}`, 'g'), '<') + .replace(new RegExp(`${identifier}>${identifier}`, 'g'), '>'); +} + +/** + * Sanitizes the args to be used in the i18n function + * @param args The args to sanitize + * @param identifier The identifier to use for the args. Use this if you want to de-sanitize the args later. + * @returns The sanitized args + */ +export function sanitizeArgs( + args: Record, + identifier?: string +): Record { + return Object.fromEntries( + Object.entries(args).map(([key, value]) => [ + key, + typeof value === 'string' ? sanitizeHtmlTags(value, identifier) : value, + ]) + ); +} const StyledHtmlRenderer = styled.span<{ isDarkTheme: boolean }>` - span { + * > span { color: ${props => (props.isDarkTheme ? 'var(--primary-color)' : 'var(--text-primary-color)')}; } `; @@ -53,14 +110,15 @@ const StyledHtmlRenderer = styled.span<{ isDarkTheme: boolean }>` */ export const I18n = (props: I18nProps) => { const isDarkMode = useIsDarkTheme(); - const i18nArgs = 'args' in props ? props.args : undefined; + const containsFormattingTags = createSupportedFormattingTagsRegex().test(props.token); + + const args = 'args' in props ? props.args : undefined; + const i18nArgs = args && containsFormattingTags ? sanitizeArgs(args) : args; - const i18nString = window.i18n( + let i18nString: string = window.i18n( ...([props.token, i18nArgs] as GetMessageArgs) ); - const containsFormattingTag = i18nString.match(formattingTagRegex); - let startTag: CustomTag | null = null; let endTag: CustomTag | null = null; @@ -69,37 +127,38 @@ export const I18n = (props: I18nProps) => { * @param group - The custom tag, without the angle brackets. * @param index - The index of the match in the string. */ - i18nString.replace(customTagRegex, (match: string, group: CustomTag, index: number) => { - if (index === 0) { - startTag = group; - } else if (index === i18nString.length - match.length) { - endTag = group; - } else { - /** - * If the match is not at the start or end of the string, throw an error. - * NOTE: This should never happen as this rule is enforced when the dictionary is generated. - */ - throw new Error( - `Custom tag ${group} (${match}) is not at the start or end (i=${index}) of the string: ${i18nString}` - ); - } + i18nString = i18nString.replace( + createSupportedCustomTagsRegex(), + (match: string, group: CustomTag, index: number) => { + if (index === 0) { + startTag = group; + } else if (index === i18nString.length - match.length) { + endTag = group; + } else { + /** + * If the match is not at the start or end of the string, throw an error. + * NOTE: This should never happen as this rule is enforced when the dictionary is generated. + */ + throw new Error( + `Custom tag ${group} (${match}) is not at the start or end (i=${index}) of the string: ${i18nString}` + ); + } - return ''; - }); + return ''; + } + ); - const content = containsFormattingTag ? ( + const content = createSupportedFormattingTagsRegex().test(i18nString) ? ( /** If the string contains a relevant formatting tag, render it as HTML */ - + ) : ( i18nString ); - const Comp = props.as ?? Fragment; - return ( - + {startTag ? ( (props: I18nProps) => { tagProps={props.endTagProps as CustomTagProps} /> ) : null} - + ); }; diff --git a/ts/util/i18n.ts b/ts/util/i18n.ts index 16fbc12b7..7376e2b8f 100644 --- a/ts/util/i18n.ts +++ b/ts/util/i18n.ts @@ -12,11 +12,11 @@ import { subMilliseconds, } from 'date-fns'; import timeLocales from 'date-fns/locale'; -import { isUndefined } from 'lodash'; import { GetNetworkTime } from '../session/apis/snode_api/getNetworkTime'; -import { DURATION_SECONDS, LOCALE_DEFAULTS } from '../session/constants'; +import { DURATION_SECONDS } from '../session/constants'; import { updateLocale } from '../state/ducks/dictionary'; import { + ArgsRecordExcludingDefaults, DictionaryWithoutPluralStrings, GetMessageArgs, LocalizerDictionary, @@ -24,6 +24,8 @@ import { PluralKey, PluralString, } from '../types/Localizer'; +import { deSanitizeHtmlTags, sanitizeArgs } from '../components/basic/I18n'; +import { LOCALE_DEFAULTS } from '../localization/constants'; export function loadDictionary(locale: Locale) { return import(`../../_locales/${locale}/messages.json`) as Promise; @@ -125,6 +127,9 @@ function getStringForCardinalRule( const isPluralForm = (localizedString: string): localizedString is PluralString => /{\w+, plural, one \[.+\] other \[.+\]}/g.test(localizedString); +const isStringWithArgs = (localizedString: string): localizedString is any => + localizedString.includes('{'); + /** * Logs an i18n message to the console. * @param message - The message to log. @@ -137,6 +142,14 @@ function i18nLog(message: string) { (window?.log?.error ?? console.log)(message); } +export function getLocale(): Locale { + return window?.inboxStore?.getState().dictionary.locale ?? window?.locale; +} + +function getDictionary(): LocalizerDictionary { + return window?.inboxStore?.getState().dictionary.dictionary; +} + /** * Sets up the i18n function with the provided locale and messages. * @@ -150,6 +163,8 @@ export const setupi18n = (locale: Locale, dictionary: LocalizerDictionary) => { throw new Error('i18n: locale parameter is required'); } + window.locale = locale; + if (!dictionary) { throw new Error('i18n: messages parameter is required'); } @@ -160,48 +175,21 @@ export const setupi18n = (locale: Locale, dictionary: LocalizerDictionary) => { } i18nLog('i18n setup'); - /** - * Retrieves a localized message string, substituting variables where necessary. - * - * @param token - The token identifying the message to retrieve. - * @param args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic variables. - * - * @returns The localized message string with substitutions applied. - * - * @example - * // The string greeting is 'Hello, {name}!' in the current locale - * window.i18n('greeting', { name: 'Alice' }); - * // => 'Hello, Alice!' - */ - function getMessage( + function getRawMessage( ...[token, args]: GetMessageArgs - ): R { + ): R | T { try { - const { - inboxStore, - sessionFeatureFlags: { replaceLocalizedStringsWithKeys }, - } = window; - - if (replaceLocalizedStringsWithKeys) { - return token as R; - } - - const storedDictionary = - inboxStore && 'getState' in inboxStore && typeof inboxStore.getState === 'function' - ? (inboxStore.getState().dictionary.dictionary as LocalizerDictionary) - : undefined; - - if (!storedDictionary) { - i18nLog(`i18n: Stored dictionary not found, using setup dictionary as fallback`); + if (window?.sessionFeatureFlags?.replaceLocalizedStringsWithKeys) { + return token as T; } - const localizedDictionary = storedDictionary ?? dictionary; + const localizedDictionary = getDictionary() ?? dictionary; let localizedString = localizedDictionary[token] as R; if (!localizedString) { i18nLog(`i18n: Attempted to get translation for nonexistent key '${token}'`); - return token as R; + return token as T; } /** If a localized string does not have any arguments to substitute it is returned with no @@ -221,7 +209,8 @@ export const setupi18n = (locale: Locale, dictionary: LocalizerDictionary) => { } else { const num = args?.[pluralKey as keyof typeof args] ?? 0; - const cardinalRule = new Intl.PluralRules(locale).select(num); + const currentLocale = getLocale() ?? locale; + const cardinalRule = new Intl.PluralRules(currentLocale).select(num); const pluralString = getStringForCardinalRule(localizedString, cardinalRule); @@ -229,37 +218,66 @@ export const setupi18n = (locale: Locale, dictionary: LocalizerDictionary) => { i18nLog( `i18n: Plural string not found for cardinal '${cardinalRule}': '${localizedString}'` ); - return token as R; + return token as T; } localizedString = pluralString.replaceAll('#', `${num}`) as R; } } + return localizedString; + } catch (error) { + i18nLog(`i18n: ${error.message}`); + return token as T; + } + } - /** Find and replace the dynamic variables in a localized string and substitute the variables with the provided values */ - return (localizedString as DictionaryWithoutPluralStrings[T]).replace( - /\{(\w+)\}/g, - (match, arg) => { - const substitution: string | undefined = args?.[arg as keyof typeof args]; + function formatMessageWithArgs< + T extends LocalizerToken, + R extends DictionaryWithoutPluralStrings[T], + >(rawMessage: R, args: ArgsRecordExcludingDefaults): R { + /** Find and replace the dynamic variables in a localized string and substitute the variables with the provided values */ + return rawMessage.replace( + /\{(\w+)\}/g, + (match, arg) => + (args?.[arg as keyof typeof args] as string) ?? + LOCALE_DEFAULTS[arg as keyof typeof LOCALE_DEFAULTS] ?? + match + ) as R; + } - if (isUndefined(substitution)) { - const defaultSubstitution = LOCALE_DEFAULTS[arg as keyof typeof LOCALE_DEFAULTS]; + /** + * Retrieves a localized message string, substituting variables where necessary. + * + * @param token - The token identifying the message to retrieve. + * @param args - An optional record of substitution variables and their replacement values. This is required if the string has dynamic variables. + * + * @returns The localized message string with substitutions applied. + * + * @example + * // The string greeting is 'Hello, {name}!' in the current locale + * window.i18n('greeting', { name: 'Alice' }); + * // => 'Hello, Alice!' + */ + function getMessage( + ...[token, args]: GetMessageArgs + ): R | T { + try { + const rawMessage = getRawMessage(...([token, args] as GetMessageArgs)); - return isUndefined(defaultSubstitution) ? match : defaultSubstitution; - } + /** If a localized string does not have any arguments to substitute it is returned with no + * changes. We also need to check if the string contains a curly bracket as if it does + * there might be a default arg */ + if (!args && !isStringWithArgs(rawMessage)) { + return rawMessage; + } - // TODO: figure out why is was type never and fix the type - return (substitution as string).toString(); - } - ) as R; + return formatMessageWithArgs(rawMessage as any, args as any) as R; } catch (error) { i18nLog(`i18n: ${error.message}`); return token as R; } } - window.getLocale = () => locale; - /** * Retrieves a localized message string, substituting variables where necessary. Then strips the message of any HTML and custom tags. * @@ -276,18 +294,21 @@ export const setupi18n = (locale: Locale, dictionary: LocalizerDictionary) => { getMessage.stripped = ( ...[token, args]: GetMessageArgs ): R => { + const sanitizedArgs = args ? sanitizeArgs(args, '\u200B') : undefined; + const i18nString = getMessage( - ...([token, args] as GetMessageArgs) + ...([token, sanitizedArgs] as GetMessageArgs) ); - return i18nString.replaceAll(/<[^>]*>/g, '') as R; + const strippedString = i18nString.replaceAll(/<[^>]*>/g, ''); + + return deSanitizeHtmlTags(strippedString, '\u200B') as R; }; - return getMessage; -}; + getMessage.getRawMessage = getRawMessage; + getMessage.formatMessageWithArgs = formatMessageWithArgs; -export const getI18nFunction = (stripTags: boolean) => { - return stripTags ? window.i18n.stripped : window.i18n; + return getMessage; }; // eslint-disable-next-line import/no-mutable-exports @@ -298,7 +319,7 @@ export const loadEmojiPanelI18n = async () => { return undefined; } - const lang = window.getLocale(); + const lang = getLocale(); if (lang !== 'en') { try { const langData = await import(`@emoji-mart/data/i18n/${lang}.json`); @@ -315,14 +336,13 @@ export const loadEmojiPanelI18n = async () => { return undefined; }; -export const formatTimeDistance = ( - durationSeconds: number, - baseDate: Date = new Date(0), +export const formatTimeDuration = ( + durationMs: number, options?: Omit ) => { - const locale = window.getLocale(); + const locale = getLocale(); - return formatDistanceStrict(new Date(durationSeconds * 1000), baseDate, { + return formatDistanceStrict(new Date(durationMs), new Date(0), { locale: timeLocaleMap[locale], ...options, }); @@ -456,7 +476,7 @@ export const formatTimeDistanceToNow = ( durationSeconds: number, options?: Omit ) => { - const locale = window.getLocale(); + const locale = getLocale(); return formatDistanceToNowStrict(durationSeconds * 1000, { locale: timeLocaleMap[locale], ...options, @@ -464,7 +484,7 @@ export const formatTimeDistanceToNow = ( }; export const formatDateDistanceWithOffset = (date: Date): string => { - const locale = window.getLocale(); + const locale = getLocale(); const adjustedDate = subMilliseconds(date, GetNetworkTime.getLatestTimestampOffset()); return formatDistanceToNow(adjustedDate, { addSuffix: true, locale: timeLocaleMap[locale] }); }; diff --git a/ts/window.d.ts b/ts/window.d.ts index 6283d7d1c..888f2725a 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -49,7 +49,8 @@ declare global { ...[token, args]: GetMessageArgs ) => R; }; - getLocale: () => Locale; + /** NOTE: This locale is a readonly backup of the locale in the store. Use {@link getLocale} instead. */ + locale: Readonly; log: any; sessionFeatureFlags: { useOnionRequests: boolean;