@oxyhq/core 9.2.4 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/i18n/index.js +22 -2
  3. package/dist/cjs/i18n/locales/en-US.json +4 -1
  4. package/dist/cjs/i18n/locales/es-ES.json +4 -1
  5. package/dist/cjs/i18n/locales/locales/en-US.json +4 -1
  6. package/dist/cjs/i18n/locales/locales/es-ES.json +4 -1
  7. package/dist/cjs/index.js +10 -5
  8. package/dist/cjs/mixins/OxyServices.assets.js +10 -6
  9. package/dist/cjs/mixins/OxyServices.language.js +11 -10
  10. package/dist/cjs/mixins/OxyServices.privacy.js +6 -0
  11. package/dist/cjs/mixins/OxyServices.user.js +40 -0
  12. package/dist/cjs/server/safeFetch.js +3 -3
  13. package/dist/cjs/session/SessionClient.js +1 -1
  14. package/dist/cjs/shared/utils/colorUtils.js +9 -9
  15. package/dist/cjs/utils/languageUtils.js +195 -158
  16. package/dist/cjs/utils/platform.js +9 -2
  17. package/dist/esm/.tsbuildinfo +1 -1
  18. package/dist/esm/i18n/index.js +22 -2
  19. package/dist/esm/i18n/locales/en-US.json +4 -1
  20. package/dist/esm/i18n/locales/es-ES.json +4 -1
  21. package/dist/esm/i18n/locales/locales/en-US.json +4 -1
  22. package/dist/esm/i18n/locales/locales/es-ES.json +4 -1
  23. package/dist/esm/index.js +1 -1
  24. package/dist/esm/mixins/OxyServices.assets.js +10 -6
  25. package/dist/esm/mixins/OxyServices.language.js +12 -11
  26. package/dist/esm/mixins/OxyServices.privacy.js +6 -0
  27. package/dist/esm/mixins/OxyServices.user.js +40 -0
  28. package/dist/esm/server/safeFetch.js +3 -3
  29. package/dist/esm/session/SessionClient.js +1 -1
  30. package/dist/esm/shared/utils/colorUtils.js +9 -9
  31. package/dist/esm/utils/languageUtils.js +189 -156
  32. package/dist/esm/utils/platform.js +9 -2
  33. package/dist/types/.tsbuildinfo +1 -1
  34. package/dist/types/index.d.ts +3 -3
  35. package/dist/types/mixins/OxyServices.language.d.ts +4 -4
  36. package/dist/types/mixins/OxyServices.user.d.ts +30 -0
  37. package/dist/types/models/interfaces.d.ts +8 -0
  38. package/dist/types/session/accountDialogController.d.ts +1 -1
  39. package/dist/types/utils/languageUtils.d.ts +86 -24
  40. package/package.json +2 -2
  41. package/src/HttpService.ts +2 -2
  42. package/src/crypto/keyManager.ts +3 -3
  43. package/src/crypto/recoveryPhrase.ts +1 -1
  44. package/src/i18n/index.ts +21 -2
  45. package/src/i18n/locales/en-US.json +4 -1
  46. package/src/i18n/locales/es-ES.json +4 -1
  47. package/src/index.ts +8 -2
  48. package/src/mixins/OxyServices.assets.ts +15 -11
  49. package/src/mixins/OxyServices.language.ts +31 -17
  50. package/src/mixins/OxyServices.privacy.ts +6 -0
  51. package/src/mixins/OxyServices.security.ts +1 -1
  52. package/src/mixins/OxyServices.user.ts +57 -0
  53. package/src/models/interfaces.ts +8 -0
  54. package/src/server/safeFetch.ts +3 -3
  55. package/src/session/SessionClient.ts +1 -1
  56. package/src/session/accountDialogController.ts +1 -1
  57. package/src/shared/utils/colorUtils.ts +11 -11
  58. package/src/shared/utils/errorUtils.ts +1 -1
  59. package/src/utils/__tests__/languageUtils.test.ts +219 -0
  60. package/src/utils/avatarUtils.ts +1 -1
  61. package/src/utils/languageUtils.ts +223 -162
  62. package/src/utils/platform.ts +21 -3
  63. package/src/utils/requestUtils.ts +3 -3
  64. package/src/utils/sessionUtils.ts +2 -2
@@ -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
  */
@@ -144,6 +144,14 @@ export interface User {
144
144
  managedBy?: string;
145
145
  /** Real-estate taxonomy when this user is a `kind: 'organization'` account. */
146
146
  organizationCategory?: OrganizationCategory;
147
+ /**
148
+ * The account's languages as full BCP-47 locales (`language-REGION`, e.g.
149
+ * `en-US`, `es-MX`, `pt-BR`), ordered with the PRIMARY (UI) locale first.
150
+ * `languages[0]` is the primary locale — there is no singular `language`
151
+ * field. Resolve via `getUserLanguages` / `getPrimaryLanguage`, which
152
+ * normalize, validate against the supported catalog, and de-duplicate.
153
+ */
154
+ languages?: string[];
147
155
  // User-controlled notification preferences. All channels default to on; users
148
156
  // opt out per-channel. Updated via `PUT /users/me`.
149
157
  notificationPreferences?: NotificationPreferences;
@@ -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: Infinity,
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: number = 0.6): string => {
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: number = 0.3): string => {
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: string = 'An unknown error occurred'): string => {
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
+ });
@@ -17,7 +17,7 @@ export interface AssetVisibilityService {
17
17
  export async function updateAvatarVisibility(
18
18
  fileId: string | undefined,
19
19
  oxyServices: AssetVisibilityService,
20
- contextName: string = 'AvatarUtils'
20
+ contextName = 'AvatarUtils'
21
21
  ): Promise<void> {
22
22
  if (!fileId || fileId.startsWith('temp-')) {
23
23
  return;