// this file is a weird one as it is used by both sides of electron at the same time import { Duration, FormatDistanceStrictOptions, FormatDistanceToNowStrictOptions, formatDistanceStrict, formatDistanceToNow, formatDistanceToNowStrict, formatDuration, intervalToDuration, subMilliseconds, } from 'date-fns'; import timeLocales from 'date-fns/locale'; import { GetNetworkTime } from '../session/apis/snode_api/getNetworkTime'; import { DURATION_SECONDS } from '../session/constants'; import { updateLocale } from '../state/ducks/dictionary'; import { ArgsRecordExcludingDefaults, DictionaryWithoutPluralStrings, GetMessageArgs, LocalizerDictionary, LocalizerToken, 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; } const timeLocaleMap = { ar: timeLocales.ar, be: timeLocales.be, bg: timeLocales.bg, ca: timeLocales.ca, cs: timeLocales.cs, da: timeLocales.da, de: timeLocales.de, el: timeLocales.el, en: timeLocales.enUS, eo: timeLocales.eo, es: timeLocales.es, /** TODO - Check this */ es_419: timeLocales.es, et: timeLocales.et, fa: timeLocales.faIR, fi: timeLocales.fi, /** TODO - Check this */ fil: timeLocales.fi, fr: timeLocales.fr, he: timeLocales.he, hi: timeLocales.hi, hr: timeLocales.hr, hu: timeLocales.hu, /** TODO - Check this */ 'hy-AM': timeLocales.hy, id: timeLocales.id, it: timeLocales.it, ja: timeLocales.ja, ka: timeLocales.ka, km: timeLocales.km, /** TODO - Check this */ kmr: timeLocales.km, kn: timeLocales.kn, ko: timeLocales.ko, lt: timeLocales.lt, lv: timeLocales.lv, mk: timeLocales.mk, nb: timeLocales.nb, nl: timeLocales.nl, /** TODO - Find this this */ no: timeLocales.enUS, /** TODO - Find this this */ pa: timeLocales.enUS, pl: timeLocales.pl, pt_BR: timeLocales.ptBR, pt_PT: timeLocales.pt, ro: timeLocales.ro, ru: timeLocales.ru, /** TODO - Find this this */ si: timeLocales.enUS, sk: timeLocales.sk, sl: timeLocales.sl, sq: timeLocales.sq, sr: timeLocales.sr, sv: timeLocales.sv, ta: timeLocales.ta, th: timeLocales.th, /** TODO - Find this this */ tl: timeLocales.enUS, tr: timeLocales.tr, uk: timeLocales.uk, uz: timeLocales.uz, vi: timeLocales.vi, zh_CN: timeLocales.zhCN, zh_TW: timeLocales.zhTW, }; export type Locale = keyof typeof timeLocaleMap; function getPluralKey(string: PluralString): R { const match = /{(\w+), plural, one \[.+\] other \[.+\]}/g.exec(string); return (match?.[1] ?? undefined) as R; } function getStringForCardinalRule( localizedString: string, cardinalRule: Intl.LDMLPluralRule ): string | undefined { // TODO: investigate if this is the best way to handle regex like this const cardinalPluralRegex: Record = { zero: /zero \[(.*?)\]/g, one: /one \[(.*?)\]/g, two: /two \[(.*?)\]/g, few: /few \[(.*?)\]/g, many: /many \[(.*?)\]/g, other: /other \[(.*?)\]/g, }; const regex = cardinalPluralRegex[cardinalRule]; const match = regex.exec(localizedString); return match?.[1] ?? undefined; } 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. * * TODO - Replace this logging method when the new logger is created */ function i18nLog(message: string) { // eslint:disable: no-console // eslint-disable-next-line no-console (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. * * @param locale - The locale to use for translations. * @param dictionary - A dictionary of localized messages. * * @returns A function that retrieves a localized message string, substituting variables where necessary. */ export const setupi18n = (locale: Locale, dictionary: LocalizerDictionary) => { if (!locale) { throw new Error('i18n: locale parameter is required'); } window.locale = locale; if (!dictionary) { throw new Error('i18n: messages parameter is required'); } if (window?.inboxStore) { window.inboxStore.dispatch(updateLocale(locale)); i18nLog('Loaded dictionary dispatch'); } i18nLog('i18n setup'); function getRawMessage( ...[token, args]: GetMessageArgs ): R | T { try { if (window?.sessionFeatureFlags?.replaceLocalizedStringsWithKeys) { return token as T; } 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 T; } /** 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 && !localizedString.includes('{')) { return localizedString; } if (isPluralForm(localizedString)) { const pluralKey = getPluralKey(localizedString); if (!pluralKey) { i18nLog( `i18n: Attempted to nonexistent pluralKey for plural form string '${localizedString}'` ); } else { const num = args?.[pluralKey as keyof typeof args] ?? 0; const currentLocale = getLocale() ?? locale; const cardinalRule = new Intl.PluralRules(currentLocale).select(num); const pluralString = getStringForCardinalRule(localizedString, cardinalRule); if (!pluralString) { i18nLog( `i18n: Plural string not found for cardinal '${cardinalRule}': '${localizedString}'` ); return token as T; } localizedString = pluralString.replaceAll('#', `${num}`) as R; } } return localizedString; } catch (error) { i18nLog(`i18n: ${error.message}`); return token as T; } } 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; } /** * 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)); /** 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; } return formatMessageWithArgs(rawMessage as any, args as any) as R; } catch (error) { i18nLog(`i18n: ${error.message}`); return token as R; } } /** * Retrieves a localized message string, substituting variables where necessary. Then strips the message of any HTML and custom tags. * * @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. Any HTML and custom tags are removed. * * @example * // The string greeting is 'Hello, {name}! Welcome!' in the current locale * window.i18n.stripped('greeting', { name: 'Alice' }); * // => 'Hello, Alice! Welcome!' */ getMessage.stripped = ( ...[token, args]: GetMessageArgs ): R => { const sanitizedArgs = args ? sanitizeArgs(args, '\u200B') : undefined; const i18nString = getMessage( ...([token, sanitizedArgs] as GetMessageArgs) ); const strippedString = i18nString.replaceAll(/<[^>]*>/g, ''); return deSanitizeHtmlTags(strippedString, '\u200B') as R; }; getMessage.getRawMessage = getRawMessage; getMessage.formatMessageWithArgs = formatMessageWithArgs; return getMessage; }; // eslint-disable-next-line import/no-mutable-exports export let langNotSupportedMessageShown = false; export const loadEmojiPanelI18n = async () => { if (!window) { return undefined; } const lang = getLocale(); if (lang !== 'en') { try { const langData = await import(`@emoji-mart/data/i18n/${lang}.json`); return langData; } catch (err) { if (!langNotSupportedMessageShown) { window?.log?.warn( 'Language is not supported by emoji-mart package. See https://github.com/missive/emoji-mart/tree/main/packages/emoji-mart-data/i18n' ); langNotSupportedMessageShown = true; } } } return undefined; }; export const formatTimeDuration = ( durationMs: number, options?: Omit ) => { const locale = getLocale(); return formatDistanceStrict(new Date(durationMs), new Date(0), { locale: timeLocaleMap[locale], ...options, }); }; /** * date-fns `intervalToDuration` takes a duration in ms. * This is a simple wrapper to avoid duplicating this (and not forget about it). * * Note: * - date-fns intervalToDuration returns doesn't return 2w for 14d and such, so this forces it to be used. * - this will throw if the duration is > 4 weeks * * @param seconds the seconds to get the durations from * @returns a date-fns `Duration` type with the fields set */ const secondsToDuration = (seconds: number): Duration => { if (seconds > 3600 * 24 * 28) { throw new Error('secondsToDuration cannot handle more than 4 weeks for now'); } const duration = intervalToDuration({ start: 0, end: new Date(seconds * 1000) }); if (!duration) { throw new Error('intervalToDuration failed to convert duration'); } if (duration.days) { duration.weeks = Math.floor(duration.days / 7); duration.days %= 7; } return duration; }; /** * We decided against localizing the abbreviated durations like 1h, 1m, 1s as most apps don't. * This function just replaces any long form of "seconds?" to "s", "minutes?" to "m", etc. * * Note: * We don't replace to 'months' as it would be the same as 'minutes', so this function shouldn't be used for a string containing months or longer units in it. * * Date-fns also doesn't support the 'narrow' syntax for formatDistanceStrict and even if it did, minutes are abbreviated as 'min' in english. * * @param unlocalized the string containing the units to abbreviate * @returns the string with abbreviated units */ const unlocalizedDurationToAbbreviated = (unlocalized: string): string => { return unlocalized .replace(/ weeks?/g, 'w') .replace(/ days?/g, 'd') .replace(/ hours?/g, 'h') .replace(/ minutes?/g, 'm') .replace(/ seconds?/g, 's'); }; /** * Format an expiring/disappearing message timer to its abbreviated form. * Note: we don't localize this, and cannot have a value > 4 weeks * * @param timerSeconds the timer to format, in seconds * @returns '1h' for a duration of 3600s. */ export const formatAbbreviatedExpireTimer = (timerSeconds: number) => { // Note: we keep this function in this file even if it is not localizing anything // so we have access to timeLocaleMap.en. if (timerSeconds > DURATION_SECONDS.WEEKS * 4) { throw new Error('formatAbbreviatedExpireTimer is not design to handle >4 weeks durations '); } const duration = secondsToDuration(timerSeconds); const unlocalized = formatDuration(duration, { locale: timeLocaleMap.en, }); return unlocalizedDurationToAbbreviated(unlocalized); }; /** * Format an expiring/disappearing message timer to its abbreviated form. * Note: we don't localize this, and cannot have a value > 4 weeks * * @param timerSeconds the timer to format, in seconds * @returns '1h' for a duration of 3600s. */ export const formatAbbreviatedExpireDoubleTimer = (timerSeconds: number) => { // Note: we keep this function in this file even if it is not localizing anything // so we have access to timeLocaleMap.en. if (timerSeconds > DURATION_SECONDS.WEEKS * 4) { throw new Error( 'formatAbbreviatedExpireDoubleTimer is not design to handle >4 weeks durations ' ); } if (timerSeconds <= 0) { return ['0s']; } const duration = secondsToDuration(timerSeconds); const format: Array = []; if (duration.months || duration.years) { throw new Error("we don't support years or months to be !== 0"); } if (duration.weeks && format.length < 2) { format.push('weeks'); } if (duration.days && format.length < 2) { format.push('days'); } if (duration.hours && format.length < 2) { format.push('hours'); } if (duration.minutes && format.length < 2) { format.push('minutes'); } if (duration.seconds && format.length < 2) { format.push('seconds'); } const unlocalized = formatDuration(duration, { locale: timeLocaleMap.en, delimiter: '#', format, }); return unlocalizedDurationToAbbreviated(unlocalized).split('#'); }; export const formatTimeDistanceToNow = ( durationSeconds: number, options?: Omit ) => { const locale = getLocale(); return formatDistanceToNowStrict(durationSeconds * 1000, { locale: timeLocaleMap[locale], ...options, }); }; export const formatDateDistanceWithOffset = (date: Date): string => { const locale = getLocale(); const adjustedDate = subMilliseconds(date, GetNetworkTime.getLatestTimestampOffset()); return formatDistanceToNow(adjustedDate, { addSuffix: true, locale: timeLocaleMap[locale] }); }; // RTL Support export type HTMLDirection = 'ltr' | 'rtl'; export function isRtlBody(): boolean { const body = document.getElementsByTagName('body').item(0); return body?.classList.contains('rtl') || false; } export const useHTMLDirection = (): HTMLDirection => (isRtlBody() ? 'rtl' : 'ltr');