@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.
Files changed (69) 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 +16 -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/cjs/utils/textNormalization.js +168 -0
  18. package/dist/esm/.tsbuildinfo +1 -1
  19. package/dist/esm/i18n/index.js +22 -2
  20. package/dist/esm/i18n/locales/en-US.json +4 -1
  21. package/dist/esm/i18n/locales/es-ES.json +4 -1
  22. package/dist/esm/i18n/locales/locales/en-US.json +4 -1
  23. package/dist/esm/i18n/locales/locales/es-ES.json +4 -1
  24. package/dist/esm/index.js +5 -1
  25. package/dist/esm/mixins/OxyServices.assets.js +10 -6
  26. package/dist/esm/mixins/OxyServices.language.js +12 -11
  27. package/dist/esm/mixins/OxyServices.privacy.js +6 -0
  28. package/dist/esm/mixins/OxyServices.user.js +40 -0
  29. package/dist/esm/server/safeFetch.js +3 -3
  30. package/dist/esm/session/SessionClient.js +1 -1
  31. package/dist/esm/shared/utils/colorUtils.js +9 -9
  32. package/dist/esm/utils/languageUtils.js +189 -156
  33. package/dist/esm/utils/platform.js +9 -2
  34. package/dist/esm/utils/textNormalization.js +164 -0
  35. package/dist/types/.tsbuildinfo +1 -1
  36. package/dist/types/index.d.ts +4 -3
  37. package/dist/types/mixins/OxyServices.language.d.ts +4 -4
  38. package/dist/types/mixins/OxyServices.user.d.ts +30 -0
  39. package/dist/types/models/interfaces.d.ts +14 -0
  40. package/dist/types/session/accountDialogController.d.ts +1 -1
  41. package/dist/types/utils/languageUtils.d.ts +86 -24
  42. package/dist/types/utils/textNormalization.d.ts +100 -0
  43. package/package.json +3 -3
  44. package/src/HttpService.ts +2 -2
  45. package/src/crypto/keyManager.ts +3 -3
  46. package/src/crypto/recoveryPhrase.ts +1 -1
  47. package/src/i18n/index.ts +21 -2
  48. package/src/i18n/locales/en-US.json +4 -1
  49. package/src/i18n/locales/es-ES.json +4 -1
  50. package/src/index.ts +16 -2
  51. package/src/mixins/OxyServices.assets.ts +15 -11
  52. package/src/mixins/OxyServices.language.ts +31 -17
  53. package/src/mixins/OxyServices.privacy.ts +6 -0
  54. package/src/mixins/OxyServices.security.ts +1 -1
  55. package/src/mixins/OxyServices.user.ts +57 -0
  56. package/src/models/interfaces.ts +14 -0
  57. package/src/server/safeFetch.ts +3 -3
  58. package/src/session/SessionClient.ts +1 -1
  59. package/src/session/accountDialogController.ts +1 -1
  60. package/src/shared/utils/colorUtils.ts +11 -11
  61. package/src/shared/utils/errorUtils.ts +1 -1
  62. package/src/utils/__tests__/languageUtils.test.ts +219 -0
  63. package/src/utils/__tests__/textNormalization.test.ts +196 -0
  64. package/src/utils/avatarUtils.ts +1 -1
  65. package/src/utils/languageUtils.ts +223 -162
  66. package/src/utils/platform.ts +21 -3
  67. package/src/utils/requestUtils.ts +3 -3
  68. package/src/utils/sessionUtils.ts +2 -2
  69. package/src/utils/textNormalization.ts +173 -0
@@ -26,7 +26,7 @@ export type { ServiceTokenResponse } from './mixins/OxyServices.auth';
26
26
  export type { CommonsSignInHandle, CommonsSignInStatus, CommonsApprovalInfo, CommonsSignInActionResult, } from './mixins/OxyServices.auth';
27
27
  export type { ServiceApp, ServiceActingAsVerification } from './mixins/OxyServices.utility';
28
28
  export type { ContactDiscoveryMatch, ContactDiscoveryResponse, } from './mixins/OxyServices.contacts';
29
- export type { BulkFollowEntry, BulkFollowResult, BulkUnfollowEntry, BulkUnfollowResult, } from './mixins/OxyServices.user';
29
+ export type { BulkFollowEntry, BulkFollowResult, BulkUnfollowEntry, BulkUnfollowResult, ViewerGraph, } from './mixins/OxyServices.user';
30
30
  export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
31
31
  export { getNormalizedUserId, normalizeUserIdentity, normalizeUserIdentityOrNull, } from './utils/userIdentity';
32
32
  export { getCanonicalUserHandle, getNormalizedUserHandle, } from './utils/userHandle';
@@ -58,8 +58,8 @@ export type { OxyConfig, PrivacySettings, NotificationPreferences, UserPreferenc
58
58
  export { SECURITY_EVENT_SEVERITY_MAP } from './models/interfaces';
59
59
  export { TopicType, TopicSource } from './models/Topic';
60
60
  export type { TopicData, TopicTranslation } from './models/Topic';
61
- export { SUPPORTED_LANGUAGES, getLanguageMetadata, getLanguageName, getNativeLanguageName, normalizeLanguageCode, isRTLLocale, } from './utils/languageUtils';
62
- export type { LanguageMetadata } from './utils/languageUtils';
61
+ export { SUPPORTED_LANGUAGES, FALLBACK_LOCALE, getBaseLanguage, normalizeLocale, isSupportedLocale, getLanguageMetadata, getLanguageName, getNativeLanguageName, isRTLLocale, getUserLanguages, getPrimaryLanguage, } from './utils/languageUtils';
62
+ export type { SupportedLanguage } from './utils/languageUtils';
63
63
  export { getPlatformOS, setPlatformOS, isWeb, isNative, isIOS, isAndroid, isWebBrowser, } from './utils/platform';
64
64
  export type { PlatformOS } from './utils/platform';
65
65
  export { darkenColor, lightenColor, hexToRgb, rgbToHex, withOpacity, isLightColor, getContrastTextColor, } from './shared/utils/colorUtils';
@@ -75,6 +75,7 @@ export type { PaginationParams, ApiResponse, ErrorResponse, } from './utils/apiU
75
75
  export { ErrorCodes, createApiError, handleHttpError, validateRequiredFields, } from './utils/errorUtils';
76
76
  export { retryAsync } from './utils/asyncUtils';
77
77
  export { EMAIL_REGEX, USERNAME_REGEX, PASSWORD_REGEX, isValidEmail, isValidUsername, isValidPassword, isValidDisplayName, isRequiredString, isRequiredNumber, isRequiredBoolean, isValidArray, isValidObject, isValidUUID, isValidURL, isValidDate, isValidFileSize, isValidFileType, sanitizeString, sanitizeHTML, isValidObjectId, validateAndSanitizeUserInput, } from './utils/validationUtils';
78
+ export { normalizeInlineText, normalizeMultilineText, } from './utils/textNormalization';
78
79
  export { logger, LogLevel, logAuth, logApi, logSession, logUser, logDevice, logPayment, logPerformance, } from './utils/loggerUtils';
79
80
  export type { LogContext } from './utils/loggerUtils';
80
81
  export { updateAvatarVisibility } from './utils/avatarUtils';
@@ -1,4 +1,4 @@
1
- import type { LanguageMetadata } from '../utils/languageUtils';
1
+ import type { SupportedLanguage } from '../utils/languageUtils';
2
2
  import type { OxyServicesBase } from '../OxyServices.base';
3
3
  export declare function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base: T): {
4
4
  new (...args: any[]): {
@@ -11,9 +11,9 @@ export declare function OxyServicesLanguageMixin<T extends typeof OxyServicesBas
11
11
  removeItem: (key: string) => Promise<void>;
12
12
  }>;
13
13
  /**
14
- * Get the current language from storage or user profile
14
+ * Get the current locale from the user profile or local storage.
15
15
  * @param storageKeyPrefix - Optional prefix for storage key (default: 'oxy_session')
16
- * @returns The current language code (e.g., 'en-US') or null if not set
16
+ * @returns The current BCP-47 locale (e.g., 'en-US') or null if not set
17
17
  */
18
18
  getCurrentLanguage(storageKeyPrefix?: string): Promise<string | null>;
19
19
  /**
@@ -21,7 +21,7 @@ export declare function OxyServicesLanguageMixin<T extends typeof OxyServicesBas
21
21
  * @param storageKeyPrefix - Optional prefix for storage key (default: 'oxy_session')
22
22
  * @returns Language metadata object or null if not set
23
23
  */
24
- getCurrentLanguageMetadata(storageKeyPrefix?: string): Promise<LanguageMetadata | null>;
24
+ getCurrentLanguageMetadata(storageKeyPrefix?: string): Promise<SupportedLanguage | null>;
25
25
  /**
26
26
  * Get the current language name (e.g., 'English')
27
27
  * @param storageKeyPrefix - Optional prefix for storage key (default: 'oxy_session')
@@ -21,6 +21,22 @@ export interface BulkFollowResult {
21
21
  /** Number of users newly followed by this request. */
22
22
  followedCount: number;
23
23
  }
24
+ /**
25
+ * The authenticated viewer's OWN social graph, ids-only — the response of
26
+ * `GET /users/me/graph`. Consolidates the accounts the viewer follows, the
27
+ * subset who follow back (mutuals), and the accounts the viewer has blocked
28
+ * into one payload so a consumer can fetch its whole viewer graph in a single
29
+ * round trip instead of three. Each list is server-bounded; bare ids only (no
30
+ * hydrated DTOs) because the consumer hydrates/ranks itself.
31
+ */
32
+ export interface ViewerGraph {
33
+ /** Accounts the viewer follows (most-recent first, bounded). */
34
+ followingIds: string[];
35
+ /** Accounts the viewer follows that ALSO follow the viewer back (bounded). */
36
+ mutualIds: string[];
37
+ /** Accounts the viewer has blocked (bounded). */
38
+ blockedIds: string[];
39
+ }
24
40
  /** Per-user outcome returned by `POST /users/unfollow/bulk`. */
25
41
  export interface BulkUnfollowEntry {
26
42
  /** The user ID that was processed. */
@@ -383,6 +399,20 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
383
399
  getFollowsOfFollowsIds(params?: {
384
400
  limit?: number;
385
401
  }): Promise<string[]>;
402
+ /**
403
+ * Get the authenticated VIEWER's OWN social graph — the accounts they follow,
404
+ * the subset who follow back (mutuals), and the accounts they have blocked —
405
+ * as ONE ids-only payload. The viewer is derived server-side from the SDK's
406
+ * auth token (never a param).
407
+ *
408
+ * Consolidates what were three separate round trips (`getUserFollowing` /
409
+ * `getMutualUserIds` / `getBlockedUsers`) into a single request so a consumer
410
+ * can prime its whole viewer graph at once. Mirrors {@link getMutualUserIds}'s
411
+ * caching posture (2-minute identity-scoped cache); the follow/unfollow/block/
412
+ * unblock write methods bust this entry so a local mutation is reflected
413
+ * immediately. An anonymous caller resolves to empty lists.
414
+ */
415
+ getViewerGraph(): Promise<ViewerGraph>;
386
416
  /**
387
417
  * Get notifications
388
418
  */
@@ -93,6 +93,12 @@ export interface User {
93
93
  */
94
94
  name: UserNameResponse;
95
95
  bio?: string;
96
+ /**
97
+ * Longer public profile text. Emitted alongside `bio` by every user DTO the
98
+ * API produces (single profile, `getUsersByIds`, follower/following/mutual
99
+ * lists, profile search) — the two are separate fields on the User document.
100
+ */
101
+ description?: string;
96
102
  phone?: string;
97
103
  address?: string;
98
104
  birthday?: string;
@@ -131,6 +137,14 @@ export interface User {
131
137
  managedBy?: string;
132
138
  /** Real-estate taxonomy when this user is a `kind: 'organization'` account. */
133
139
  organizationCategory?: OrganizationCategory;
140
+ /**
141
+ * The account's languages as full BCP-47 locales (`language-REGION`, e.g.
142
+ * `en-US`, `es-MX`, `pt-BR`), ordered with the PRIMARY (UI) locale first.
143
+ * `languages[0]` is the primary locale — there is no singular `language`
144
+ * field. Resolve via `getUserLanguages` / `getPrimaryLanguage`, which
145
+ * normalize, validate against the supported catalog, and de-duplicate.
146
+ */
147
+ languages?: string[];
134
148
  notificationPreferences?: NotificationPreferences;
135
149
  userPreferences?: UserPreferences;
136
150
  [key: string]: unknown;
@@ -27,7 +27,7 @@
27
27
  */
28
28
  import type { OxyServices } from '../OxyServices';
29
29
  import type { SessionLoginResponse, MinimalUserData } from '../models/session';
30
- import { SessionClient } from './SessionClient';
30
+ import type { SessionClient } from './SessionClient';
31
31
  import { type SwitchableAccount } from './accountProjection';
32
32
  /** The dialog's top-level view. */
33
33
  export type AccountDialogView = 'accounts' | 'signin' | 'qr' | 'add';
@@ -1,38 +1,100 @@
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
- export interface LanguageMetadata {
6
- id: string;
15
+ import type { User } from '../models/interfaces';
16
+ /**
17
+ * A supported BCP-47 locale in the Oxy catalog.
18
+ */
19
+ export interface SupportedLanguage {
20
+ /** Canonical BCP-47 locale tag, `language-REGION` (e.g. `'es-ES'`). */
21
+ code: string;
22
+ /** ISO 639-1 base language subtag, lowercased (e.g. `'es'`). */
23
+ language: string;
24
+ /** ISO 3166-1 alpha-2 region subtag, uppercased (e.g. `'ES'`). */
25
+ region: string;
26
+ /** English display name, `'Language (Region)'` (e.g. `'Spanish (Spain)'`). */
7
27
  name: string;
28
+ /** Endonym — the name as written in the locale itself (e.g. `'Español (España)'`). */
8
29
  nativeName: string;
9
- flag: string;
10
- icon: string;
11
- color: string;
30
+ /** `true` when the locale is written right-to-left. Omitted for LTR locales. */
31
+ rtl?: boolean;
12
32
  }
13
- export declare const SUPPORTED_LANGUAGES: LanguageMetadata[];
14
33
  /**
15
- * Get language metadata by language code
16
- * @param languageCode - BCP-47 language code (e.g., 'en-US', 'es-ES')
17
- * @returns Language metadata or null if not found
34
+ * The supported-locale catalog. Ordered by prominence for display; the order
35
+ * is not otherwise significant. Every entry is a full `language-REGION` tag,
36
+ * with the base subtag lowercased and the region uppercased.
37
+ */
38
+ export declare const SUPPORTED_LANGUAGES: readonly SupportedLanguage[];
39
+ /** Canonical fallback locale used when a display value cannot be resolved. */
40
+ export declare const FALLBACK_LOCALE = "en-US";
41
+ /**
42
+ * Extract the base language subtag from a locale, lowercased.
43
+ *
44
+ * Tolerant of bare base subtags and of extra subtags (script/variant):
45
+ * `'es-ES'` → `'es'`, `'es'` → `'es'`, `'zh-Hant-TW'` → `'zh'`. Returns an
46
+ * empty string for empty input.
47
+ */
48
+ export declare function getBaseLanguage(locale: string): string;
49
+ /**
50
+ * Normalize a locale string to its canonical `language-REGION` form when it is
51
+ * a supported locale, otherwise `undefined`.
52
+ *
53
+ * Canonicalization lowercases the base subtag and uppercases the region
54
+ * subtag (`'es-es'` → `'es-ES'`, `'EN-us'` → `'en-US'`) and validates the
55
+ * result against {@link SUPPORTED_LANGUAGES}. A bare base subtag (`'es'`) has
56
+ * no region and is therefore not a locale — it returns `undefined`.
57
+ */
58
+ export declare function normalizeLocale(input: string): string | undefined;
59
+ /**
60
+ * Whether `input` resolves to a supported BCP-47 locale.
61
+ */
62
+ export declare function isSupportedLocale(input: string): boolean;
63
+ /**
64
+ * Resolve catalog metadata for a locale.
65
+ *
66
+ * @param code - A locale tag (any case; e.g. `'es-ES'`, `'es-es'`).
67
+ * @returns The {@link SupportedLanguage} entry, or `null` when the tag is empty
68
+ * or not a supported locale.
18
69
  */
19
- export declare function getLanguageMetadata(languageCode: string | null | undefined): LanguageMetadata | null;
70
+ export declare function getLanguageMetadata(code: string | null | undefined): SupportedLanguage | null;
20
71
  /**
21
- * Get language name by language code
22
- * @param languageCode - BCP-47 language code (e.g., 'en-US', 'es-ES')
23
- * @returns Language name (e.g., 'English') or the code if not found
72
+ * English display name for a locale (e.g. `'Spanish (Spain)'`).
73
+ * Falls back to the input tag, then {@link FALLBACK_LOCALE}.
24
74
  */
25
- export declare function getLanguageName(languageCode: string | null | undefined): string;
75
+ export declare function getLanguageName(code: string | null | undefined): string;
26
76
  /**
27
- * Get native language name by language code
28
- * @param languageCode - BCP-47 language code (e.g., 'en-US', 'es-ES')
29
- * @returns Native language name (e.g., 'Español') or the code if not found
77
+ * Native display name (endonym) for a locale (e.g. `'Español (España)'`).
78
+ * Falls back to the input tag, then {@link FALLBACK_LOCALE}.
30
79
  */
31
- export declare function getNativeLanguageName(languageCode: string | null | undefined): string;
80
+ export declare function getNativeLanguageName(code: string | null | undefined): string;
32
81
  /**
33
- * Normalize language code to BCP-47 format
34
- * @param lang - Language code (may be short like 'en' or full like 'en-US')
35
- * @returns Normalized BCP-47 language code
82
+ * Whether a locale (or bare base subtag) is written right-to-left. Unknown
83
+ * tags are treated as left-to-right.
36
84
  */
37
- export declare function normalizeLanguageCode(lang?: string | null): string;
38
85
  export declare function isRTLLocale(locale?: string | null): boolean;
86
+ /**
87
+ * Resolve the ordered list of account locales for a user, primary first.
88
+ *
89
+ * Reads `user.languages` (the only source — there is no singular `language`
90
+ * field), then normalizes each entry to its canonical `language-REGION` form,
91
+ * drops non-string and unsupported entries, and de-duplicates while preserving
92
+ * first-seen order. Pure and side-effect free — never throws on bad input.
93
+ */
94
+ export declare function getUserLanguages(user: Pick<User, 'languages'> | null | undefined): string[];
95
+ /**
96
+ * Resolve the single PRIMARY locale for a user (the first entry from
97
+ * {@link getUserLanguages}), or `undefined` when the user has no supported
98
+ * locale.
99
+ */
100
+ export declare function getPrimaryLanguage(user: Pick<User, 'languages'> | null | undefined): string | undefined;
@@ -0,0 +1,100 @@
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
+ * Normalize a SINGLE-LINE text value: page/link-preview titles, `siteName`,
47
+ * display names, image alt text, handles, profile field labels.
48
+ *
49
+ * A line break in such a value is never meaningful — it is an artifact of the
50
+ * markup the value was extracted from (`<title>\n Título\n</title>`) — and it
51
+ * survives into the UI because RN Web renders `Text` with `white-space:
52
+ * pre-wrap`. So ALL whitespace, line breaks included, collapses to one space.
53
+ *
54
+ * 1. NFC-normalize, so visually identical strings store and compare
55
+ * identically (a decomposed `e`+◌́ recomposes into `é`).
56
+ * 2. Collapse every run of whitespace — spaces, tabs, `\n`, `\r`, and Unicode
57
+ * spaces such as NBSP — to a single plain space.
58
+ * 3. Trim both ends.
59
+ *
60
+ * Length is NOT capped: a maximum length is a product rule of the specific
61
+ * field (see `MAX_DISPLAY_NAME_LENGTH`), not a property of text normalization.
62
+ * Markup is NOT stripped: run the caller's sanitizer first if the source can
63
+ * contain HTML.
64
+ *
65
+ * A value that is empty or whitespace-only returns `''`. Callers decide whether
66
+ * that means "omit the field" (`|| undefined`) or "store an empty string".
67
+ *
68
+ * Idempotent: `f(f(x)) === f(x)`.
69
+ */
70
+ export declare function normalizeInlineText(value: string): string;
71
+ /**
72
+ * Normalize a MULTILINE text BODY: post text, bios, summaries — anywhere the
73
+ * line breaks are the author's paragraphs and must be preserved.
74
+ *
75
+ * Cleans the whitespace noise around those paragraphs without destroying them:
76
+ *
77
+ * 1. NFC-normalize (same rationale as {@link normalizeInlineText}).
78
+ * 2. Unify every line-break form (CRLF, lone CR, U+2028, U+2029) to `\n`.
79
+ * 3. Collapse runs of HORIZONTAL whitespace (spaces, tabs, NBSP and friends)
80
+ * to a single space. Line breaks are untouched.
81
+ * 4. Strip the horizontal whitespace at the END of each line.
82
+ * 5. Collapse three or more line breaks to exactly one blank line (`\n\n`).
83
+ * 6. Trim both ends.
84
+ *
85
+ * STEP 4 MUST PRECEDE STEP 5 — this is the whole point of the function. A
86
+ * "blank" line that actually contains spaces (`"a\n \n \nb"`) breaks the
87
+ * run of `\n` characters, so a bare `\n{3,}` collapse (step 5 alone) never sees
88
+ * it and the extra blank lines survive into the UI. That is exactly the bug in
89
+ * federated post bodies. Removing the trailing horizontal whitespace first
90
+ * turns those lines into real, empty lines, which step 5 then collapses.
91
+ *
92
+ * A single space at the START of a line is preserved: only RUNS of horizontal
93
+ * whitespace collapse, and an indent is not trailing whitespace, so a one-space
94
+ * indent is treated as the author's and left alone.
95
+ *
96
+ * A value that is empty or whitespace-only returns `''`.
97
+ *
98
+ * Idempotent: `f(f(x)) === f(x)`.
99
+ */
100
+ export declare function normalizeMultilineText(value: string): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "9.2.4",
3
+ "version": "10.1.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -94,8 +94,8 @@
94
94
  }
95
95
  },
96
96
  "dependencies": {
97
- "@oxyhq/contracts": "^0.13.2",
98
- "@oxyhq/protocol": "^0.1.5",
97
+ "@oxyhq/contracts": "workspace:^",
98
+ "@oxyhq/protocol": "workspace:^",
99
99
  "bip39": "^3.1.0",
100
100
  "buffer": "^6.0.3",
101
101
  "elliptic": "^6.6.1",
@@ -232,7 +232,7 @@ export class HttpService {
232
232
  private logger: SimpleLogger;
233
233
  private config: OxyConfig;
234
234
  private tokenRefreshPromise: Promise<string | null> | null = null;
235
- private tokenRefreshCooldownUntil: number = 0;
235
+ private tokenRefreshCooldownUntil = 0;
236
236
  private authRefreshHandler: AuthRefreshHandler | null = null;
237
237
  private accessTokenProvider: AccessTokenProvider | null = null;
238
238
  private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
@@ -242,7 +242,7 @@ export class HttpService {
242
242
  * re-emitted. Throttles the {@link CACHE_SOFT_MAX_ENTRIES} warning to at most
243
243
  * one per {@link CACHE_SIZE_WARNING_THROTTLE_MS} window.
244
244
  */
245
- private cacheSizeWarningSilentUntil: number = 0;
245
+ private cacheSizeWarningSilentUntil = 0;
246
246
 
247
247
  /**
248
248
  * Fan-out listeners notified on EVERY access-token change on this instance:
@@ -1178,9 +1178,9 @@ export class KeyManager {
1178
1178
  * @param userConfirmed - If true, user has explicitly confirmed deletion (default: false)
1179
1179
  */
1180
1180
  static async deleteIdentity(
1181
- skipBackup: boolean = false,
1182
- force: boolean = false,
1183
- userConfirmed: boolean = false
1181
+ skipBackup = false,
1182
+ force = false,
1183
+ userConfirmed = false
1184
1184
  ): Promise<void> {
1185
1185
  if (isWebPlatform()) {
1186
1186
  return; // Identity storage is only available on native platforms, nothing to delete
@@ -160,7 +160,7 @@ export class RecoveryPhraseService {
160
160
  /**
161
161
  * Get suggestions for a partial word
162
162
  */
163
- static getSuggestions(partial: string, limit: number = 5): string[] {
163
+ static getSuggestions(partial: string, limit = 5): string[] {
164
164
  const lowerPartial = partial.toLowerCase();
165
165
  return bip39.wordlists.english
166
166
  .filter((word: string) => word.startsWith(lowerPartial))
package/src/i18n/index.ts CHANGED
@@ -9,6 +9,7 @@ import jaJP from './locales/ja-JP.json';
9
9
  import koKR from './locales/ko-KR.json';
10
10
  import zhCN from './locales/zh-CN.json';
11
11
  import arSA from './locales/ar-SA.json';
12
+ import { getBaseLanguage } from '../utils/languageUtils';
12
13
 
13
14
  export type LocaleDict = Record<string, any>;
14
15
 
@@ -39,12 +40,30 @@ const DICTS: Record<string, LocaleDict> = {
39
40
 
40
41
  const FALLBACK = 'en-US';
41
42
 
43
+ /**
44
+ * Resolve a locale tag to the key of the dictionary that should serve it.
45
+ *
46
+ * Account locales are full BCP-47 tags (e.g. `es-MX`, `pt-BR`, `fr-CA`), but a
47
+ * translation dictionary is shipped per base language. Resolution order:
48
+ * 1. Exact dictionary for the tag (e.g. `es-ES`).
49
+ * 2. Dictionary for the base subtag (e.g. `es-MX` → `es`).
50
+ * 3. The English fallback.
51
+ */
52
+ function resolveLang(locale: string | undefined): string {
53
+ if (locale) {
54
+ if (DICTS[locale]) return locale;
55
+ const base = getBaseLanguage(locale);
56
+ if (DICTS[base]) return base;
57
+ }
58
+ return FALLBACK;
59
+ }
60
+
42
61
  function getNested(obj: any, path: string): any {
43
62
  return path.split('.').reduce((acc, key) => (acc && acc[key] != null ? acc[key] : undefined), obj);
44
63
  }
45
64
 
46
65
  export function translate(locale: string | undefined, key: string, vars?: Record<string, string | number>): string {
47
- const lang = locale && DICTS[locale] ? locale : FALLBACK;
66
+ const lang = resolveLang(locale);
48
67
  const dict = DICTS[lang] || DICTS[FALLBACK];
49
68
  let val = getNested(dict, key);
50
69
  // Per-key fallback to the English dictionary when a key is missing from the
@@ -64,6 +83,6 @@ export function translate(locale: string | undefined, key: string, vars?: Record
64
83
  }
65
84
 
66
85
  export function hasKey(locale: string | undefined, key: string): boolean {
67
- const lang = locale && DICTS[locale] ? locale : FALLBACK;
86
+ const lang = resolveLang(locale);
68
87
  return getNested(DICTS[lang], key) != null || getNested(DICTS[FALLBACK], key) != null;
69
88
  }
@@ -169,9 +169,12 @@
169
169
  },
170
170
  "language": {
171
171
  "title": "Language",
172
- "subtitle": "Choose your preferred language",
172
+ "subtitle": "Choose the languages you use. The first is your primary interface language.",
173
173
  "current": "Current Language",
174
+ "selected": "Your languages",
174
175
  "available": "Available Languages",
176
+ "primary": "Primary",
177
+ "remove": "Remove",
175
178
  "changed": "Language changed to {{lang}}",
176
179
  "saveFailed": "Failed to save language preference",
177
180
  "search": "Search languages"
@@ -123,9 +123,12 @@
123
123
  },
124
124
  "language": {
125
125
  "title": "Idioma",
126
- "subtitle": "Elige tu idioma preferido",
126
+ "subtitle": "Elige los idiomas que usas. El primero es tu idioma de interfaz principal.",
127
127
  "current": "Idioma actual",
128
+ "selected": "Tus idiomas",
128
129
  "available": "Idiomas disponibles",
130
+ "primary": "Principal",
131
+ "remove": "Quitar",
129
132
  "changed": "Idioma cambiado a {{lang}}",
130
133
  "saveFailed": "Error al guardar la preferencia de idioma",
131
134
  "search": "Buscar idiomas"
package/src/index.ts CHANGED
@@ -53,6 +53,7 @@ export type {
53
53
  BulkFollowResult,
54
54
  BulkUnfollowEntry,
55
55
  BulkUnfollowResult,
56
+ ViewerGraph,
56
57
  } from './mixins/OxyServices.user';
57
58
  export { OxyAppDataIdentifierError } from './mixins/OxyServices.appData';
58
59
 
@@ -323,13 +324,18 @@ export type { TopicData, TopicTranslation } from './models/Topic';
323
324
  // ---------------------------------------------------------------------------
324
325
  export {
325
326
  SUPPORTED_LANGUAGES,
327
+ FALLBACK_LOCALE,
328
+ getBaseLanguage,
329
+ normalizeLocale,
330
+ isSupportedLocale,
326
331
  getLanguageMetadata,
327
332
  getLanguageName,
328
333
  getNativeLanguageName,
329
- normalizeLanguageCode,
330
334
  isRTLLocale,
335
+ getUserLanguages,
336
+ getPrimaryLanguage,
331
337
  } from './utils/languageUtils';
332
- export type { LanguageMetadata } from './utils/languageUtils';
338
+ export type { SupportedLanguage } from './utils/languageUtils';
333
339
 
334
340
  // ---------------------------------------------------------------------------
335
341
  // Platform detection
@@ -460,6 +466,14 @@ export {
460
466
  validateAndSanitizeUserInput,
461
467
  } from './utils/validationUtils';
462
468
 
469
+ // ---------------------------------------------------------------------------
470
+ // Text normalization
471
+ // ---------------------------------------------------------------------------
472
+ export {
473
+ normalizeInlineText,
474
+ normalizeMultilineText,
475
+ } from './utils/textNormalization';
476
+
463
477
  // ---------------------------------------------------------------------------
464
478
  // Logging
465
479
  // ---------------------------------------------------------------------------
@@ -435,12 +435,16 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
435
435
  if (error instanceof Error) {
436
436
  errorMessage = error.message || errorMessage;
437
437
  } else if (error && typeof error === 'object') {
438
- if ('message' in error) {
439
- errorMessage = String((error as any).message) || errorMessage;
440
- } else if ('error' in error && typeof (error as any).error === 'string') {
441
- errorMessage = (error as any).error;
442
- } else if ('data' in error && (error as any).data?.message) {
443
- errorMessage = String((error as any).data.message);
438
+ const errObj = error as Record<string, unknown>;
439
+ if ('message' in errObj) {
440
+ errorMessage = String(errObj.message) || errorMessage;
441
+ } else if (typeof errObj.error === 'string') {
442
+ errorMessage = errObj.error;
443
+ } else if (errObj.data && typeof errObj.data === 'object') {
444
+ const dataObj = errObj.data as Record<string, unknown>;
445
+ if (dataObj.message) {
446
+ errorMessage = String(dataObj.message);
447
+ }
444
448
  }
445
449
  } else if (error) {
446
450
  errorMessage = String(error) || errorMessage;
@@ -462,8 +466,8 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
462
466
  throw handledError;
463
467
  }
464
468
 
465
- const newError = new Error(errorMessage);
466
- (newError as any).fileContext = contextError.fileContext;
469
+ const newError: Error & { fileContext?: Record<string, unknown> } = new Error(errorMessage);
470
+ newError.fileContext = contextError.fileContext;
467
471
  throw this.handleError(newError);
468
472
  }
469
473
  }
@@ -546,7 +550,7 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
546
550
  /**
547
551
  * Delete asset with optional force
548
552
  */
549
- async assetDelete(fileId: string, force: boolean = false): Promise<any> {
553
+ async assetDelete(fileId: string, force = false): Promise<any> {
550
554
  try {
551
555
  const params: any = force ? { force: 'true' } : undefined;
552
556
  const result = await this.makeRequest('DELETE', `/assets/${fileId}`, params, { cache: false });
@@ -590,7 +594,7 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
590
594
  }
591
595
  }
592
596
 
593
- async uploadAvatar(file: AssetUploadInput, userId: string, app: string = 'profiles'): Promise<any> {
597
+ async uploadAvatar(file: AssetUploadInput, userId: string, app = 'profiles'): Promise<any> {
594
598
  try {
595
599
  const asset = await this.assetUpload(file, 'public');
596
600
  await this.assetLink(asset.file.id, app, 'avatar', userId, 'public');
@@ -600,7 +604,7 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
600
604
  }
601
605
  }
602
606
 
603
- async uploadProfileBanner(file: AssetUploadInput, userId: string, app: string = 'profiles'): Promise<any> {
607
+ async uploadProfileBanner(file: AssetUploadInput, userId: string, app = 'profiles'): Promise<any> {
604
608
  try {
605
609
  const asset = await this.assetUpload(file, 'public');
606
610
  await this.assetLink(asset.file.id, app, 'profile-banner', userId, 'public');