// 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 {
ArgsRecord ,
DictionaryWithoutPluralStrings ,
GetMessageArgs ,
LocalizerDictionary ,
LocalizerToken ,
PluralKey ,
PluralString ,
SetupI18nReturnType ,
} from '../types/Localizer' ;
import { deSanitizeHtmlTags , sanitizeArgs } from '../components/basic/I18n' ;
import { LOCALE_DEFAULTS } from '../localization/constants' ;
import { en } from '../localization/locales' ;
export function loadDictionary ( locale : Locale ) {
return import ( ` ../../_locales/ ${ locale } /messages.json ` ) as Promise < LocalizerDictionary > ;
}
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 < R extends PluralKey | undefined > ( 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 < Intl.LDMLPluralRule , RegExp > = {
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 ) ;
/ * *
* Checks if a string contains a dynamic variable .
* @param localizedString - The string to check .
* @returns ` true ` if the string contains a dynamic variable , otherwise ` false ` .
*
* TODO : Change this to a proper type assertion when the type is fixed .
* /
const isStringWithArgs = < R extends DictionaryWithoutPluralStrings [ LocalizerToken ] > (
localizedString : string
) : localizedString is R = > 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 ) ( ` i18n: ${ message } ` ) ;
}
/ * *
* Returns the current locale .
* @param params - An object containing optional parameters .
* @param params . fallback - The fallback locale to use if redux is not available . Defaults to en .
* /
export function getLocale ( params ? : { fallback? : Locale } ) : Locale {
const locale = window ? . inboxStore ? . getState ( ) . dictionary . locale ;
if ( locale ) {
return locale ;
}
if ( params ? . fallback ) {
i18nLog ( ` getLocale: No locale found in redux store. Using fallback: ${ params . fallback } ` ) ;
return params . fallback ;
}
i18nLog ( 'getLocale: No locale found in redux store. No fallback provided. Using en.' ) ;
return 'en' ;
}
/ * *
* Returns the current dictionary .
* @param params - An object containing optional parameters .
* @param params . fallback - The fallback dictionary to use if redux is not available . Defaults to { @link en } .
* /
function getDictionary ( params ? : { fallback? : LocalizerDictionary } ) : LocalizerDictionary {
const dict = window ? . inboxStore ? . getState ( ) . dictionary . dictionary ;
if ( dict ) {
return dict ;
}
if ( params ? . fallback ) {
i18nLog ( 'getDictionary: No dictionary found in redux store. Using fallback.' ) ;
return params . fallback ;
}
i18nLog ( 'getDictionary: No dictionary found in redux store. No fallback provided. Using en.' ) ;
return en ;
}
/ * *
* Sets up the i18n function with the provided locale and messages .
*
* @param params - An object containing optional parameters .
* @param params . initialLocale - The locale to use for translations . Defaults to 'en' .
* @param params . initialDictionary - A dictionary of localized messages . Defaults to { @link en } .
*
* @returns A function that retrieves a localized message string , substituting variables where necessary .
* /
export const setupI18n = ( params : {
initialLocale : Locale ;
initialDictionary : LocalizerDictionary ;
} ) : SetupI18nReturnType = > {
let initialLocale = params . initialLocale ;
let initialDictionary = params . initialDictionary ;
if ( ! initialLocale ) {
initialLocale = 'en' ;
i18nLog ( ` initialLocale not provided in i18n setup. Falling back to ${ initialLocale } ` ) ;
}
if ( ! initialLocale ) {
initialDictionary = en ;
i18nLog ( 'initialDictionary not provided in i18n setup. Falling back.' ) ;
}
if ( window ? . inboxStore ) {
window . inboxStore . dispatch ( updateLocale ( initialLocale ) ) ;
i18nLog ( 'Loaded dictionary dispatch' ) ;
} else {
i18nLog ( 'No redux store found. Not dispatching dictionary update.' ) ;
}
/** NOTE: Because of docstring limitations changes MUST be manually synced between {@link setupI18n.getRawMessage } and {@link window.i18n.getRawMessage } */
/ * *
* Retrieves a localized message string , without substituting any variables . This resolves any plural forms using the given args
* @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 .
*
* NOTE : This is intended to be used to get the raw string then format it with { @link formatMessageWithArgs }
*
* @example
* // The string greeting is 'Hello, {name}!' in the current locale
* window . i18n . getRawMessage ( 'greeting' , { name : 'Alice' } ) ;
* // => 'Hello, {name}!'
*
* // The string search is '{count, plural, one [{found_count} of # match] other [{found_count} of # matches]}' in the current locale
* window . i18n . getRawMessage ( 'search' , { count : 1 , found_count : 1 } ) ;
* // => '{found_count} of {count} match'
* /
function getRawMessage < T extends LocalizerToken , R extends DictionaryWithoutPluralStrings [ T ] > (
. . . [ token , args ] : GetMessageArgs < T >
) : R | T {
try {
if ( window ? . sessionFeatureFlags ? . replaceLocalizedStringsWithKeys ) {
return token as T ;
}
const localizedDictionary = getDictionary ( { fallback : initialDictionary } ) ;
let localizedString = localizedDictionary [ token ] as R ;
if ( ! localizedString ) {
i18nLog ( ` Attempted to get translation for nonexistent key: ' ${ token } ' ` ) ;
return token as T ;
}
/ * * I f a l o c a l i z e d s t r i n g d o e s n o t h a v e a n y a r g u m e n t s t o s u b s t i t u t e i t i s r e t u r n e d w i t h n o
* 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 ( ` Attempted to nonexistent pluralKey for plural form string ' ${ localizedString } ' ` ) ;
} else {
const num = args ? . [ pluralKey as keyof typeof args ] ? ? 0 ;
const currentLocale = getLocale ( ) ? ? initialLocale ;
const cardinalRule = new Intl . PluralRules ( currentLocale ) . select ( num ) ;
const pluralString = getStringForCardinalRule ( localizedString , cardinalRule ) ;
if ( ! pluralString ) {
i18nLog ( ` Plural string not found for cardinal ' ${ cardinalRule } ': ' ${ localizedString } ' ` ) ;
return token as T ;
}
localizedString = pluralString . replaceAll ( '#' , ` ${ num } ` ) as R ;
}
}
return localizedString ;
} catch ( error ) {
i18nLog ( error . message ) ;
return token as T ;
}
}
/** NOTE: Because of docstring limitations changes MUST be manually synced between {@link setupI18n.formatMessageWithArgs } and {@link window.i18n.formatMessageWithArgs } */
/ * *
* Formats a localized message string with arguments and returns the formatted string .
* @param rawMessage - The raw message string to format . After using @see { @link getRawMessage } to get the raw string .
* @param args - An optional record of substitution variables and their replacement values . This
* is required if the string has dynamic variables . This can be optional as a strings args may be defined in @see { @link LOCALE_DEFAULTS }
*
* @returns The formatted message string .
*
* @example
* // The string greeting is 'Hello, {name}!' in the current locale
* window . i18n . getRawMessage ( 'greeting' , { name : 'Alice' } ) ;
* // => 'Hello, {name}!'
* window . i18n . formatMessageWithArgs ( 'greeting' , { name : 'Alice' } ) ;
* // => 'Hello, Alice!'
*
* // The string search is '{count, plural, one [{found_count} of # match] other [{found_count} of # matches]}' in the current locale
* window . i18n . getRawMessage ( 'search' , { count : 1 , found_count : 1 } ) ;
* // => '{found_count} of {count} match'
* window . i18n . formatMessageWithArgs ( 'search' , { count : 1 , found_count : 1 } ) ;
* // => '1 of 1 match'
* /
function formatMessageWithArgs <
T extends LocalizerToken ,
R extends DictionaryWithoutPluralStrings [ T ] ,
> ( rawMessage : R , args? : ArgsRecord < T > ) : R {
/** Find and replace the dynamic variables in a localized string and substitute the variables with the provided values */
// TODO: remove the type casting once we have a proper DictionaryWithoutPluralStrings type
return ( rawMessage as ` ${ string } { ${ string } } ${ string } ` ) . replace (
/\{(\w+)\}/g ,
( match , arg : string ) = > {
const matchedArg = args ? args [ arg as keyof typeof args ] : undefined ;
return (
matchedArg ? . toString ( ) ? ? LOCALE_DEFAULTS [ arg as keyof typeof LOCALE_DEFAULTS ] ? ? match
) ;
}
) as R ;
}
/** NOTE: Because of docstring limitations changes MUST be manually synced between {@link setupI18n.getMessage } and {@link window.i18n } */
/ * *
* 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!'
*
* // The string search is '{count, plural, one [{found_count} of # match] other [{found_count} of # matches]}' in the current locale
* window . i18n ( 'search' , { count : 1 , found_count : 1 } ) ;
* // => '1 of 1 match'
* /
function getMessage < T extends LocalizerToken , R extends LocalizerDictionary [ T ] > (
. . . [ token , args ] : GetMessageArgs < T >
) : R | T {
try {
const rawMessage = getRawMessage < T , R > ( . . . ( [ token , args ] as GetMessageArgs < T > ) ) ;
/** If a localized string does not have any arguments to substitute it is returned with no changes. */
if ( ! isStringWithArgs < R > ( rawMessage ) ) {
return rawMessage ;
}
return formatMessageWithArgs < T , R > ( rawMessage , args as ArgsRecord < T > ) ;
} catch ( error ) {
i18nLog ( error . message ) ;
return token as R ;
}
}
/** NOTE: Because of docstring limitations changes MUST be manually synced between {@link setupI18n.stripped } and {@link window.i18n.stripped } */
/ * *
* 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}! <b>Welcome!</b>' in the current locale
* window . i18n . stripped ( 'greeting' , { name : 'Alice' } ) ;
* // => 'Hello, Alice! Welcome!'
* /
function stripped < T extends LocalizerToken , R extends LocalizerDictionary [ T ] > (
. . . [ token , args ] : GetMessageArgs < T >
) : R | T {
const sanitizedArgs = args ? sanitizeArgs ( args , '\u200B' ) : undefined ;
const i18nString = getMessage < T , LocalizerDictionary [ T ] > (
. . . ( [ token , sanitizedArgs ] as GetMessageArgs < T > )
) ;
const strippedString = i18nString . replaceAll ( /<[^>]*>/g , '' ) ;
return deSanitizeHtmlTags ( strippedString , '\u200B' ) as R ;
}
getMessage . stripped = stripped ;
getMessage . getRawMessage = getRawMessage ;
getMessage . formatMessageWithArgs = formatMessageWithArgs ;
i18nLog ( 'Setup Complete' ) ;
return getMessage as SetupI18nReturnType ;
} ;
// 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 ;
} ;
/ * *
* Formats a duration in milliseconds into a localized human - readable string .
*
* @param durationMs - The duration in milliseconds .
* @param options - An optional object containing formatting options .
* @returns A formatted string representing the duration .
* /
export const formatTimeDuration = (
durationMs : number ,
options? : Omit < FormatDistanceStrictOptions , ' locale ' >
) = > {
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 2 w for 14 d 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 1 h , 1 m , 1 s 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 3600 s .
* /
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 3600 s .
* /
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 < keyof Duration > = [ ] ;
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 < FormatDistanceToNowStrictOptions , ' locale ' >
) = > {
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' ) ;