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