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