@oxyhq/core 9.2.3 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/i18n/index.js +22 -2
  3. package/dist/cjs/i18n/locales/en-US.json +4 -1
  4. package/dist/cjs/i18n/locales/es-ES.json +4 -1
  5. package/dist/cjs/i18n/locales/locales/en-US.json +4 -1
  6. package/dist/cjs/i18n/locales/locales/es-ES.json +4 -1
  7. package/dist/cjs/index.js +10 -5
  8. package/dist/cjs/mixins/OxyServices.assets.js +10 -6
  9. package/dist/cjs/mixins/OxyServices.language.js +11 -10
  10. package/dist/cjs/mixins/OxyServices.privacy.js +6 -0
  11. package/dist/cjs/mixins/OxyServices.user.js +40 -0
  12. package/dist/cjs/server/safeFetch.js +3 -3
  13. package/dist/cjs/session/SessionClient.js +1 -1
  14. package/dist/cjs/shared/utils/colorUtils.js +9 -9
  15. package/dist/cjs/utils/languageUtils.js +195 -158
  16. package/dist/cjs/utils/platform.js +9 -2
  17. package/dist/esm/.tsbuildinfo +1 -1
  18. package/dist/esm/i18n/index.js +22 -2
  19. package/dist/esm/i18n/locales/en-US.json +4 -1
  20. package/dist/esm/i18n/locales/es-ES.json +4 -1
  21. package/dist/esm/i18n/locales/locales/en-US.json +4 -1
  22. package/dist/esm/i18n/locales/locales/es-ES.json +4 -1
  23. package/dist/esm/index.js +1 -1
  24. package/dist/esm/mixins/OxyServices.assets.js +10 -6
  25. package/dist/esm/mixins/OxyServices.language.js +12 -11
  26. package/dist/esm/mixins/OxyServices.privacy.js +6 -0
  27. package/dist/esm/mixins/OxyServices.user.js +40 -0
  28. package/dist/esm/server/safeFetch.js +3 -3
  29. package/dist/esm/session/SessionClient.js +1 -1
  30. package/dist/esm/shared/utils/colorUtils.js +9 -9
  31. package/dist/esm/utils/languageUtils.js +189 -156
  32. package/dist/esm/utils/platform.js +9 -2
  33. package/dist/types/.tsbuildinfo +1 -1
  34. package/dist/types/index.d.ts +3 -3
  35. package/dist/types/mixins/OxyServices.language.d.ts +4 -4
  36. package/dist/types/mixins/OxyServices.user.d.ts +30 -0
  37. package/dist/types/models/interfaces.d.ts +8 -0
  38. package/dist/types/session/accountDialogController.d.ts +1 -1
  39. package/dist/types/utils/languageUtils.d.ts +86 -24
  40. package/package.json +3 -3
  41. package/src/HttpService.ts +2 -2
  42. package/src/crypto/keyManager.ts +3 -3
  43. package/src/crypto/recoveryPhrase.ts +1 -1
  44. package/src/i18n/index.ts +21 -2
  45. package/src/i18n/locales/en-US.json +4 -1
  46. package/src/i18n/locales/es-ES.json +4 -1
  47. package/src/index.ts +8 -2
  48. package/src/mixins/OxyServices.assets.ts +15 -11
  49. package/src/mixins/OxyServices.language.ts +31 -17
  50. package/src/mixins/OxyServices.privacy.ts +6 -0
  51. package/src/mixins/OxyServices.security.ts +1 -1
  52. package/src/mixins/OxyServices.user.ts +57 -0
  53. package/src/models/interfaces.ts +8 -0
  54. package/src/server/safeFetch.ts +3 -3
  55. package/src/session/SessionClient.ts +1 -1
  56. package/src/session/accountDialogController.ts +1 -1
  57. package/src/shared/utils/colorUtils.ts +11 -11
  58. package/src/shared/utils/errorUtils.ts +1 -1
  59. package/src/utils/__tests__/languageUtils.test.ts +219 -0
  60. package/src/utils/avatarUtils.ts +1 -1
  61. package/src/utils/languageUtils.ts +223 -162
  62. package/src/utils/platform.ts +21 -3
  63. package/src/utils/requestUtils.ts +3 -3
  64. package/src/utils/sessionUtils.ts +2 -2
@@ -1,179 +1,212 @@
1
1
  /**
2
- * Language utilities for OxyServices
3
- * Provides access to supported languages and language metadata
2
+ * Locale utilities for OxyServices.
3
+ *
4
+ * The Oxy platform models account languages as full BCP-47 locales
5
+ * (`language-REGION`, e.g. `es-ES`, `es-MX`, `pt-BR`). Region is significant:
6
+ * "Spanish (Spain)" is a different locale than "Spanish (Mexico)". A user's
7
+ * account locales live on `User.languages` as an ordered list with the PRIMARY
8
+ * (UI) locale first — there is no singular `language` field.
9
+ *
10
+ * This module is the single source of truth for the supported-locale catalog
11
+ * and every locale operation the SDK exposes: parsing base subtags, canonical
12
+ * normalization, validation, metadata lookup, and resolving a user's locales.
13
+ * It is pure and side-effect free.
14
+ */
15
+ /**
16
+ * The supported-locale catalog. Ordered by prominence for display; the order
17
+ * is not otherwise significant. Every entry is a full `language-REGION` tag,
18
+ * with the base subtag lowercased and the region uppercased.
4
19
  */
5
- // Supported languages with their metadata
6
20
  export const SUPPORTED_LANGUAGES = [
7
- {
8
- id: 'en-US',
9
- name: 'English',
10
- nativeName: 'English',
11
- flag: '🇺🇸',
12
- icon: 'translate',
13
- color: '#007AFF',
14
- },
15
- {
16
- id: 'es-ES',
17
- name: 'Spanish',
18
- nativeName: 'Español',
19
- flag: '🇪🇸',
20
- icon: 'translate',
21
- color: '#FF3B30',
22
- },
23
- {
24
- id: 'ca-ES',
25
- name: 'Catalan',
26
- nativeName: 'Català',
27
- flag: '🇪🇸',
28
- icon: 'translate',
29
- color: '#0CA678',
30
- },
31
- {
32
- id: 'fr-FR',
33
- name: 'French',
34
- nativeName: 'Français',
35
- flag: '🇫🇷',
36
- icon: 'translate',
37
- color: '#5856D6',
38
- },
39
- {
40
- id: 'de-DE',
41
- name: 'German',
42
- nativeName: 'Deutsch',
43
- flag: '🇩🇪',
44
- icon: 'translate',
45
- color: '#FF9500',
46
- },
47
- {
48
- id: 'it-IT',
49
- name: 'Italian',
50
- nativeName: 'Italiano',
51
- flag: '🇮🇹',
52
- icon: 'translate',
53
- color: '#34C759',
54
- },
55
- {
56
- id: 'pt-PT',
57
- name: 'Portuguese',
58
- nativeName: 'Português',
59
- flag: '🇵🇹',
60
- icon: 'translate',
61
- color: '#AF52DE',
62
- },
63
- {
64
- id: 'ja-JP',
65
- name: 'Japanese',
66
- nativeName: '日本語',
67
- flag: '🇯🇵',
68
- icon: 'translate',
69
- color: '#FF2D92',
70
- },
71
- {
72
- id: 'ko-KR',
73
- name: 'Korean',
74
- nativeName: '한국어',
75
- flag: '🇰🇷',
76
- icon: 'translate',
77
- color: '#32D74B',
78
- },
79
- {
80
- id: 'zh-CN',
81
- name: 'Chinese',
82
- nativeName: '中文',
83
- flag: '🇨🇳',
84
- icon: 'translate',
85
- color: '#FF9F0A',
86
- },
87
- {
88
- id: 'ar-SA',
89
- name: 'Arabic',
90
- nativeName: 'العربية',
91
- flag: '🇸🇦',
92
- icon: 'translate',
93
- color: '#30B0C7',
94
- },
21
+ // English
22
+ { code: 'en-US', language: 'en', region: 'US', name: 'English (United States)', nativeName: 'English (United States)' },
23
+ { code: 'en-GB', language: 'en', region: 'GB', name: 'English (United Kingdom)', nativeName: 'English (United Kingdom)' },
24
+ { code: 'en-AU', language: 'en', region: 'AU', name: 'English (Australia)', nativeName: 'English (Australia)' },
25
+ { code: 'en-CA', language: 'en', region: 'CA', name: 'English (Canada)', nativeName: 'English (Canada)' },
26
+ { code: 'en-IN', language: 'en', region: 'IN', name: 'English (India)', nativeName: 'English (India)' },
27
+ // Spanish
28
+ { code: 'es-ES', language: 'es', region: 'ES', name: 'Spanish (Spain)', nativeName: 'Español (España)' },
29
+ { code: 'es-MX', language: 'es', region: 'MX', name: 'Spanish (Mexico)', nativeName: 'Español (México)' },
30
+ { code: 'es-US', language: 'es', region: 'US', name: 'Spanish (United States)', nativeName: 'Español (Estados Unidos)' },
31
+ { code: 'es-AR', language: 'es', region: 'AR', name: 'Spanish (Argentina)', nativeName: 'Español (Argentina)' },
32
+ { code: 'es-CO', language: 'es', region: 'CO', name: 'Spanish (Colombia)', nativeName: 'Español (Colombia)' },
33
+ // Portuguese
34
+ { code: 'pt-BR', language: 'pt', region: 'BR', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
35
+ { code: 'pt-PT', language: 'pt', region: 'PT', name: 'Portuguese (Portugal)', nativeName: 'Português (Portugal)' },
36
+ // French
37
+ { code: 'fr-FR', language: 'fr', region: 'FR', name: 'French (France)', nativeName: 'Français (France)' },
38
+ { code: 'fr-CA', language: 'fr', region: 'CA', name: 'French (Canada)', nativeName: 'Français (Canada)' },
39
+ // German
40
+ { code: 'de-DE', language: 'de', region: 'DE', name: 'German (Germany)', nativeName: 'Deutsch (Deutschland)' },
41
+ { code: 'de-AT', language: 'de', region: 'AT', name: 'German (Austria)', nativeName: 'Deutsch (Österreich)' },
42
+ { code: 'de-CH', language: 'de', region: 'CH', name: 'German (Switzerland)', nativeName: 'Deutsch (Schweiz)' },
43
+ // Italian
44
+ { code: 'it-IT', language: 'it', region: 'IT', name: 'Italian (Italy)', nativeName: 'Italiano (Italia)' },
45
+ // Dutch
46
+ { code: 'nl-NL', language: 'nl', region: 'NL', name: 'Dutch (Netherlands)', nativeName: 'Nederlands (Nederland)' },
47
+ { code: 'nl-BE', language: 'nl', region: 'BE', name: 'Dutch (Belgium)', nativeName: 'Nederlands (België)' },
48
+ // Nordic
49
+ { code: 'sv-SE', language: 'sv', region: 'SE', name: 'Swedish (Sweden)', nativeName: 'Svenska (Sverige)' },
50
+ { code: 'nb-NO', language: 'nb', region: 'NO', name: 'Norwegian Bokmål (Norway)', nativeName: 'Norsk bokmål (Norge)' },
51
+ { code: 'da-DK', language: 'da', region: 'DK', name: 'Danish (Denmark)', nativeName: 'Dansk (Danmark)' },
52
+ { code: 'fi-FI', language: 'fi', region: 'FI', name: 'Finnish (Finland)', nativeName: 'Suomi (Suomi)' },
53
+ // Central & Eastern Europe
54
+ { code: 'pl-PL', language: 'pl', region: 'PL', name: 'Polish (Poland)', nativeName: 'Polski (Polska)' },
55
+ { code: 'cs-CZ', language: 'cs', region: 'CZ', name: 'Czech (Czechia)', nativeName: 'Čeština (Česko)' },
56
+ { code: 'sk-SK', language: 'sk', region: 'SK', name: 'Slovak (Slovakia)', nativeName: 'Slovenčina (Slovensko)' },
57
+ { code: 'hu-HU', language: 'hu', region: 'HU', name: 'Hungarian (Hungary)', nativeName: 'Magyar (Magyarország)' },
58
+ { code: 'ro-RO', language: 'ro', region: 'RO', name: 'Romanian (Romania)', nativeName: 'Română (România)' },
59
+ { code: 'bg-BG', language: 'bg', region: 'BG', name: 'Bulgarian (Bulgaria)', nativeName: 'Български (България)' },
60
+ { code: 'hr-HR', language: 'hr', region: 'HR', name: 'Croatian (Croatia)', nativeName: 'Hrvatski (Hrvatska)' },
61
+ { code: 'sr-RS', language: 'sr', region: 'RS', name: 'Serbian (Serbia)', nativeName: 'Српски (Србија)' },
62
+ { code: 'sl-SI', language: 'sl', region: 'SI', name: 'Slovenian (Slovenia)', nativeName: 'Slovenščina (Slovenija)' },
63
+ { code: 'lt-LT', language: 'lt', region: 'LT', name: 'Lithuanian (Lithuania)', nativeName: 'Lietuvių (Lietuva)' },
64
+ { code: 'lv-LV', language: 'lv', region: 'LV', name: 'Latvian (Latvia)', nativeName: 'Latviešu (Latvija)' },
65
+ { code: 'et-EE', language: 'et', region: 'EE', name: 'Estonian (Estonia)', nativeName: 'Eesti (Eesti)' },
66
+ { code: 'uk-UA', language: 'uk', region: 'UA', name: 'Ukrainian (Ukraine)', nativeName: 'Українська (Україна)' },
67
+ { code: 'ru-RU', language: 'ru', region: 'RU', name: 'Russian (Russia)', nativeName: 'Русский (Россия)' },
68
+ { code: 'el-GR', language: 'el', region: 'GR', name: 'Greek (Greece)', nativeName: 'Ελληνικά (Ελλάδα)' },
69
+ // Turkish
70
+ { code: 'tr-TR', language: 'tr', region: 'TR', name: 'Turkish (Türkiye)', nativeName: 'Türkçe (Türkiye)' },
71
+ // Catalan
72
+ { code: 'ca-ES', language: 'ca', region: 'ES', name: 'Catalan (Spain)', nativeName: 'Català (Espanya)' },
73
+ // East, South & Southeast Asia
74
+ { code: 'ja-JP', language: 'ja', region: 'JP', name: 'Japanese (Japan)', nativeName: '日本語 (日本)' },
75
+ { code: 'ko-KR', language: 'ko', region: 'KR', name: 'Korean (South Korea)', nativeName: '한국어 (대한민국)' },
76
+ { code: 'zh-CN', language: 'zh', region: 'CN', name: 'Chinese (Simplified, China)', nativeName: '中文 (中国)' },
77
+ { code: 'zh-TW', language: 'zh', region: 'TW', name: 'Chinese (Traditional, Taiwan)', nativeName: '中文 (台灣)' },
78
+ { code: 'zh-HK', language: 'zh', region: 'HK', name: 'Chinese (Traditional, Hong Kong)', nativeName: '中文 (香港)' },
79
+ { code: 'hi-IN', language: 'hi', region: 'IN', name: 'Hindi (India)', nativeName: 'हिन्दी (भारत)' },
80
+ { code: 'bn-BD', language: 'bn', region: 'BD', name: 'Bengali (Bangladesh)', nativeName: 'বাংলা (বাংলাদেশ)' },
81
+ { code: 'id-ID', language: 'id', region: 'ID', name: 'Indonesian (Indonesia)', nativeName: 'Bahasa Indonesia (Indonesia)' },
82
+ { code: 'ms-MY', language: 'ms', region: 'MY', name: 'Malay (Malaysia)', nativeName: 'Bahasa Melayu (Malaysia)' },
83
+ { code: 'th-TH', language: 'th', region: 'TH', name: 'Thai (Thailand)', nativeName: 'ไทย (ประเทศไทย)' },
84
+ { code: 'vi-VN', language: 'vi', region: 'VN', name: 'Vietnamese (Vietnam)', nativeName: 'Tiếng Việt (Việt Nam)' },
85
+ // Right-to-left
86
+ { code: 'ar-SA', language: 'ar', region: 'SA', name: 'Arabic (Saudi Arabia)', nativeName: 'العربية (السعودية)', rtl: true },
87
+ { code: 'ar-EG', language: 'ar', region: 'EG', name: 'Arabic (Egypt)', nativeName: 'العربية (مصر)', rtl: true },
88
+ { code: 'he-IL', language: 'he', region: 'IL', name: 'Hebrew (Israel)', nativeName: 'עברית (ישראל)', rtl: true },
89
+ { code: 'fa-IR', language: 'fa', region: 'IR', name: 'Persian (Iran)', nativeName: 'فارسی (ایران)', rtl: true },
90
+ { code: 'ur-PK', language: 'ur', region: 'PK', name: 'Urdu (Pakistan)', nativeName: 'اردو (پاکستان)', rtl: true },
95
91
  ];
96
- const FALLBACK_LANGUAGE = 'en-US';
92
+ /** Canonical fallback locale used when a display value cannot be resolved. */
93
+ export const FALLBACK_LOCALE = 'en-US';
94
+ /** O(1) lookup of a catalog entry by its canonical code. */
95
+ const LOCALE_BY_CODE = new Map(SUPPORTED_LANGUAGES.map((entry) => [entry.code, entry]));
97
96
  /**
98
- * Get language metadata by language code
99
- * @param languageCode - BCP-47 language code (e.g., 'en-US', 'es-ES')
100
- * @returns Language metadata or null if not found
97
+ * Extract the base language subtag from a locale, lowercased.
98
+ *
99
+ * Tolerant of bare base subtags and of extra subtags (script/variant):
100
+ * `'es-ES'` → `'es'`, `'es'` → `'es'`, `'zh-Hant-TW'` → `'zh'`. Returns an
101
+ * empty string for empty input.
101
102
  */
102
- export function getLanguageMetadata(languageCode) {
103
- if (!languageCode)
104
- return null;
105
- // Direct match
106
- const exactMatch = SUPPORTED_LANGUAGES.find(lang => lang.id === languageCode);
107
- if (exactMatch)
108
- return exactMatch;
109
- // Try to match base language code (e.g., 'en' matches 'en-US')
110
- const baseCode = languageCode.split('-')[0];
111
- const baseMatch = SUPPORTED_LANGUAGES.find(lang => lang.id.startsWith(baseCode + '-'));
112
- if (baseMatch)
113
- return baseMatch;
114
- return null;
103
+ export function getBaseLanguage(locale) {
104
+ return locale.trim().toLowerCase().split('-')[0] ?? '';
115
105
  }
116
106
  /**
117
- * Get language name by language code
118
- * @param languageCode - BCP-47 language code (e.g., 'en-US', 'es-ES')
119
- * @returns Language name (e.g., 'English') or the code if not found
107
+ * Normalize a locale string to its canonical `language-REGION` form when it is
108
+ * a supported locale, otherwise `undefined`.
109
+ *
110
+ * Canonicalization lowercases the base subtag and uppercases the region
111
+ * subtag (`'es-es'` → `'es-ES'`, `'EN-us'` → `'en-US'`) and validates the
112
+ * result against {@link SUPPORTED_LANGUAGES}. A bare base subtag (`'es'`) has
113
+ * no region and is therefore not a locale — it returns `undefined`.
120
114
  */
121
- export function getLanguageName(languageCode) {
122
- const metadata = getLanguageMetadata(languageCode);
123
- return metadata?.name || languageCode || FALLBACK_LANGUAGE;
115
+ export function normalizeLocale(input) {
116
+ const trimmed = input.trim();
117
+ if (!trimmed)
118
+ return undefined;
119
+ const parts = trimmed.split('-');
120
+ if (parts.length < 2)
121
+ return undefined;
122
+ const base = parts[0]?.toLowerCase();
123
+ const region = parts[parts.length - 1]?.toUpperCase();
124
+ if (!base || !region)
125
+ return undefined;
126
+ const canonical = `${base}-${region}`;
127
+ return LOCALE_BY_CODE.has(canonical) ? canonical : undefined;
124
128
  }
125
129
  /**
126
- * Get native language name by language code
127
- * @param languageCode - BCP-47 language code (e.g., 'en-US', 'es-ES')
128
- * @returns Native language name (e.g., 'Español') or the code if not found
130
+ * Whether `input` resolves to a supported BCP-47 locale.
129
131
  */
130
- export function getNativeLanguageName(languageCode) {
131
- const metadata = getLanguageMetadata(languageCode);
132
- return metadata?.nativeName || languageCode || FALLBACK_LANGUAGE;
132
+ export function isSupportedLocale(input) {
133
+ return normalizeLocale(input) !== undefined;
133
134
  }
134
135
  /**
135
- * Normalize language code to BCP-47 format
136
- * @param lang - Language code (may be short like 'en' or full like 'en-US')
137
- * @returns Normalized BCP-47 language code
136
+ * Resolve catalog metadata for a locale.
137
+ *
138
+ * @param code - A locale tag (any case; e.g. `'es-ES'`, `'es-es'`).
139
+ * @returns The {@link SupportedLanguage} entry, or `null` when the tag is empty
140
+ * or not a supported locale.
138
141
  */
139
- export function normalizeLanguageCode(lang) {
140
- if (!lang)
141
- return FALLBACK_LANGUAGE;
142
- if (lang.includes('-'))
143
- return lang;
144
- const map = {
145
- en: 'en-US',
146
- es: 'es-ES',
147
- ca: 'ca-ES',
148
- fr: 'fr-FR',
149
- de: 'de-DE',
150
- it: 'it-IT',
151
- pt: 'pt-PT',
152
- ja: 'ja-JP',
153
- ko: 'ko-KR',
154
- zh: 'zh-CN',
155
- ar: 'ar-SA',
156
- };
157
- return map[lang] || lang;
142
+ export function getLanguageMetadata(code) {
143
+ if (!code)
144
+ return null;
145
+ const canonical = normalizeLocale(code);
146
+ if (!canonical)
147
+ return null;
148
+ return LOCALE_BY_CODE.get(canonical) ?? null;
158
149
  }
159
150
  /**
160
- * RTL language detection.
161
- *
162
- * Returns `true` when the given BCP-47 tag or bare language code is one
163
- * of the right-to-left scripts we ship UI for. Apps use this to drive
164
- * `I18nManager.allowRTL(true)` / `forceRTL(...)` on React Native and the
165
- * `<html dir="rtl">` attribute on web.
166
- *
167
- * Includes Arabic (`ar`), Hebrew (`he` / legacy `iw`), Persian (`fa`),
168
- * Urdu (`ur`) plus their common region variants. Unknown tags are
169
- * treated as LTR.
151
+ * English display name for a locale (e.g. `'Spanish (Spain)'`).
152
+ * Falls back to the input tag, then {@link FALLBACK_LOCALE}.
153
+ */
154
+ export function getLanguageName(code) {
155
+ return getLanguageMetadata(code)?.name || code || FALLBACK_LOCALE;
156
+ }
157
+ /**
158
+ * Native display name (endonym) for a locale (e.g. `'Español (España)'`).
159
+ * Falls back to the input tag, then {@link FALLBACK_LOCALE}.
160
+ */
161
+ export function getNativeLanguageName(code) {
162
+ return getLanguageMetadata(code)?.nativeName || code || FALLBACK_LOCALE;
163
+ }
164
+ /**
165
+ * Base language subtags whose scripts are written right-to-left. Used to drive
166
+ * `I18nManager.forceRTL(...)` on React Native and `<html dir="rtl">` on web.
167
+ * Includes Arabic (`ar`), Hebrew (`he` / legacy `iw`), Persian (`fa`), and
168
+ * Urdu (`ur`).
170
169
  */
171
170
  const RTL_LANGUAGE_BASES = new Set(['ar', 'he', 'iw', 'fa', 'ur']);
171
+ /**
172
+ * Whether a locale (or bare base subtag) is written right-to-left. Unknown
173
+ * tags are treated as left-to-right.
174
+ */
172
175
  export function isRTLLocale(locale) {
173
176
  if (!locale)
174
177
  return false;
175
- const base = locale.toLowerCase().split('-')[0];
176
- if (!base)
177
- return false;
178
- return RTL_LANGUAGE_BASES.has(base);
178
+ return RTL_LANGUAGE_BASES.has(getBaseLanguage(locale));
179
+ }
180
+ /**
181
+ * Resolve the ordered list of account locales for a user, primary first.
182
+ *
183
+ * Reads `user.languages` (the only source — there is no singular `language`
184
+ * field), then normalizes each entry to its canonical `language-REGION` form,
185
+ * drops non-string and unsupported entries, and de-duplicates while preserving
186
+ * first-seen order. Pure and side-effect free — never throws on bad input.
187
+ */
188
+ export function getUserLanguages(user) {
189
+ const source = user?.languages;
190
+ if (!Array.isArray(source))
191
+ return [];
192
+ const result = [];
193
+ const seen = new Set();
194
+ for (const entry of source) {
195
+ if (typeof entry !== 'string')
196
+ continue;
197
+ const canonical = normalizeLocale(entry);
198
+ if (!canonical || seen.has(canonical))
199
+ continue;
200
+ seen.add(canonical);
201
+ result.push(canonical);
202
+ }
203
+ return result;
204
+ }
205
+ /**
206
+ * Resolve the single PRIMARY locale for a user (the first entry from
207
+ * {@link getUserLanguages}), or `undefined` when the user has no supported
208
+ * locale.
209
+ */
210
+ export function getPrimaryLanguage(user) {
211
+ return getUserLanguages(user)[0];
179
212
  }
@@ -5,6 +5,13 @@
5
5
  * This allows core modules to be used in web/Node.js environments
6
6
  * without bundlers failing on react-native imports.
7
7
  */
8
+ /**
9
+ * Typed view over `globalThis` for the RN-platform marker, avoiding an `any`
10
+ * cast at each access site.
11
+ */
12
+ function rnPlatformGlobal() {
13
+ return globalThis;
14
+ }
8
15
  /**
9
16
  * Detect the current platform without importing react-native
10
17
  *
@@ -17,7 +24,7 @@
17
24
  function detectPlatform() {
18
25
  // Check if React Native Platform is available globally (set by RN runtime)
19
26
  // This avoids static imports while still detecting RN environment
20
- const rnPlatform = globalThis.__REACT_NATIVE_PLATFORM__;
27
+ const rnPlatform = rnPlatformGlobal().__REACT_NATIVE_PLATFORM__;
21
28
  if (rnPlatform) {
22
29
  return rnPlatform;
23
30
  }
@@ -81,7 +88,7 @@ export function isAndroid() {
81
88
  */
82
89
  export function setPlatformOS(os) {
83
90
  cachedPlatform = os;
84
- globalThis.__REACT_NATIVE_PLATFORM__ = os;
91
+ rnPlatformGlobal().__REACT_NATIVE_PLATFORM__ = os;
85
92
  }
86
93
  /**
87
94
  * True only in a real web browser (a DOM is present), false on React Native