@oxyhq/core 9.2.4 → 10.1.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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/i18n/index.js +22 -2
- package/dist/cjs/i18n/locales/en-US.json +4 -1
- package/dist/cjs/i18n/locales/es-ES.json +4 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +4 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +4 -1
- package/dist/cjs/index.js +16 -5
- package/dist/cjs/mixins/OxyServices.assets.js +10 -6
- package/dist/cjs/mixins/OxyServices.language.js +11 -10
- package/dist/cjs/mixins/OxyServices.privacy.js +6 -0
- package/dist/cjs/mixins/OxyServices.user.js +40 -0
- package/dist/cjs/server/safeFetch.js +3 -3
- package/dist/cjs/session/SessionClient.js +1 -1
- package/dist/cjs/shared/utils/colorUtils.js +9 -9
- package/dist/cjs/utils/languageUtils.js +195 -158
- package/dist/cjs/utils/platform.js +9 -2
- package/dist/cjs/utils/textNormalization.js +168 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/i18n/index.js +22 -2
- package/dist/esm/i18n/locales/en-US.json +4 -1
- package/dist/esm/i18n/locales/es-ES.json +4 -1
- package/dist/esm/i18n/locales/locales/en-US.json +4 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +4 -1
- package/dist/esm/index.js +5 -1
- package/dist/esm/mixins/OxyServices.assets.js +10 -6
- package/dist/esm/mixins/OxyServices.language.js +12 -11
- package/dist/esm/mixins/OxyServices.privacy.js +6 -0
- package/dist/esm/mixins/OxyServices.user.js +40 -0
- package/dist/esm/server/safeFetch.js +3 -3
- package/dist/esm/session/SessionClient.js +1 -1
- package/dist/esm/shared/utils/colorUtils.js +9 -9
- package/dist/esm/utils/languageUtils.js +189 -156
- package/dist/esm/utils/platform.js +9 -2
- package/dist/esm/utils/textNormalization.js +164 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +4 -3
- package/dist/types/mixins/OxyServices.language.d.ts +4 -4
- package/dist/types/mixins/OxyServices.user.d.ts +30 -0
- package/dist/types/models/interfaces.d.ts +14 -0
- package/dist/types/session/accountDialogController.d.ts +1 -1
- package/dist/types/utils/languageUtils.d.ts +86 -24
- package/dist/types/utils/textNormalization.d.ts +100 -0
- package/package.json +3 -3
- package/src/HttpService.ts +2 -2
- package/src/crypto/keyManager.ts +3 -3
- package/src/crypto/recoveryPhrase.ts +1 -1
- package/src/i18n/index.ts +21 -2
- package/src/i18n/locales/en-US.json +4 -1
- package/src/i18n/locales/es-ES.json +4 -1
- package/src/index.ts +16 -2
- package/src/mixins/OxyServices.assets.ts +15 -11
- package/src/mixins/OxyServices.language.ts +31 -17
- package/src/mixins/OxyServices.privacy.ts +6 -0
- package/src/mixins/OxyServices.security.ts +1 -1
- package/src/mixins/OxyServices.user.ts +57 -0
- package/src/models/interfaces.ts +14 -0
- package/src/server/safeFetch.ts +3 -3
- package/src/session/SessionClient.ts +1 -1
- package/src/session/accountDialogController.ts +1 -1
- package/src/shared/utils/colorUtils.ts +11 -11
- package/src/shared/utils/errorUtils.ts +1 -1
- package/src/utils/__tests__/languageUtils.test.ts +219 -0
- package/src/utils/__tests__/textNormalization.test.ts +196 -0
- package/src/utils/avatarUtils.ts +1 -1
- package/src/utils/languageUtils.ts +223 -162
- package/src/utils/platform.ts +21 -3
- package/src/utils/requestUtils.ts +3 -3
- package/src/utils/sessionUtils.ts +2 -2
- package/src/utils/textNormalization.ts +173 -0
|
@@ -1,179 +1,212 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
},
|
|
15
|
-
{
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
{
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
},
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
},
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
},
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
},
|
|
63
|
-
{
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
},
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
|
103
|
-
|
|
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
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|
122
|
-
const
|
|
123
|
-
|
|
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
|
-
*
|
|
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
|
|
131
|
-
|
|
132
|
-
return metadata?.nativeName || languageCode || FALLBACK_LANGUAGE;
|
|
132
|
+
export function isSupportedLocale(input) {
|
|
133
|
+
return normalizeLocale(input) !== undefined;
|
|
133
134
|
}
|
|
134
135
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* @
|
|
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
|
|
140
|
-
if (!
|
|
141
|
-
return
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
*
|
|
161
|
-
*
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical text normalization for the Oxy ecosystem.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* ---------------
|
|
6
|
+
* Third-party text (federated actor display names, spoiler/CW text, image alt
|
|
7
|
+
* text, bios, `<title>` / `og:site_name` of scraped remote pages, …) arrives
|
|
8
|
+
* with the whitespace the remote author's markup happened to contain. A real
|
|
9
|
+
* bug: a remote page served
|
|
10
|
+
*
|
|
11
|
+
* <title>
|
|
12
|
+
* Mi título
|
|
13
|
+
* </title>
|
|
14
|
+
*
|
|
15
|
+
* and the extracted string — newlines and indentation included — was stored
|
|
16
|
+
* verbatim. Clients render text with React Native Web `Text`, which maps to
|
|
17
|
+
* CSS `white-space: pre-wrap`: unlike HTML, newlines and repeated spaces are
|
|
18
|
+
* NOT collapsed at render time, so the user saw a blank line and a six-space
|
|
19
|
+
* indent inside the link preview.
|
|
20
|
+
*
|
|
21
|
+
* Storage-time normalization is therefore the ONLY place this can be fixed:
|
|
22
|
+
* every renderer downstream is faithful by design.
|
|
23
|
+
*
|
|
24
|
+
* WHICH HELPER DO I USE?
|
|
25
|
+
* ----------------------
|
|
26
|
+
* - {@link normalizeInlineText} — the value is conceptually ONE LINE, and a
|
|
27
|
+
* line break in it is always an accident of the source markup: page titles,
|
|
28
|
+
* `siteName`, display names, image `alt`, handles, profile field labels.
|
|
29
|
+
* Every line break becomes a space.
|
|
30
|
+
*
|
|
31
|
+
* - {@link normalizeMultilineText} — the value is a BODY whose line breaks are
|
|
32
|
+
* the author's own paragraphs and must survive: post text, bios/summaries.
|
|
33
|
+
* Line breaks are preserved (capped at one blank line); only the surrounding
|
|
34
|
+
* whitespace noise is cleaned.
|
|
35
|
+
*
|
|
36
|
+
* Using the multiline helper on a title would keep the newlines and reproduce
|
|
37
|
+
* the original bug; using the inline helper on a post body would flatten the
|
|
38
|
+
* author's paragraphs into a single run-on line. Pick deliberately.
|
|
39
|
+
*
|
|
40
|
+
* Neither helper truncates, strips markup, or enforces a character policy —
|
|
41
|
+
* those are separate, product-specific concerns (see `isValidDisplayName` for
|
|
42
|
+
* the display-name character policy). These functions do exactly one thing:
|
|
43
|
+
* normalize whitespace and Unicode form.
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* Characters that make a value ineligible for the zero-work fast path, and the
|
|
47
|
+
* whitespace shapes that a normalized INLINE value can never contain.
|
|
48
|
+
*
|
|
49
|
+
* A value that matches nothing here is, by construction, already normalized:
|
|
50
|
+
* it holds only printable ASCII (which is NFC-stable, so `normalize('NFC')`
|
|
51
|
+
* would be a no-op), its only whitespace is the plain space, it has no leading
|
|
52
|
+
* or trailing space, and no run of two spaces. Returning it untouched skips
|
|
53
|
+
* three string allocations — worth it because the common case in the feed
|
|
54
|
+
* hydration hot path is text that is already clean.
|
|
55
|
+
*
|
|
56
|
+
* Non-global (safe for repeated `.test()`; a global regex is stateful).
|
|
57
|
+
*/
|
|
58
|
+
const INLINE_NEEDS_NORMALIZATION = /[^\x20-\x7E]|^ | $| {2}/;
|
|
59
|
+
/**
|
|
60
|
+
* Same idea as {@link INLINE_NEEDS_NORMALIZATION}, for MULTILINE values: `\n`
|
|
61
|
+
* joins the printable-ASCII fast-path alphabet, and the additional shapes a
|
|
62
|
+
* normalized body can never contain are a space before a line break (trailing
|
|
63
|
+
* horizontal whitespace) and a run of three line breaks (more than one blank
|
|
64
|
+
* line). A single space AFTER a line break is legal — a one-space indent is
|
|
65
|
+
* the author's, and normalization deliberately preserves it.
|
|
66
|
+
*/
|
|
67
|
+
const MULTILINE_NEEDS_NORMALIZATION = /[^\x20-\x7E\n]|^[ \n]|[ \n]$| {2}| \n|\n{3}/;
|
|
68
|
+
/** Any run of whitespace, including tabs, line breaks and Unicode spaces. */
|
|
69
|
+
const ANY_WHITESPACE_RUN = /\s+/g;
|
|
70
|
+
/**
|
|
71
|
+
* Every line-break form, unified to `\n` before the multiline passes run:
|
|
72
|
+
* CRLF (Windows), a lone CR (classic Mac / stray `\r`), and the Unicode LINE
|
|
73
|
+
* SEPARATOR (U+2028) / PARAGRAPH SEPARATOR (U+2029), which are mandatory breaks
|
|
74
|
+
* in Unicode and a well-known hazard in JSON/JS payloads. CRLF must be matched
|
|
75
|
+
* before the lone `\r` alternative or it would yield two breaks. The separators
|
|
76
|
+
* are matched by Unicode property (`\p{Zl}` = U+2028, `\p{Zp}` = U+2029) rather
|
|
77
|
+
* than as literals: a literal U+2028/U+2029 is a LineTerminator and cannot appear
|
|
78
|
+
* inside a regex literal at all.
|
|
79
|
+
*/
|
|
80
|
+
const LINE_BREAK_FORMS = /\r\n|\r|\p{Zl}|\p{Zp}/gu;
|
|
81
|
+
/**
|
|
82
|
+
* A run of HORIZONTAL whitespace: any whitespace that is not a line break.
|
|
83
|
+
* `[^\S\n]` is "whitespace, minus `\n`" — it covers the tab, the vertical tab,
|
|
84
|
+
* the form feed and every Unicode space separator (NBSP U+00A0, the U+2000
|
|
85
|
+
* block, the ideographic space U+3000, the zero-width no-break space U+FEFF).
|
|
86
|
+
* Applied AFTER {@link LINE_BREAK_FORMS}, so no line break can hide in it.
|
|
87
|
+
*/
|
|
88
|
+
const HORIZONTAL_WHITESPACE_RUN = /[^\S\n]+/g;
|
|
89
|
+
/** Horizontal whitespace at the end of a line — the blank-line spoiler. */
|
|
90
|
+
const TRAILING_HORIZONTAL_WHITESPACE = / +\n/g;
|
|
91
|
+
/** Three or more line breaks: more than one blank line between paragraphs. */
|
|
92
|
+
const EXCESS_BLANK_LINES = /\n{3,}/g;
|
|
93
|
+
/**
|
|
94
|
+
* Normalize a SINGLE-LINE text value: page/link-preview titles, `siteName`,
|
|
95
|
+
* display names, image alt text, handles, profile field labels.
|
|
96
|
+
*
|
|
97
|
+
* A line break in such a value is never meaningful — it is an artifact of the
|
|
98
|
+
* markup the value was extracted from (`<title>\n Título\n</title>`) — and it
|
|
99
|
+
* survives into the UI because RN Web renders `Text` with `white-space:
|
|
100
|
+
* pre-wrap`. So ALL whitespace, line breaks included, collapses to one space.
|
|
101
|
+
*
|
|
102
|
+
* 1. NFC-normalize, so visually identical strings store and compare
|
|
103
|
+
* identically (a decomposed `e`+◌́ recomposes into `é`).
|
|
104
|
+
* 2. Collapse every run of whitespace — spaces, tabs, `\n`, `\r`, and Unicode
|
|
105
|
+
* spaces such as NBSP — to a single plain space.
|
|
106
|
+
* 3. Trim both ends.
|
|
107
|
+
*
|
|
108
|
+
* Length is NOT capped: a maximum length is a product rule of the specific
|
|
109
|
+
* field (see `MAX_DISPLAY_NAME_LENGTH`), not a property of text normalization.
|
|
110
|
+
* Markup is NOT stripped: run the caller's sanitizer first if the source can
|
|
111
|
+
* contain HTML.
|
|
112
|
+
*
|
|
113
|
+
* A value that is empty or whitespace-only returns `''`. Callers decide whether
|
|
114
|
+
* that means "omit the field" (`|| undefined`) or "store an empty string".
|
|
115
|
+
*
|
|
116
|
+
* Idempotent: `f(f(x)) === f(x)`.
|
|
117
|
+
*/
|
|
118
|
+
export function normalizeInlineText(value) {
|
|
119
|
+
if (!INLINE_NEEDS_NORMALIZATION.test(value)) {
|
|
120
|
+
return value;
|
|
121
|
+
}
|
|
122
|
+
return value.normalize('NFC').replace(ANY_WHITESPACE_RUN, ' ').trim();
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Normalize a MULTILINE text BODY: post text, bios, summaries — anywhere the
|
|
126
|
+
* line breaks are the author's paragraphs and must be preserved.
|
|
127
|
+
*
|
|
128
|
+
* Cleans the whitespace noise around those paragraphs without destroying them:
|
|
129
|
+
*
|
|
130
|
+
* 1. NFC-normalize (same rationale as {@link normalizeInlineText}).
|
|
131
|
+
* 2. Unify every line-break form (CRLF, lone CR, U+2028, U+2029) to `\n`.
|
|
132
|
+
* 3. Collapse runs of HORIZONTAL whitespace (spaces, tabs, NBSP and friends)
|
|
133
|
+
* to a single space. Line breaks are untouched.
|
|
134
|
+
* 4. Strip the horizontal whitespace at the END of each line.
|
|
135
|
+
* 5. Collapse three or more line breaks to exactly one blank line (`\n\n`).
|
|
136
|
+
* 6. Trim both ends.
|
|
137
|
+
*
|
|
138
|
+
* STEP 4 MUST PRECEDE STEP 5 — this is the whole point of the function. A
|
|
139
|
+
* "blank" line that actually contains spaces (`"a\n \n \nb"`) breaks the
|
|
140
|
+
* run of `\n` characters, so a bare `\n{3,}` collapse (step 5 alone) never sees
|
|
141
|
+
* it and the extra blank lines survive into the UI. That is exactly the bug in
|
|
142
|
+
* federated post bodies. Removing the trailing horizontal whitespace first
|
|
143
|
+
* turns those lines into real, empty lines, which step 5 then collapses.
|
|
144
|
+
*
|
|
145
|
+
* A single space at the START of a line is preserved: only RUNS of horizontal
|
|
146
|
+
* whitespace collapse, and an indent is not trailing whitespace, so a one-space
|
|
147
|
+
* indent is treated as the author's and left alone.
|
|
148
|
+
*
|
|
149
|
+
* A value that is empty or whitespace-only returns `''`.
|
|
150
|
+
*
|
|
151
|
+
* Idempotent: `f(f(x)) === f(x)`.
|
|
152
|
+
*/
|
|
153
|
+
export function normalizeMultilineText(value) {
|
|
154
|
+
if (!MULTILINE_NEEDS_NORMALIZATION.test(value)) {
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
return value
|
|
158
|
+
.normalize('NFC')
|
|
159
|
+
.replace(LINE_BREAK_FORMS, '\n')
|
|
160
|
+
.replace(HORIZONTAL_WHITESPACE_RUN, ' ')
|
|
161
|
+
.replace(TRAILING_HORIZONTAL_WHITESPACE, '\n')
|
|
162
|
+
.replace(EXCESS_BLANK_LINES, '\n\n')
|
|
163
|
+
.trim();
|
|
164
|
+
}
|