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