@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,12 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Language Methods Mixin
|
|
3
3
|
*/
|
|
4
|
-
import {
|
|
5
|
-
import type {
|
|
4
|
+
import { normalizeLocale, getPrimaryLanguage, getLanguageMetadata, getLanguageName, getNativeLanguageName } from '../utils/languageUtils';
|
|
5
|
+
import type { SupportedLanguage } from '../utils/languageUtils';
|
|
6
6
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
7
7
|
import { loadAsyncStorage } from '@oxyhq/protocol';
|
|
8
8
|
import { isDev } from '../shared/utils/debugUtils';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Cross-mixin surface consumed by the language methods. `getCurrentUser` is
|
|
12
|
+
* provided by the user mixin at runtime — both live on the composed
|
|
13
|
+
* `OxyServices` instance — but it is not visible on `OxyServicesBase` at the
|
|
14
|
+
* type level. We narrow `this` through this interface (mirroring the
|
|
15
|
+
* `OxyAuthInstance` pattern in `OxyServices.utility.ts`) instead of casting to
|
|
16
|
+
* `any`. Only the fields this mixin reads are declared, keeping it decoupled
|
|
17
|
+
* from the full `User` shape.
|
|
18
|
+
*/
|
|
19
|
+
interface LanguageMixinCrossAccess {
|
|
20
|
+
getCurrentUser(): Promise<{ languages?: string[] }>;
|
|
21
|
+
}
|
|
22
|
+
|
|
10
23
|
export function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
11
24
|
return class extends Base {
|
|
12
25
|
constructor(...args: any[]) {
|
|
@@ -62,29 +75,30 @@ export function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
62
75
|
}
|
|
63
76
|
|
|
64
77
|
/**
|
|
65
|
-
* Get the current
|
|
78
|
+
* Get the current locale from the user profile or local storage.
|
|
66
79
|
* @param storageKeyPrefix - Optional prefix for storage key (default: 'oxy_session')
|
|
67
|
-
* @returns The current
|
|
80
|
+
* @returns The current BCP-47 locale (e.g., 'en-US') or null if not set
|
|
68
81
|
*/
|
|
69
|
-
async getCurrentLanguage(storageKeyPrefix
|
|
82
|
+
async getCurrentLanguage(storageKeyPrefix = 'oxy_session'): Promise<string | null> {
|
|
70
83
|
try {
|
|
71
|
-
// First try
|
|
84
|
+
// First try the authenticated user's primary account locale.
|
|
72
85
|
try {
|
|
73
|
-
const user = await (this as
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
76
|
-
return
|
|
86
|
+
const user = await (this as unknown as LanguageMixinCrossAccess).getCurrentUser();
|
|
87
|
+
const primary = getPrimaryLanguage(user);
|
|
88
|
+
if (primary) {
|
|
89
|
+
return primary;
|
|
77
90
|
}
|
|
78
|
-
} catch
|
|
79
|
-
//
|
|
91
|
+
} catch {
|
|
92
|
+
// Not authenticated or the profile fetch failed — fall through to the
|
|
93
|
+
// locally stored preference below.
|
|
80
94
|
}
|
|
81
95
|
|
|
82
|
-
// Fall back to
|
|
96
|
+
// Fall back to the locally stored locale preference.
|
|
83
97
|
const storage = await this.getStorage();
|
|
84
98
|
const storageKey = `${storageKeyPrefix}_language`;
|
|
85
99
|
const storedLanguage = await storage.getItem(storageKey);
|
|
86
100
|
if (storedLanguage) {
|
|
87
|
-
return
|
|
101
|
+
return normalizeLocale(storedLanguage) ?? storedLanguage;
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
return null;
|
|
@@ -101,7 +115,7 @@ export function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
101
115
|
* @param storageKeyPrefix - Optional prefix for storage key (default: 'oxy_session')
|
|
102
116
|
* @returns Language metadata object or null if not set
|
|
103
117
|
*/
|
|
104
|
-
async getCurrentLanguageMetadata(storageKeyPrefix
|
|
118
|
+
async getCurrentLanguageMetadata(storageKeyPrefix = 'oxy_session'): Promise<SupportedLanguage | null> {
|
|
105
119
|
const languageCode = await this.getCurrentLanguage(storageKeyPrefix);
|
|
106
120
|
return getLanguageMetadata(languageCode);
|
|
107
121
|
}
|
|
@@ -111,7 +125,7 @@ export function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
111
125
|
* @param storageKeyPrefix - Optional prefix for storage key (default: 'oxy_session')
|
|
112
126
|
* @returns Language name or null if not set
|
|
113
127
|
*/
|
|
114
|
-
async getCurrentLanguageName(storageKeyPrefix
|
|
128
|
+
async getCurrentLanguageName(storageKeyPrefix = 'oxy_session'): Promise<string | null> {
|
|
115
129
|
const languageCode = await this.getCurrentLanguage(storageKeyPrefix);
|
|
116
130
|
if (!languageCode) return null;
|
|
117
131
|
return getLanguageName(languageCode);
|
|
@@ -122,7 +136,7 @@ export function OxyServicesLanguageMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
122
136
|
* @param storageKeyPrefix - Optional prefix for storage key (default: 'oxy_session')
|
|
123
137
|
* @returns Native language name or null if not set
|
|
124
138
|
*/
|
|
125
|
-
async getCurrentNativeLanguageName(storageKeyPrefix
|
|
139
|
+
async getCurrentNativeLanguageName(storageKeyPrefix = 'oxy_session'): Promise<string | null> {
|
|
126
140
|
const languageCode = await this.getCurrentLanguage(storageKeyPrefix);
|
|
127
141
|
if (!languageCode) return null;
|
|
128
142
|
return getNativeLanguageName(languageCode);
|
|
@@ -82,6 +82,9 @@ export function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
82
82
|
cache: false,
|
|
83
83
|
});
|
|
84
84
|
this.clearCacheEntry('GET:/privacy/blocked');
|
|
85
|
+
// The block changed the viewer's graph (`blockedIds`) — bust the cached
|
|
86
|
+
// consolidated `GET /users/me/graph` so the next read reflects it.
|
|
87
|
+
this.clearCacheEntry('GET:/users/me/graph');
|
|
85
88
|
return result;
|
|
86
89
|
} catch (error) {
|
|
87
90
|
throw this.handleError(error);
|
|
@@ -105,6 +108,9 @@ export function OxyServicesPrivacyMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
105
108
|
cache: false,
|
|
106
109
|
});
|
|
107
110
|
this.clearCacheEntry('GET:/privacy/blocked');
|
|
111
|
+
// Symmetric to blockUser: the unblock changed the viewer's `blockedIds`,
|
|
112
|
+
// so bust the consolidated `GET /users/me/graph` cache.
|
|
113
|
+
this.clearCacheEntry('GET:/users/me/graph');
|
|
108
114
|
return result;
|
|
109
115
|
} catch (error) {
|
|
110
116
|
throw this.handleError(error);
|
|
@@ -57,7 +57,7 @@ export function OxyServicesSecurityMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
57
57
|
* @param limit - Number of recent events to fetch (default: 10)
|
|
58
58
|
* @returns Array of recent security activities
|
|
59
59
|
*/
|
|
60
|
-
async getRecentSecurityActivity(limit
|
|
60
|
+
async getRecentSecurityActivity(limit = 10): Promise<SecurityActivity[]> {
|
|
61
61
|
try {
|
|
62
62
|
const response = await this.getSecurityActivity(limit, 0);
|
|
63
63
|
return response.data || [];
|
|
@@ -49,6 +49,23 @@ export interface BulkFollowResult {
|
|
|
49
49
|
followedCount: number;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* The authenticated viewer's OWN social graph, ids-only — the response of
|
|
54
|
+
* `GET /users/me/graph`. Consolidates the accounts the viewer follows, the
|
|
55
|
+
* subset who follow back (mutuals), and the accounts the viewer has blocked
|
|
56
|
+
* into one payload so a consumer can fetch its whole viewer graph in a single
|
|
57
|
+
* round trip instead of three. Each list is server-bounded; bare ids only (no
|
|
58
|
+
* hydrated DTOs) because the consumer hydrates/ranks itself.
|
|
59
|
+
*/
|
|
60
|
+
export interface ViewerGraph {
|
|
61
|
+
/** Accounts the viewer follows (most-recent first, bounded). */
|
|
62
|
+
followingIds: string[];
|
|
63
|
+
/** Accounts the viewer follows that ALSO follow the viewer back (bounded). */
|
|
64
|
+
mutualIds: string[];
|
|
65
|
+
/** Accounts the viewer has blocked (bounded). */
|
|
66
|
+
blockedIds: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
/** Per-user outcome returned by `POST /users/unfollow/bulk`. */
|
|
53
70
|
export interface BulkUnfollowEntry {
|
|
54
71
|
/** The user ID that was processed. */
|
|
@@ -666,6 +683,10 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
666
683
|
try {
|
|
667
684
|
const result = await this.makeRequest<{ success: boolean; message: string }>('POST', `/users/${userId}/follow`, undefined, { cache: false });
|
|
668
685
|
this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
|
|
686
|
+
// The follow changed the viewer's graph — bust the cached consolidated
|
|
687
|
+
// `GET /users/me/graph` so the next read reflects the new following/
|
|
688
|
+
// mutual set instead of the stale pre-write snapshot.
|
|
689
|
+
this.clearCacheEntry('GET:/users/me/graph');
|
|
669
690
|
return result;
|
|
670
691
|
} catch (error) {
|
|
671
692
|
throw this.handleError(error);
|
|
@@ -690,6 +711,8 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
690
711
|
for (const id of userIds) {
|
|
691
712
|
this.clearCacheEntry(`GET:/users/${id}/follow-status`);
|
|
692
713
|
}
|
|
714
|
+
// The batch changed the viewer's graph — bust the consolidated cache.
|
|
715
|
+
this.clearCacheEntry('GET:/users/me/graph');
|
|
693
716
|
return result;
|
|
694
717
|
} catch (error) {
|
|
695
718
|
throw this.handleError(error);
|
|
@@ -714,6 +737,8 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
714
737
|
for (const id of userIds) {
|
|
715
738
|
this.clearCacheEntry(`GET:/users/${id}/follow-status`);
|
|
716
739
|
}
|
|
740
|
+
// The batch changed the viewer's graph — bust the consolidated cache.
|
|
741
|
+
this.clearCacheEntry('GET:/users/me/graph');
|
|
717
742
|
return result;
|
|
718
743
|
} catch (error) {
|
|
719
744
|
throw this.handleError(error);
|
|
@@ -728,6 +753,8 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
728
753
|
const result = await this.makeRequest<{ success: boolean; message: string }>('DELETE', `/users/${userId}/follow`, undefined, { cache: false });
|
|
729
754
|
// Bust the cached follow-status so a remount reads fresh truth (see `followUser`).
|
|
730
755
|
this.clearCacheEntry(`GET:/users/${userId}/follow-status`);
|
|
756
|
+
// The unfollow changed the viewer's graph — bust the consolidated cache.
|
|
757
|
+
this.clearCacheEntry('GET:/users/me/graph');
|
|
731
758
|
return result;
|
|
732
759
|
} catch (error) {
|
|
733
760
|
throw this.handleError(error);
|
|
@@ -872,6 +899,36 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
|
|
|
872
899
|
}
|
|
873
900
|
}
|
|
874
901
|
|
|
902
|
+
/**
|
|
903
|
+
* Get the authenticated VIEWER's OWN social graph — the accounts they follow,
|
|
904
|
+
* the subset who follow back (mutuals), and the accounts they have blocked —
|
|
905
|
+
* as ONE ids-only payload. The viewer is derived server-side from the SDK's
|
|
906
|
+
* auth token (never a param).
|
|
907
|
+
*
|
|
908
|
+
* Consolidates what were three separate round trips (`getUserFollowing` /
|
|
909
|
+
* `getMutualUserIds` / `getBlockedUsers`) into a single request so a consumer
|
|
910
|
+
* can prime its whole viewer graph at once. Mirrors {@link getMutualUserIds}'s
|
|
911
|
+
* caching posture (2-minute identity-scoped cache); the follow/unfollow/block/
|
|
912
|
+
* unblock write methods bust this entry so a local mutation is reflected
|
|
913
|
+
* immediately. An anonymous caller resolves to empty lists.
|
|
914
|
+
*/
|
|
915
|
+
async getViewerGraph(): Promise<ViewerGraph> {
|
|
916
|
+
try {
|
|
917
|
+
const response = await this.makeRequest<{ data: ViewerGraph }>('GET', '/users/me/graph', undefined, {
|
|
918
|
+
cache: true,
|
|
919
|
+
cacheTTL: 2 * 60 * 1000, // 2 minutes cache
|
|
920
|
+
});
|
|
921
|
+
const graph = response.data;
|
|
922
|
+
return {
|
|
923
|
+
followingIds: graph?.followingIds || [],
|
|
924
|
+
mutualIds: graph?.mutualIds || [],
|
|
925
|
+
blockedIds: graph?.blockedIds || [],
|
|
926
|
+
};
|
|
927
|
+
} catch (error) {
|
|
928
|
+
throw this.handleError(error);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
875
932
|
/**
|
|
876
933
|
* Get notifications
|
|
877
934
|
*/
|
package/src/models/interfaces.ts
CHANGED
|
@@ -103,6 +103,12 @@ export interface User {
|
|
|
103
103
|
*/
|
|
104
104
|
name: UserNameResponse;
|
|
105
105
|
bio?: string;
|
|
106
|
+
/**
|
|
107
|
+
* Longer public profile text. Emitted alongside `bio` by every user DTO the
|
|
108
|
+
* API produces (single profile, `getUsersByIds`, follower/following/mutual
|
|
109
|
+
* lists, profile search) — the two are separate fields on the User document.
|
|
110
|
+
*/
|
|
111
|
+
description?: string;
|
|
106
112
|
phone?: string;
|
|
107
113
|
address?: string;
|
|
108
114
|
birthday?: string;
|
|
@@ -144,6 +150,14 @@ export interface User {
|
|
|
144
150
|
managedBy?: string;
|
|
145
151
|
/** Real-estate taxonomy when this user is a `kind: 'organization'` account. */
|
|
146
152
|
organizationCategory?: OrganizationCategory;
|
|
153
|
+
/**
|
|
154
|
+
* The account's languages as full BCP-47 locales (`language-REGION`, e.g.
|
|
155
|
+
* `en-US`, `es-MX`, `pt-BR`), ordered with the PRIMARY (UI) locale first.
|
|
156
|
+
* `languages[0]` is the primary locale — there is no singular `language`
|
|
157
|
+
* field. Resolve via `getUserLanguages` / `getPrimaryLanguage`, which
|
|
158
|
+
* normalize, validate against the supported catalog, and de-duplicate.
|
|
159
|
+
*/
|
|
160
|
+
languages?: string[];
|
|
147
161
|
// User-controlled notification preferences. All channels default to on; users
|
|
148
162
|
// opt out per-channel. Updated via `PUT /users/me`.
|
|
149
163
|
notificationPreferences?: NotificationPreferences;
|
package/src/server/safeFetch.ts
CHANGED
|
@@ -183,7 +183,7 @@ function ipv6ToGroups(ip: string): number[] | null {
|
|
|
183
183
|
const groups: number[] = [];
|
|
184
184
|
for (const part of segment.split(':')) {
|
|
185
185
|
if (!/^[0-9a-fA-F]{1,4}$/.test(part)) return null;
|
|
186
|
-
groups.push(parseInt(part, 16));
|
|
186
|
+
groups.push(Number.parseInt(part, 16));
|
|
187
187
|
}
|
|
188
188
|
return groups;
|
|
189
189
|
};
|
|
@@ -234,8 +234,8 @@ function extractEmbeddedIpv4(ip: string): string | null {
|
|
|
234
234
|
// Hex form "::ffff:0102:0304" → 1.2.3.4
|
|
235
235
|
const hexMapped = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
236
236
|
if (hexMapped) {
|
|
237
|
-
const hi = parseInt(hexMapped[1], 16);
|
|
238
|
-
const lo = parseInt(hexMapped[2], 16);
|
|
237
|
+
const hi = Number.parseInt(hexMapped[1], 16);
|
|
238
|
+
const lo = Number.parseInt(hexMapped[2], 16);
|
|
239
239
|
return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
|
|
240
240
|
}
|
|
241
241
|
return null;
|
|
@@ -374,7 +374,7 @@ export class SessionClient {
|
|
|
374
374
|
transports: ['websocket'],
|
|
375
375
|
autoConnect: true,
|
|
376
376
|
reconnection: true,
|
|
377
|
-
reconnectionAttempts:
|
|
377
|
+
reconnectionAttempts: Number.POSITIVE_INFINITY,
|
|
378
378
|
reconnectionDelay: 1000,
|
|
379
379
|
reconnectionDelayMax: 10000,
|
|
380
380
|
auth: (cb: (data: Record<string, string>) => void) => {
|
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
normalizeOAuthRedirectUri,
|
|
38
38
|
persistOAuthHandshake,
|
|
39
39
|
} from '../utils/oauthPkce';
|
|
40
|
-
import { SessionClient } from './SessionClient';
|
|
40
|
+
import type { SessionClient } from './SessionClient';
|
|
41
41
|
import {
|
|
42
42
|
projectSwitchableAccounts,
|
|
43
43
|
switchableAccountIds,
|
|
@@ -20,12 +20,12 @@
|
|
|
20
20
|
* darkenColor('FF0000', 0.3); // Also works without #
|
|
21
21
|
* ```
|
|
22
22
|
*/
|
|
23
|
-
export const darkenColor = (color: string, factor
|
|
23
|
+
export const darkenColor = (color: string, factor = 0.6): string => {
|
|
24
24
|
const hex = color.replace('#', '');
|
|
25
25
|
|
|
26
|
-
const r = parseInt(hex.substring(0, 2), 16);
|
|
27
|
-
const g = parseInt(hex.substring(2, 4), 16);
|
|
28
|
-
const b = parseInt(hex.substring(4, 6), 16);
|
|
26
|
+
const r = Number.parseInt(hex.substring(0, 2), 16);
|
|
27
|
+
const g = Number.parseInt(hex.substring(2, 4), 16);
|
|
28
|
+
const b = Number.parseInt(hex.substring(4, 6), 16);
|
|
29
29
|
|
|
30
30
|
const newR = Math.max(0, Math.round(r * (1 - factor)));
|
|
31
31
|
const newG = Math.max(0, Math.round(g * (1 - factor)));
|
|
@@ -46,12 +46,12 @@ export const darkenColor = (color: string, factor: number = 0.6): string => {
|
|
|
46
46
|
* lightenColor('#0000FF', 0.5); // Returns a lighter blue
|
|
47
47
|
* ```
|
|
48
48
|
*/
|
|
49
|
-
export const lightenColor = (color: string, factor
|
|
49
|
+
export const lightenColor = (color: string, factor = 0.3): string => {
|
|
50
50
|
const hex = color.replace('#', '');
|
|
51
51
|
|
|
52
|
-
const r = parseInt(hex.substring(0, 2), 16);
|
|
53
|
-
const g = parseInt(hex.substring(2, 4), 16);
|
|
54
|
-
const b = parseInt(hex.substring(4, 6), 16);
|
|
52
|
+
const r = Number.parseInt(hex.substring(0, 2), 16);
|
|
53
|
+
const g = Number.parseInt(hex.substring(2, 4), 16);
|
|
54
|
+
const b = Number.parseInt(hex.substring(4, 6), 16);
|
|
55
55
|
|
|
56
56
|
const newR = Math.min(255, Math.round(r + (255 - r) * factor));
|
|
57
57
|
const newG = Math.min(255, Math.round(g + (255 - g) * factor));
|
|
@@ -75,9 +75,9 @@ export const hexToRgb = (hex: string): { r: number; g: number; b: number } | nul
|
|
|
75
75
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
76
76
|
return result
|
|
77
77
|
? {
|
|
78
|
-
r: parseInt(result[1], 16),
|
|
79
|
-
g: parseInt(result[2], 16),
|
|
80
|
-
b: parseInt(result[3], 16),
|
|
78
|
+
r: Number.parseInt(result[1], 16),
|
|
79
|
+
g: Number.parseInt(result[2], 16),
|
|
80
|
+
b: Number.parseInt(result[3], 16),
|
|
81
81
|
}
|
|
82
82
|
: null;
|
|
83
83
|
};
|
|
@@ -57,7 +57,7 @@ export const getErrorStatus = (error: unknown): number | undefined => {
|
|
|
57
57
|
* @param fallback - Fallback message if none found
|
|
58
58
|
* @returns The error message
|
|
59
59
|
*/
|
|
60
|
-
export const getErrorMessage = (error: unknown, fallback
|
|
60
|
+
export const getErrorMessage = (error: unknown, fallback = 'An unknown error occurred'): string => {
|
|
61
61
|
if (!error) return fallback;
|
|
62
62
|
|
|
63
63
|
if (typeof error === 'string') return error;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SUPPORTED_LANGUAGES,
|
|
3
|
+
getBaseLanguage,
|
|
4
|
+
normalizeLocale,
|
|
5
|
+
isSupportedLocale,
|
|
6
|
+
getLanguageMetadata,
|
|
7
|
+
getLanguageName,
|
|
8
|
+
getNativeLanguageName,
|
|
9
|
+
isRTLLocale,
|
|
10
|
+
getUserLanguages,
|
|
11
|
+
getPrimaryLanguage,
|
|
12
|
+
} from '../languageUtils';
|
|
13
|
+
|
|
14
|
+
describe('getBaseLanguage', () => {
|
|
15
|
+
it('extracts the base subtag from a full locale, lowercased', () => {
|
|
16
|
+
expect(getBaseLanguage('es-ES')).toBe('es');
|
|
17
|
+
expect(getBaseLanguage('EN-us')).toBe('en');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('is tolerant of a bare base subtag', () => {
|
|
21
|
+
expect(getBaseLanguage('es')).toBe('es');
|
|
22
|
+
expect(getBaseLanguage('ES')).toBe('es');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('ignores script/variant subtags', () => {
|
|
26
|
+
expect(getBaseLanguage('zh-Hant-TW')).toBe('zh');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('trims surrounding whitespace and handles empty input', () => {
|
|
30
|
+
expect(getBaseLanguage(' fr-FR ')).toBe('fr');
|
|
31
|
+
expect(getBaseLanguage('')).toBe('');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('normalizeLocale', () => {
|
|
36
|
+
it('canonicalizes case: lowercase base, uppercase region', () => {
|
|
37
|
+
expect(normalizeLocale('es-es')).toBe('es-ES');
|
|
38
|
+
expect(normalizeLocale('EN-us')).toBe('en-US');
|
|
39
|
+
expect(normalizeLocale('PT-br')).toBe('pt-BR');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('returns already-canonical supported locales unchanged', () => {
|
|
43
|
+
expect(normalizeLocale('es-MX')).toBe('es-MX');
|
|
44
|
+
expect(normalizeLocale('zh-TW')).toBe('zh-TW');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('trims surrounding whitespace', () => {
|
|
48
|
+
expect(normalizeLocale(' fr-CA ')).toBe('fr-CA');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('collapses extra subtags to language-REGION', () => {
|
|
52
|
+
expect(normalizeLocale('zh-Hant-TW')).toBe('zh-TW');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('returns undefined for a bare base subtag (no region)', () => {
|
|
56
|
+
expect(normalizeLocale('es')).toBeUndefined();
|
|
57
|
+
expect(normalizeLocale('en')).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('returns undefined for unsupported locales', () => {
|
|
61
|
+
expect(normalizeLocale('xx-ZZ')).toBeUndefined();
|
|
62
|
+
expect(normalizeLocale('es-ZZ')).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns undefined for empty or whitespace input', () => {
|
|
66
|
+
expect(normalizeLocale('')).toBeUndefined();
|
|
67
|
+
expect(normalizeLocale(' ')).toBeUndefined();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('isSupportedLocale', () => {
|
|
72
|
+
it('is true for supported locales in any case', () => {
|
|
73
|
+
expect(isSupportedLocale('es-ES')).toBe(true);
|
|
74
|
+
expect(isSupportedLocale('es-es')).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('is false for bare subtags and unknown locales', () => {
|
|
78
|
+
expect(isSupportedLocale('es')).toBe(false);
|
|
79
|
+
expect(isSupportedLocale('xx-ZZ')).toBe(false);
|
|
80
|
+
expect(isSupportedLocale('')).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('getLanguageMetadata', () => {
|
|
85
|
+
it('resolves a supported locale to its catalog entry', () => {
|
|
86
|
+
const entry = getLanguageMetadata('es-mx');
|
|
87
|
+
expect(entry).not.toBeNull();
|
|
88
|
+
expect(entry?.code).toBe('es-MX');
|
|
89
|
+
expect(entry?.language).toBe('es');
|
|
90
|
+
expect(entry?.region).toBe('MX');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('returns null for bare, unknown, or empty codes', () => {
|
|
94
|
+
expect(getLanguageMetadata('es')).toBeNull();
|
|
95
|
+
expect(getLanguageMetadata('xx-ZZ')).toBeNull();
|
|
96
|
+
expect(getLanguageMetadata('')).toBeNull();
|
|
97
|
+
expect(getLanguageMetadata(null)).toBeNull();
|
|
98
|
+
expect(getLanguageMetadata(undefined)).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('getLanguageName / getNativeLanguageName', () => {
|
|
103
|
+
it('returns the English and native display names for a supported locale', () => {
|
|
104
|
+
expect(getLanguageName('es-ES')).toBe('Spanish (Spain)');
|
|
105
|
+
expect(getNativeLanguageName('es-ES')).toBe('Español (España)');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('falls back to the input tag for unsupported locales', () => {
|
|
109
|
+
expect(getLanguageName('xx-ZZ')).toBe('xx-ZZ');
|
|
110
|
+
expect(getNativeLanguageName('xx-ZZ')).toBe('xx-ZZ');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('falls back to the fallback locale for empty input', () => {
|
|
114
|
+
expect(getLanguageName('')).toBe('en-US');
|
|
115
|
+
expect(getNativeLanguageName(null)).toBe('en-US');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('isRTLLocale', () => {
|
|
120
|
+
it('is true for right-to-left locales and their base subtags', () => {
|
|
121
|
+
expect(isRTLLocale('ar-SA')).toBe(true);
|
|
122
|
+
expect(isRTLLocale('he-IL')).toBe(true);
|
|
123
|
+
expect(isRTLLocale('fa')).toBe(true);
|
|
124
|
+
expect(isRTLLocale('ur-PK')).toBe(true);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('is false for left-to-right locales and empty input', () => {
|
|
128
|
+
expect(isRTLLocale('en-US')).toBe(false);
|
|
129
|
+
expect(isRTLLocale('es-ES')).toBe(false);
|
|
130
|
+
expect(isRTLLocale(null)).toBe(false);
|
|
131
|
+
expect(isRTLLocale(undefined)).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('getUserLanguages', () => {
|
|
136
|
+
it('normalizes and validates the plural array, preserving order', () => {
|
|
137
|
+
expect(getUserLanguages({ languages: ['es-es', 'EN-us', 'pt-BR'] })).toEqual([
|
|
138
|
+
'es-ES',
|
|
139
|
+
'en-US',
|
|
140
|
+
'pt-BR',
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('drops unsupported locales and bare subtags', () => {
|
|
145
|
+
expect(getUserLanguages({ languages: ['es-ES', 'xx-ZZ', 'en'] })).toEqual(['es-ES']);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('de-duplicates after normalization, keeping first-seen order', () => {
|
|
149
|
+
expect(getUserLanguages({ languages: ['es-ES', 'es-es', 'en-US', 'ES-es'] })).toEqual([
|
|
150
|
+
'es-ES',
|
|
151
|
+
'en-US',
|
|
152
|
+
]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('ignores non-string junk entries without throwing', () => {
|
|
156
|
+
const languages = ['es-ES', 42, null, undefined, {}, 'en-US'] as unknown as string[];
|
|
157
|
+
expect(getUserLanguages({ languages })).toEqual(['es-ES', 'en-US']);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('returns an empty array when languages is absent, empty, or non-array', () => {
|
|
161
|
+
expect(getUserLanguages({})).toEqual([]);
|
|
162
|
+
expect(getUserLanguages({ languages: [] })).toEqual([]);
|
|
163
|
+
expect(getUserLanguages({ languages: undefined })).toEqual([]);
|
|
164
|
+
expect(getUserLanguages(null)).toEqual([]);
|
|
165
|
+
expect(getUserLanguages(undefined)).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('ignores any stray singular language field (removed from User)', () => {
|
|
169
|
+
const strayLanguage: { language: string; languages?: string[] } = { language: 'es-ES' };
|
|
170
|
+
expect(getUserLanguages(strayLanguage)).toEqual([]);
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('getPrimaryLanguage', () => {
|
|
175
|
+
it('returns the first normalized locale from the plural array', () => {
|
|
176
|
+
expect(getPrimaryLanguage({ languages: ['es-es', 'en-US'] })).toBe('es-ES');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('skips leading unsupported entries', () => {
|
|
180
|
+
expect(getPrimaryLanguage({ languages: ['xx-ZZ', 'pt-br'] })).toBe('pt-BR');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('returns undefined when the user has no supported locale', () => {
|
|
184
|
+
expect(getPrimaryLanguage({})).toBeUndefined();
|
|
185
|
+
expect(getPrimaryLanguage({ languages: ['xx-ZZ'] })).toBeUndefined();
|
|
186
|
+
expect(getPrimaryLanguage(null)).toBeUndefined();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('SUPPORTED_LANGUAGES catalog integrity', () => {
|
|
191
|
+
it('is non-empty', () => {
|
|
192
|
+
expect(SUPPORTED_LANGUAGES.length).toBeGreaterThan(0);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('has no duplicate codes', () => {
|
|
196
|
+
const codes = SUPPORTED_LANGUAGES.map((entry) => entry.code);
|
|
197
|
+
expect(new Set(codes).size).toBe(codes.length);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('every entry is internally consistent', () => {
|
|
201
|
+
for (const entry of SUPPORTED_LANGUAGES) {
|
|
202
|
+
// code is canonical language-REGION derived from its subtags
|
|
203
|
+
expect(entry.code).toBe(`${entry.language}-${entry.region}`);
|
|
204
|
+
// base subtag is lowercase, region is uppercase
|
|
205
|
+
expect(entry.language).toBe(entry.language.toLowerCase());
|
|
206
|
+
expect(entry.region).toBe(entry.region.toUpperCase());
|
|
207
|
+
// parsing the code recovers the subtags
|
|
208
|
+
expect(getBaseLanguage(entry.code)).toBe(entry.language);
|
|
209
|
+
// the code round-trips through normalization
|
|
210
|
+
expect(normalizeLocale(entry.code)).toBe(entry.code);
|
|
211
|
+
expect(isSupportedLocale(entry.code)).toBe(true);
|
|
212
|
+
// display names are present
|
|
213
|
+
expect(entry.name.length).toBeGreaterThan(0);
|
|
214
|
+
expect(entry.nativeName.length).toBeGreaterThan(0);
|
|
215
|
+
// rtl flag agrees with the RTL detector
|
|
216
|
+
expect(isRTLLocale(entry.code)).toBe(entry.rtl === true);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
});
|