@oxy.so/services 13.6.0 → 13.8.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 (114) hide show
  1. package/android/build.gradle +1 -0
  2. package/android/src/main/java/so/oxy/assets/OxyAssetFilesModule.kt +49 -0
  3. package/android/src/main/java/so/oxy/assets/StreamSha256.kt +23 -0
  4. package/android/src/test/java/so/oxy/assets/StreamSha256Test.kt +45 -0
  5. package/expo-module.config.json +3 -2
  6. package/ios/OxyAssetFilesModule.swift +72 -0
  7. package/lib/commonjs/ui/components/AccountLanguageBoundary.js +35 -0
  8. package/lib/commonjs/ui/components/AccountLanguageBoundary.js.map +1 -0
  9. package/lib/commonjs/ui/components/LanguageBridge.js +37 -34
  10. package/lib/commonjs/ui/components/LanguageBridge.js.map +1 -1
  11. package/lib/commonjs/ui/components/OxyProvider.js +19 -26
  12. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  13. package/lib/commonjs/ui/components/signIn/OxySignUpPanel.js +8 -2
  14. package/lib/commonjs/ui/components/signIn/OxySignUpPanel.js.map +1 -1
  15. package/lib/commonjs/ui/context/OxyContext.js +23 -2
  16. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  17. package/lib/commonjs/ui/hooks/useEntryLink.js +97 -0
  18. package/lib/commonjs/ui/hooks/useEntryLink.js.map +1 -0
  19. package/lib/commonjs/ui/hooks/useLanguageManagement.js +45 -14
  20. package/lib/commonjs/ui/hooks/useLanguageManagement.js.map +1 -1
  21. package/lib/commonjs/ui/utils/entryLink.js +54 -0
  22. package/lib/commonjs/ui/utils/entryLink.js.map +1 -0
  23. package/lib/commonjs/ui/utils/nativeAssetFiles.js +11 -0
  24. package/lib/commonjs/ui/utils/nativeAssetFiles.js.map +1 -0
  25. package/lib/commonjs/ui/utils/nativeAssetFiles.macos.js +11 -0
  26. package/lib/commonjs/ui/utils/nativeAssetFiles.macos.js.map +1 -0
  27. package/lib/commonjs/ui/utils/nativeAssetFiles.native.js +202 -0
  28. package/lib/commonjs/ui/utils/nativeAssetFiles.native.js.map +1 -0
  29. package/lib/module/ui/components/AccountLanguageBoundary.js +32 -0
  30. package/lib/module/ui/components/AccountLanguageBoundary.js.map +1 -0
  31. package/lib/module/ui/components/LanguageBridge.js +38 -35
  32. package/lib/module/ui/components/LanguageBridge.js.map +1 -1
  33. package/lib/module/ui/components/OxyProvider.js +18 -27
  34. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  35. package/lib/module/ui/components/signIn/OxySignUpPanel.js +8 -2
  36. package/lib/module/ui/components/signIn/OxySignUpPanel.js.map +1 -1
  37. package/lib/module/ui/context/OxyContext.js +24 -3
  38. package/lib/module/ui/context/OxyContext.js.map +1 -1
  39. package/lib/module/ui/hooks/useEntryLink.js +94 -0
  40. package/lib/module/ui/hooks/useEntryLink.js.map +1 -0
  41. package/lib/module/ui/hooks/useLanguageManagement.js +45 -14
  42. package/lib/module/ui/hooks/useLanguageManagement.js.map +1 -1
  43. package/lib/module/ui/utils/entryLink.js +49 -0
  44. package/lib/module/ui/utils/entryLink.js.map +1 -0
  45. package/lib/module/ui/utils/nativeAssetFiles.js +7 -0
  46. package/lib/module/ui/utils/nativeAssetFiles.js.map +1 -0
  47. package/lib/module/ui/utils/nativeAssetFiles.macos.js +7 -0
  48. package/lib/module/ui/utils/nativeAssetFiles.macos.js.map +1 -0
  49. package/lib/module/ui/utils/nativeAssetFiles.native.js +197 -0
  50. package/lib/module/ui/utils/nativeAssetFiles.native.js.map +1 -0
  51. package/lib/typescript/commonjs/ui/components/AccountLanguageBoundary.d.ts +8 -0
  52. package/lib/typescript/commonjs/ui/components/AccountLanguageBoundary.d.ts.map +1 -0
  53. package/lib/typescript/commonjs/ui/components/LanguageBridge.d.ts +7 -17
  54. package/lib/typescript/commonjs/ui/components/LanguageBridge.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/ui/components/signIn/OxySignUpPanel.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +5 -0
  59. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/ui/hooks/useEntryLink.d.ts +15 -0
  61. package/lib/typescript/commonjs/ui/hooks/useEntryLink.d.ts.map +1 -0
  62. package/lib/typescript/commonjs/ui/hooks/useLanguageManagement.d.ts +5 -1
  63. package/lib/typescript/commonjs/ui/hooks/useLanguageManagement.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/ui/types/navigation.d.ts +4 -0
  65. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ui/utils/entryLink.d.ts +8 -0
  67. package/lib/typescript/commonjs/ui/utils/entryLink.d.ts.map +1 -0
  68. package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.d.ts +4 -0
  69. package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.d.ts.map +1 -0
  70. package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.macos.d.ts +4 -0
  71. package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.macos.d.ts.map +1 -0
  72. package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.native.d.ts +4 -0
  73. package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.native.d.ts.map +1 -0
  74. package/lib/typescript/module/ui/components/AccountLanguageBoundary.d.ts +8 -0
  75. package/lib/typescript/module/ui/components/AccountLanguageBoundary.d.ts.map +1 -0
  76. package/lib/typescript/module/ui/components/LanguageBridge.d.ts +7 -17
  77. package/lib/typescript/module/ui/components/LanguageBridge.d.ts.map +1 -1
  78. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  79. package/lib/typescript/module/ui/components/signIn/OxySignUpPanel.d.ts.map +1 -1
  80. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  81. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +5 -0
  82. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  83. package/lib/typescript/module/ui/hooks/useEntryLink.d.ts +15 -0
  84. package/lib/typescript/module/ui/hooks/useEntryLink.d.ts.map +1 -0
  85. package/lib/typescript/module/ui/hooks/useLanguageManagement.d.ts +5 -1
  86. package/lib/typescript/module/ui/hooks/useLanguageManagement.d.ts.map +1 -1
  87. package/lib/typescript/module/ui/types/navigation.d.ts +4 -0
  88. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  89. package/lib/typescript/module/ui/utils/entryLink.d.ts +8 -0
  90. package/lib/typescript/module/ui/utils/entryLink.d.ts.map +1 -0
  91. package/lib/typescript/module/ui/utils/nativeAssetFiles.d.ts +4 -0
  92. package/lib/typescript/module/ui/utils/nativeAssetFiles.d.ts.map +1 -0
  93. package/lib/typescript/module/ui/utils/nativeAssetFiles.macos.d.ts +4 -0
  94. package/lib/typescript/module/ui/utils/nativeAssetFiles.macos.d.ts.map +1 -0
  95. package/lib/typescript/module/ui/utils/nativeAssetFiles.native.d.ts +4 -0
  96. package/lib/typescript/module/ui/utils/nativeAssetFiles.native.d.ts.map +1 -0
  97. package/package.json +6 -5
  98. package/src/ui/components/AccountLanguageBoundary.tsx +35 -0
  99. package/src/ui/components/LanguageBridge.tsx +43 -39
  100. package/src/ui/components/OxyProvider.tsx +18 -24
  101. package/src/ui/components/__tests__/AccountLanguageBoundary.test.tsx +87 -0
  102. package/src/ui/components/__tests__/LanguageBridge.test.tsx +80 -60
  103. package/src/ui/components/signIn/OxySignUpPanel.tsx +9 -1
  104. package/src/ui/context/OxyContext.tsx +34 -2
  105. package/src/ui/context/oxyContextTypes.ts +5 -0
  106. package/src/ui/hooks/useEntryLink.ts +103 -0
  107. package/src/ui/hooks/useLanguageManagement.ts +53 -18
  108. package/src/ui/types/navigation.ts +4 -0
  109. package/src/ui/utils/__tests__/entryLink.test.ts +30 -0
  110. package/src/ui/utils/__tests__/nativeAssetFiles.test.ts +270 -0
  111. package/src/ui/utils/entryLink.ts +61 -0
  112. package/src/ui/utils/nativeAssetFiles.macos.ts +6 -0
  113. package/src/ui/utils/nativeAssetFiles.native.ts +212 -0
  114. package/src/ui/utils/nativeAssetFiles.ts +6 -0
@@ -1,91 +1,111 @@
1
1
  import React from 'react';
2
- import { render } from '@testing-library/react';
2
+ import { act, render, waitFor } from '@testing-library/react';
3
3
  import { LanguageBridge } from '../LanguageBridge';
4
4
 
5
- /**
6
- * `OxyProvider`'s language config prop, realized: the one place Oxy's
7
- * resolved language reaches a host app's own i18n library. `useOxy` is mocked here —
8
- * it is the SDK's own well-tested context — so these tests exercise exactly
9
- * what this component adds: coercion to the caller's catalog, and calling
10
- * back only when the RESOLVED locale actually changes.
11
- */
12
-
13
5
  let mockCurrentLanguage = 'en-US';
6
+ let mockLanguageReady = true;
7
+ let mockAuthResolved = true;
14
8
  jest.mock('../../context/oxyRuntimeContext', () => ({
15
- useOxy: () => ({ currentLanguage: mockCurrentLanguage }),
9
+ useOxy: () => ({
10
+ currentLanguage: mockCurrentLanguage,
11
+ isLanguageReady: mockLanguageReady,
12
+ isAuthResolved: mockAuthResolved,
13
+ }),
16
14
  }));
17
-
18
15
  const HOST_CATALOG = ['en-US', 'es-ES', 'fr-FR'] as const;
19
-
20
16
  beforeEach(() => {
21
17
  mockCurrentLanguage = 'en-US';
18
+ mockLanguageReady = true;
19
+ mockAuthResolved = true;
22
20
  });
23
21
 
24
- it('resolves and reports the account language on mount', () => {
25
- mockCurrentLanguage = 'es-ES';
22
+ it.each([
23
+ ['es-ES', 'es-ES'],
24
+ ['es-MX', 'es-ES'],
25
+ ['ja-JP', 'en-US'],
26
+ ])('resolves %s to the host catalog %s', async (locale, expected) => {
27
+ mockCurrentLanguage = locale;
26
28
  const onChange = jest.fn();
27
29
  render(
28
- <LanguageBridge supportedLocales={HOST_CATALOG} fallbackLocale="en-US" onChange={onChange} />,
30
+ <LanguageBridge config={{ supportedLocales: HOST_CATALOG, fallbackLocale: 'en-US', onChange }}>
31
+ <span>App</span>
32
+ </LanguageBridge>,
29
33
  );
30
-
31
- expect(onChange).toHaveBeenCalledWith('es-ES');
34
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith(expected));
32
35
  });
33
36
 
34
- it('coerces a locale the host never shipped a catalog for, by base language', () => {
35
- mockCurrentLanguage = 'es-MX';
36
- const onChange = jest.fn();
37
- render(
38
- <LanguageBridge supportedLocales={HOST_CATALOG} fallbackLocale="en-US" onChange={onChange} />,
37
+ it('does not paint any app text before saved preferences, account restoration and catalog loading finish', async () => {
38
+ mockLanguageReady = false;
39
+ mockAuthResolved = false;
40
+ let finishCatalog: (() => void) | undefined;
41
+ const onChange = jest.fn(
42
+ () =>
43
+ new Promise<void>((resolve) => {
44
+ finishCatalog = resolve;
45
+ }),
39
46
  );
40
-
41
- expect(onChange).toHaveBeenCalledWith('es-ES');
42
- });
43
-
44
- it('falls back to the caller default when nothing matches, even by base', () => {
45
- mockCurrentLanguage = 'ja-JP';
46
- const onChange = jest.fn();
47
- render(
48
- <LanguageBridge supportedLocales={HOST_CATALOG} fallbackLocale="en-US" onChange={onChange} />,
49
- );
50
-
51
- expect(onChange).toHaveBeenCalledWith('en-US');
47
+ const config = { supportedLocales: HOST_CATALOG, fallbackLocale: 'en-US', onChange };
48
+ const children = <span>Translated app</span>;
49
+ const screen = render(<LanguageBridge config={config}>{children}</LanguageBridge>);
50
+ expect(screen.queryByText('Translated app')).toBeNull();
51
+ expect(onChange).not.toHaveBeenCalled();
52
+ mockLanguageReady = true;
53
+ mockCurrentLanguage = 'es-ES';
54
+ screen.rerender(<LanguageBridge config={config}>{children}</LanguageBridge>);
55
+ expect(onChange).not.toHaveBeenCalled();
56
+ mockAuthResolved = true;
57
+ screen.rerender(<LanguageBridge config={config}>{children}</LanguageBridge>);
58
+ await waitFor(() => expect(onChange).toHaveBeenCalledWith('es-ES'));
59
+ expect(screen.queryByText('Translated app')).toBeNull();
60
+ await act(async () => finishCatalog?.());
61
+ expect(screen.getByText('Translated app')).toBeTruthy();
52
62
  });
53
63
 
54
- it('calls back again only when the RESOLVED locale changes', () => {
64
+ it('keeps route state mounted across a language change and ignores callback identity changes', async () => {
65
+ let mounts = 0;
66
+ function Page() {
67
+ React.useEffect(() => {
68
+ mounts += 1;
69
+ }, []);
70
+ return <span>App</span>;
71
+ }
55
72
  const onChange = jest.fn();
56
- const { rerender } = render(
57
- <LanguageBridge supportedLocales={HOST_CATALOG} fallbackLocale="en-US" onChange={onChange} />,
73
+ const config = { supportedLocales: HOST_CATALOG, fallbackLocale: 'en-US', onChange };
74
+ const screen = render(
75
+ <LanguageBridge config={config}>
76
+ <Page />
77
+ </LanguageBridge>,
58
78
  );
59
- expect(onChange).toHaveBeenCalledTimes(1);
60
-
61
- // Same resolved locale, brand-new callback identity (the common case for an
62
- // inline arrow function) — must not re-fire.
63
- rerender(
64
- <LanguageBridge supportedLocales={HOST_CATALOG} fallbackLocale="en-US" onChange={() => {}} />,
79
+ await waitFor(() => expect(mounts).toBe(1));
80
+ screen.rerender(
81
+ <LanguageBridge config={{ ...config, onChange: jest.fn() }}>
82
+ <Page />
83
+ </LanguageBridge>,
65
84
  );
66
85
  expect(onChange).toHaveBeenCalledTimes(1);
67
-
68
86
  mockCurrentLanguage = 'fr-FR';
69
- rerender(
70
- <LanguageBridge supportedLocales={HOST_CATALOG} fallbackLocale="en-US" onChange={onChange} />,
87
+ screen.rerender(
88
+ <LanguageBridge config={config}>
89
+ <Page />
90
+ </LanguageBridge>,
71
91
  );
72
- expect(onChange).toHaveBeenCalledTimes(2);
73
- expect(onChange).toHaveBeenLastCalledWith('fr-FR');
92
+ await waitFor(() => expect(onChange).toHaveBeenLastCalledWith('fr-FR'));
93
+ expect(mounts).toBe(1);
74
94
  });
75
95
 
76
- it('reports a rejected onChange to onError instead of throwing', async () => {
77
- const failure = new Error('catalog failed to load');
78
- const onChange = jest.fn().mockRejectedValue(failure);
96
+ it('reports a failed catalog and initializes the fallback before showing children', async () => {
97
+ mockCurrentLanguage = 'es-ES';
98
+ const failure = new Error('catalog unavailable');
99
+ const onChange = jest.fn().mockRejectedValueOnce(failure).mockResolvedValueOnce(undefined);
79
100
  const onError = jest.fn();
80
- render(
101
+ const screen = render(
81
102
  <LanguageBridge
82
- supportedLocales={HOST_CATALOG}
83
- fallbackLocale="en-US"
84
- onChange={onChange}
85
- onError={onError}
86
- />,
103
+ config={{ supportedLocales: HOST_CATALOG, fallbackLocale: 'en-US', onChange, onError }}
104
+ >
105
+ <span>App</span>
106
+ </LanguageBridge>,
87
107
  );
88
-
89
- await new Promise((resolve) => setTimeout(resolve, 0));
90
- expect(onError).toHaveBeenCalledWith(failure, 'en-US');
108
+ await waitFor(() => expect(screen.getByText('App')).toBeTruthy());
109
+ expect(onError).toHaveBeenCalledWith(failure, 'es-ES');
110
+ expect(onChange.mock.calls.map(([locale]) => locale)).toEqual(['es-ES', 'en-US']);
91
111
  });
@@ -67,7 +67,13 @@ export const OxySignUpPanel: React.FC<OxySignUpPanelProps> = ({
67
67
  host = 'page',
68
68
  }) => {
69
69
  const { t } = useI18n();
70
- const { oxyServices, handleWebSession, accountDialogController } = useOxy();
70
+ const {
71
+ oxyServices,
72
+ handleWebSession,
73
+ accountDialogController,
74
+ currentLanguage,
75
+ campaignAttribution,
76
+ } = useOxy();
71
77
  const snapshot = useAccountDialogSnapshot(accountDialogController);
72
78
  const flowOwner = host === 'dialog' ? accountDialogController : null;
73
79
  const [saved] = useState(() => readSignInFlow<SavedSignUpFlow>(flowOwner, SIGN_UP_FLOW_KEY));
@@ -139,6 +145,8 @@ export const OxySignUpPanel: React.FC<OxySignUpPanelProps> = ({
139
145
  username: username.trim(),
140
146
  email,
141
147
  emailTicket: ticket,
148
+ languages: [currentLanguage],
149
+ ...(campaignAttribution ? { campaignAttribution } : {}),
142
150
  });
143
151
  await handleWebSession(session);
144
152
  writeSignInFlow(flowOwner, SIGN_UP_FLOW_KEY, undefined);
@@ -1,3 +1,4 @@
1
+ import { installNativeAssetFiles } from '../utils/nativeAssetFiles';
1
2
  import {
2
3
  startNativeOAuthSignIn,
3
4
  type StartNativeOAuthSignInOptions,
@@ -9,7 +10,15 @@ import {
9
10
  setIsolatedOAuthSession,
10
11
  } from './isolatedOAuthSession';
11
12
  import type React from 'react';
12
- import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
13
+ import {
14
+ useCallback,
15
+ useEffect,
16
+ useLayoutEffect,
17
+ useMemo,
18
+ useRef,
19
+ useState,
20
+ useSyncExternalStore,
21
+ } from 'react';
13
22
  import { AppState, Linking, Platform } from 'react-native';
14
23
  import { OxyServices } from '@oxy.so/core';
15
24
  import type { LoginSessionResult } from '@oxy.so/contracts';
@@ -62,6 +71,7 @@ import {
62
71
  import { createTokenLossRecovery, TOKEN_RECOVERY_BACKOFF_MS } from '../session/tokenLossRecovery';
63
72
  import { createCachedBootStore, type CachedBootStore } from '../session/cachedBoot';
64
73
  import { bindAuthStoreToRuntime } from '../stores/authStore';
74
+ import { useEntryLink } from '../hooks/useEntryLink';
65
75
  import { useLanguageManagement } from '../hooks/useLanguageManagement';
66
76
  import { useSessionManagement } from '../hooks/useSessionManagement';
67
77
  import { useAuthOperations, clearPersistedAuthSafe } from './hooks/useAuthOperations';
@@ -153,6 +163,8 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
153
163
  sessionMode = 'account',
154
164
  webAuthMode = 'popup',
155
165
  backgroundSession = false,
166
+ legacyLanguageKeys,
167
+ captureCampaignAttribution = false,
156
168
  platformStorage,
157
169
  accountQueries,
158
170
  onAuthStateChange,
@@ -179,6 +191,9 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
179
191
  }
180
192
  }
181
193
  const oxyServices = oxyServicesRef.current;
194
+ // Child passive effects may upload immediately on mount. Configure transport
195
+ // during commit, before those effects, without mutating the client in render.
196
+ useLayoutEffect(() => installNativeAssetFiles(oxyServices), [oxyServices]);
182
197
 
183
198
  // The device-first persisted auth-state store (per-origin device credential on
184
199
  // web; SecureStore session blob on native). Built ONCE per provider mount.
@@ -415,8 +430,10 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
415
430
  .catch((err) => {
416
431
  // Nothing persisted can be restored; the account cache still has to
417
432
  // become ready, or RequireOxyAuth would wait forever.
418
- accountQueryPersistence.setStorage(createMemoryStorage());
433
+ const memoryStorage = createMemoryStorage();
434
+ accountQueryPersistence.setStorage(memoryStorage);
419
435
  if (mounted) {
436
+ setStorage(memoryStorage);
420
437
  logger('Failed to initialize storage', err);
421
438
  onError?.({
422
439
  message: 'Failed to initialize storage',
@@ -430,8 +447,15 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
430
447
  };
431
448
  }, [accountQueryPersistence, logger, onError, platformStorage, runtime, storageReady]);
432
449
 
450
+ const entryLink = useEntryLink({
451
+ storage,
452
+ storageKey: `${storageKeyPrefix}_campaign_attribution`,
453
+ captureCampaign: captureCampaignAttribution,
454
+ onError: logger,
455
+ });
433
456
  const {
434
457
  currentLanguage,
458
+ isLanguageReady,
435
459
  languages: currentLanguages,
436
460
  metadata: currentLanguageMetadata,
437
461
  languageName: currentLanguageName,
@@ -441,6 +465,9 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
441
465
  } = useLanguageManagement({
442
466
  storage,
443
467
  languageKey: storageKeys.language,
468
+ entryUrl: entryLink.url,
469
+ entryLinkReady: entryLink.ready,
470
+ legacyLanguageKeys,
444
471
  user,
445
472
  onError,
446
473
  logger,
@@ -1751,6 +1778,8 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
1751
1778
  webAuthMode,
1752
1779
  error,
1753
1780
  currentLanguage,
1781
+ isLanguageReady: isLanguageReady && entryLink.campaignReady,
1782
+ campaignAttribution: entryLink.campaignAttribution,
1754
1783
  currentLanguages,
1755
1784
  currentLanguageMetadata,
1756
1785
  currentLanguageName,
@@ -1808,6 +1837,9 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
1808
1837
  webAuthMode,
1809
1838
  error,
1810
1839
  currentLanguage,
1840
+ isLanguageReady,
1841
+ entryLink.campaignReady,
1842
+ entryLink.campaignAttribution,
1811
1843
  currentLanguages,
1812
1844
  currentLanguageMetadata,
1813
1845
  currentLanguageName,
@@ -1,3 +1,4 @@
1
+ import type { CampaignAttribution } from '@oxy.so/contracts';
1
2
  import type {
2
3
  StartNativeOAuthSignInOptions,
3
4
  NativeOAuthSignInResult,
@@ -85,6 +86,8 @@ export interface OxyContextState {
85
86
  error: string | null;
86
87
  /** Active UI locale (`language-REGION`): the account's primary locale when signed in, else the guest/device locale. */
87
88
  currentLanguage: string;
89
+ isLanguageReady: boolean;
90
+ campaignAttribution?: CampaignAttribution;
88
91
  /** Ordered account locales (primary first) when signed in, or the single guest override when signed out. */
89
92
  currentLanguages: string[];
90
93
  currentLanguageMetadata: ReturnType<typeof useLanguageManagement>['metadata'];
@@ -180,6 +183,8 @@ export interface OxyContextState {
180
183
  }
181
184
 
182
185
  export interface OxyRuntimeProviderProps {
186
+ legacyLanguageKeys?: readonly string[];
187
+ captureCampaignAttribution?: boolean;
183
188
  children: ReactNode;
184
189
  oxyServices?: OxyServices;
185
190
  baseURL?: string;
@@ -0,0 +1,103 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { Linking, Platform } from 'react-native';
3
+ import type { CampaignAttribution } from '@oxy.so/contracts';
4
+ import type { StorageInterface } from '../utils/storageHelpers';
5
+ import { readCampaignAttribution, readEntryLink } from '../utils/entryLink';
6
+
7
+ /** One receiver for initial and warm native links and web entry parameters. */
8
+ export function useEntryLink({
9
+ storage,
10
+ storageKey,
11
+ captureCampaign,
12
+ onError,
13
+ }: {
14
+ storage: StorageInterface | null;
15
+ storageKey: string;
16
+ captureCampaign: boolean;
17
+ onError: (message: string, error?: unknown) => void;
18
+ }) {
19
+ const [entry, setEntry] = useState<{
20
+ url: string | null | undefined;
21
+ ready: boolean;
22
+ firstCampaign?: CampaignAttribution;
23
+ }>({ url: undefined, ready: false });
24
+ const [campaignAttribution, setCampaignAttribution] = useState<CampaignAttribution>();
25
+ const [campaignReady, setCampaignReady] = useState(false);
26
+ const captures = useRef(Promise.resolve());
27
+
28
+ useEffect(() => {
29
+ let active = true;
30
+ const subscription = Linking.addEventListener('url', ({ url }) => {
31
+ setEntry((current) => ({
32
+ ...current,
33
+ url,
34
+ firstCampaign: current.firstCampaign ?? readEntryLink(url).campaignAttribution,
35
+ }));
36
+ });
37
+ const initialUrl =
38
+ Platform.OS === 'web' && typeof window !== 'undefined'
39
+ ? Promise.resolve(window.location.href)
40
+ : Linking.getInitialURL();
41
+ initialUrl
42
+ .then((initial) => {
43
+ if (active)
44
+ setEntry((current) => ({
45
+ url: current.url === undefined ? initial : current.url,
46
+ ready: true,
47
+ firstCampaign:
48
+ readEntryLink(initial ?? null).campaignAttribution ?? current.firstCampaign,
49
+ }));
50
+ })
51
+ .catch((error: unknown) => {
52
+ onError('Failed to read the initial entry link', error);
53
+ if (active) setEntry((current) => ({ ...current, url: current.url ?? null, ready: true }));
54
+ });
55
+ return () => {
56
+ active = false;
57
+ subscription.remove();
58
+ };
59
+ }, [onError]);
60
+
61
+ useEffect(() => {
62
+ if (!storage || !entry.ready) return;
63
+ let active = true;
64
+ async function capture() {
65
+ if (!captureCampaign) {
66
+ if (active) setCampaignReady(true);
67
+ return;
68
+ }
69
+ let stored: CampaignAttribution | undefined;
70
+ const raw = await storage?.getItem(storageKey);
71
+ if (raw) {
72
+ try {
73
+ stored = readCampaignAttribution(JSON.parse(raw));
74
+ } catch {
75
+ onError('Stored campaign attribution is malformed');
76
+ }
77
+ }
78
+ const selected = stored ?? entry.firstCampaign;
79
+ if (selected && !stored) await storage?.setItem(storageKey, JSON.stringify(selected));
80
+ if (active) {
81
+ setCampaignAttribution(selected);
82
+ setCampaignReady(true);
83
+ }
84
+ }
85
+ captures.current = captures.current.then(capture).catch((error: unknown) => {
86
+ onError('Failed to persist campaign attribution', error);
87
+ if (active) {
88
+ setCampaignAttribution(entry.firstCampaign);
89
+ setCampaignReady(true);
90
+ }
91
+ });
92
+ return () => {
93
+ active = false;
94
+ };
95
+ }, [captureCampaign, entry.ready, entry.firstCampaign, onError, storage, storageKey]);
96
+
97
+ return {
98
+ url: entry.url,
99
+ ready: entry.ready,
100
+ campaignAttribution,
101
+ campaignReady: !captureCampaign || campaignReady,
102
+ };
103
+ }
@@ -12,11 +12,15 @@ import {
12
12
  normalizeLocale,
13
13
  } from '@oxy.so/core';
14
14
  import type { StorageInterface } from '../utils/storageHelpers';
15
+ import { readEntryLink } from '../utils/entryLink';
15
16
  import { extractErrorMessage } from '../utils/errorHandlers';
16
17
 
17
18
  export interface UseLanguageManagementOptions {
18
19
  storage: StorageInterface | null;
19
20
  languageKey: string;
21
+ entryUrl?: string | null;
22
+ entryLinkReady?: boolean;
23
+ legacyLanguageKeys?: readonly string[];
20
24
  /**
21
25
  * The active account, when signed in. When present, the account's primary
22
26
  * locale (`languages[0]`) is authoritative for the app's UI locale; when
@@ -34,6 +38,7 @@ export interface UseLanguageManagementResult {
34
38
  * locally-chosen guest override, otherwise the device locale.
35
39
  */
36
40
  currentLanguage: string;
41
+ isLanguageReady: boolean;
37
42
  /**
38
43
  * The ordered account locales (primary first) when signed in, or the locally
39
44
  * persisted selection when signed out. Empty only before the guest override loads
@@ -145,6 +150,9 @@ function resolveDeviceLocale(): string {
145
150
  export const useLanguageManagement = ({
146
151
  storage,
147
152
  languageKey,
153
+ entryUrl = null,
154
+ entryLinkReady = true,
155
+ legacyLanguageKeys,
148
156
  user,
149
157
  onError,
150
158
  logger,
@@ -156,6 +164,8 @@ export const useLanguageManagement = ({
156
164
  // Until it loads, the UI uses the device locale.
157
165
  const [guestLanguages, setGuestLanguages] = useState<string[]>([]);
158
166
  const selectionVersion = useRef(0);
167
+ const [isLanguageReady, setLanguageReady] = useState(false);
168
+ const legacyKeysSignature = JSON.stringify(legacyLanguageKeys ?? []);
159
169
 
160
170
  const loadLanguageFromStorage = useCallback(async (): Promise<void> => {
161
171
  if (!storage) {
@@ -164,24 +174,45 @@ export const useLanguageManagement = ({
164
174
 
165
175
  const version = selectionVersion.current;
166
176
  try {
167
- const savedLanguageRaw = await storage.getItem(languageKey);
168
- // Migrate the former single locale without discarding existing preferences.
169
- let stored: unknown = savedLanguageRaw;
170
- if (savedLanguageRaw?.startsWith('[')) {
171
- stored = JSON.parse(savedLanguageRaw);
177
+ function parseStored(raw: string | null): string[] {
178
+ let stored: unknown = raw;
179
+ if (raw?.startsWith('[') || raw?.startsWith('"')) {
180
+ try {
181
+ stored = JSON.parse(raw);
182
+ } catch {
183
+ return [];
184
+ }
185
+ }
186
+ const entries = Array.isArray(stored) ? stored : [stored];
187
+ return [
188
+ ...new Set(
189
+ entries.flatMap((entry) => {
190
+ const locale = typeof entry === 'string' ? normalizeLocale(entry) : undefined;
191
+ return locale ? [locale] : [];
192
+ }),
193
+ ),
194
+ ];
172
195
  }
173
- const entries = Array.isArray(stored) ? stored : [stored];
174
- const normalized = [
175
- ...new Set(
176
- entries.flatMap((entry) => {
177
- const locale = typeof entry === 'string' ? normalizeLocale(entry) : undefined;
178
- return locale ? [locale] : [];
179
- }),
180
- ),
181
- ];
182
- if (normalized.length && version === selectionVersion.current) {
183
- setGuestLanguages(normalized);
196
+ let normalized = parseStored(await storage.getItem(languageKey));
197
+ let migratedKey: string | undefined;
198
+ const legacyKeys: string[] = JSON.parse(legacyKeysSignature);
199
+ if (!normalized.length) {
200
+ for (const key of legacyKeys) {
201
+ normalized = parseStored(await storage.getItem(key));
202
+ if (normalized.length) {
203
+ migratedKey = key;
204
+ break;
205
+ }
206
+ }
207
+ }
208
+ const linkedLanguage = readEntryLink(entryUrl).language;
209
+ const selected = linkedLanguage ? [linkedLanguage] : normalized;
210
+ if (version !== selectionVersion.current) return;
211
+ if (linkedLanguage || migratedKey) {
212
+ await storage.setItem(languageKey, JSON.stringify(selected));
213
+ if (migratedKey) await storage.removeItem(migratedKey);
184
214
  }
215
+ if (selected.length && version === selectionVersion.current) setGuestLanguages(selected);
185
216
  } catch (error) {
186
217
  const message = extractErrorMessage(error, 'Failed to load language preference');
187
218
  onError?.({
@@ -194,16 +225,19 @@ export const useLanguageManagement = ({
194
225
  } else if (__DEV__) {
195
226
  console.warn('Failed to load language preference:', error);
196
227
  }
228
+ } finally {
229
+ setLanguageReady(true);
197
230
  }
198
- }, [languageKey, logger, onError, storage]);
231
+ }, [entryUrl, languageKey, legacyKeysSignature, logger, onError, storage]);
199
232
 
200
233
  useEffect(() => {
234
+ if (!entryLinkReady) return;
201
235
  loadLanguageFromStorage().catch((error) => {
202
236
  if (logger) {
203
237
  logger('Unexpected error loading language', error);
204
238
  }
205
239
  });
206
- }, [loadLanguageFromStorage, logger]);
240
+ }, [entryLinkReady, loadLanguageFromStorage, logger]);
207
241
 
208
242
  const setLanguages = useCallback(
209
243
  async (locales: readonly string[]): Promise<void> => {
@@ -268,6 +302,7 @@ export const useLanguageManagement = ({
268
302
 
269
303
  return {
270
304
  currentLanguage,
305
+ isLanguageReady,
271
306
  languages,
272
307
  metadata,
273
308
  languageName,
@@ -182,9 +182,13 @@ export interface OxyProviderProps {
182
182
  * manages it independently of the account/device locale.
183
183
  */
184
184
  language?: OxyLanguageConfig;
185
+ /** Persist bounded first-touch link labels and attach them only to a new account. */
186
+ campaignAttribution?: boolean;
185
187
  }
186
188
 
187
189
  export interface OxyLanguageConfig {
190
+ /** Previous app-owned storage keys, migrated once without replacing a current SDK choice. */
191
+ legacyStorageKeys?: readonly string[];
188
192
  /** The exact locales this app ships a translation catalog for. */
189
193
  supportedLocales: readonly string[];
190
194
  /**
@@ -0,0 +1,30 @@
1
+ import { readEntryLink, readCampaignAttribution } from '../entryLink';
2
+
3
+ it.each([
4
+ ['https://mention.earth/?lang=es', 'es-ES'],
5
+ ['mention://compose?lang=fr-BE', 'fr-FR'],
6
+ ['https://mention.earth/?lang=zz', undefined],
7
+ ['https://mention.earth/?lang=en&lang=es', undefined],
8
+ ['not a url', undefined],
9
+ ])('resolves supported entry locale %s', (url, expected) => {
10
+ expect(readEntryLink(url).language).toBe(expected);
11
+ });
12
+
13
+ it('captures only campaign fields without retaining the URL, a referrer or unrelated private query data', () => {
14
+ expect(
15
+ readEntryLink(
16
+ 'https://mention.earth/write?utm_source=meta&utm_campaign=Spain&fbclid=click&email=private@example.test',
17
+ ),
18
+ ).toEqual({
19
+ language: undefined,
20
+ campaignAttribution: { utmSource: 'meta', utmCampaign: 'Spain', fbclid: 'click' },
21
+ });
22
+ });
23
+
24
+ it('drops empty, duplicate, overlong and control-character campaign values', () => {
25
+ const url = `mention://?utm_source=one&utm_source=two&utm_medium=&utm_campaign=${'x'.repeat(257)}&utm_content=bad%00value`;
26
+ expect(readEntryLink(url).campaignAttribution).toBeUndefined();
27
+ expect(
28
+ readCampaignAttribution({ fbclid: 'x'.repeat(1025), utmSource: ' meta ', private: 'secret' }),
29
+ ).toEqual({ utmSource: 'meta' });
30
+ });