1/* eslint-env browser */ 2import { Platform, Subscription } from 'expo-modules-core'; 3import * as rtlDetect from 'rtl-detect'; 4 5import { Localization, Calendar, Locale, CalendarIdentifier } from './Localization.types'; 6 7const getNavigatorLocales = () => { 8 return Platform.isDOMAvailable ? navigator.languages || [navigator.language] : []; 9}; 10 11type ExtendedLocale = Intl.Locale & 12 // typescript definitions for navigator language don't include some modern Intl properties 13 Partial<{ 14 textInfo: { direction: 'ltr' | 'rtl' }; 15 timeZones: string[]; 16 weekInfo: { firstDay: number }; 17 hourCycles: string[]; 18 timeZone: string; 19 calendars: string[]; 20 }>; 21 22const WEB_LANGUAGE_CHANGE_EVENT = 'languagechange'; 23 24export function addLocaleListener(listener: (event) => void): Subscription { 25 addEventListener(WEB_LANGUAGE_CHANGE_EVENT, listener); 26 return { 27 remove: () => removeEventListener(WEB_LANGUAGE_CHANGE_EVENT, listener), 28 }; 29} 30 31export function addCalendarListener(listener: (event) => void): Subscription { 32 addEventListener(WEB_LANGUAGE_CHANGE_EVENT, listener); 33 return { 34 remove: () => removeEventListener(WEB_LANGUAGE_CHANGE_EVENT, listener), 35 }; 36} 37export function removeSubscription(subscription: Subscription) { 38 subscription.remove(); 39} 40 41export default { 42 get currency(): string | null { 43 // TODO: Add support 44 return null; 45 }, 46 get decimalSeparator(): string { 47 return (1.1).toLocaleString().substring(1, 2); 48 }, 49 get digitGroupingSeparator(): string { 50 const value = (1000).toLocaleString(); 51 return value.length === 5 ? value.substring(1, 2) : ''; 52 }, 53 get isRTL(): boolean { 54 return rtlDetect.isRtlLang(this.locale) ?? false; 55 }, 56 get isMetric(): boolean { 57 const { region } = this; 58 switch (region) { 59 case 'US': // USA 60 case 'LR': // Liberia 61 case 'MM': // Myanmar 62 return false; 63 } 64 return true; 65 }, 66 get locale(): string { 67 if (!Platform.isDOMAvailable) { 68 return ''; 69 } 70 const locale = 71 navigator.language || 72 navigator['systemLanguage'] || 73 navigator['browserLanguage'] || 74 navigator['userLanguage'] || 75 this.locales[0]; 76 return locale; 77 }, 78 get locales(): string[] { 79 if (!Platform.isDOMAvailable) { 80 return []; 81 } 82 const { languages = [] } = navigator; 83 return Array.from(languages); 84 }, 85 get timezone(): string { 86 const defaultTimeZone = 'Etc/UTC'; 87 if (typeof Intl === 'undefined') { 88 return defaultTimeZone; 89 } 90 return Intl.DateTimeFormat().resolvedOptions().timeZone || defaultTimeZone; 91 }, 92 get isoCurrencyCodes(): string[] { 93 // TODO(Bacon): Add this - very low priority 94 return []; 95 }, 96 get region(): string | null { 97 // There is no way to obtain the current region, as is possible on native. 98 // Instead, use the country-code from the locale when possible (e.g. "en-US"). 99 const { locale } = this; 100 const [, ...suffixes] = typeof locale === 'string' ? locale.split('-') : []; 101 for (const suffix of suffixes) { 102 if (suffix.length === 2) { 103 return suffix.toUpperCase(); 104 } 105 } 106 return null; 107 }, 108 109 getLocales(): Locale[] { 110 const locales = getNavigatorLocales(); 111 return locales?.map((languageTag) => { 112 // TextInfo is an experimental API that is not available in all browsers. 113 // We might want to consider using a locale lookup table instead. 114 const locale = 115 typeof Intl !== 'undefined' 116 ? (new Intl.Locale(languageTag) as unknown as ExtendedLocale) 117 : { region: null, textInfo: null, language: null }; 118 const { region, textInfo, language } = locale; 119 120 // Properties added only for compatibility with native, use `toLocaleString` instead. 121 const digitGroupingSeparator = 122 Array.from((10000).toLocaleString(languageTag)).filter((c) => c > '9' || c < '0')[0] || 123 null; // using 1e5 instead of 1e4 since for some locales (like pl-PL) 1e4 does not use digit grouping 124 const decimalSeparator = (1.1).toLocaleString(languageTag).substring(1, 2); 125 126 return { 127 languageTag, 128 languageCode: language || languageTag.split('-')[0] || 'en', 129 textDirection: (textInfo?.direction as 'ltr' | 'rtl') || null, 130 digitGroupingSeparator, 131 decimalSeparator, 132 measurementSystem: null, 133 currencyCode: null, 134 currencySymbol: null, 135 regionCode: region || null, 136 }; 137 }); 138 }, 139 getCalendars(): Calendar[] { 140 const locale = ((typeof Intl !== 'undefined' 141 ? Intl.DateTimeFormat().resolvedOptions() 142 : null) ?? null) as unknown as null | ExtendedLocale; 143 return [ 144 { 145 calendar: ((locale?.calendar || locale?.calendars?.[0]) as CalendarIdentifier) || null, 146 timeZone: locale?.timeZone || locale?.timeZones?.[0] || null, 147 uses24hourClock: (locale?.hourCycle || locale?.hourCycles?.[0])?.startsWith('h2') ?? null, //https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/hourCycle 148 firstWeekday: locale?.weekInfo?.firstDay || null, 149 }, 150 ]; 151 }, 152 153 async getLocalizationAsync(): Promise<Omit<Localization, 'getCalendars' | 'getLocales'>> { 154 const { 155 currency, 156 decimalSeparator, 157 digitGroupingSeparator, 158 isoCurrencyCodes, 159 isMetric, 160 isRTL, 161 locale, 162 locales, 163 region, 164 timezone, 165 } = this; 166 return { 167 currency, 168 decimalSeparator, 169 digitGroupingSeparator, 170 isoCurrencyCodes, 171 isMetric, 172 isRTL, 173 locale, 174 locales, 175 region, 176 timezone, 177 }; 178 }, 179}; 180