@oxy.so/services 13.6.0 → 13.7.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 (80) hide show
  1. package/lib/commonjs/ui/components/AccountLanguageBoundary.js +35 -0
  2. package/lib/commonjs/ui/components/AccountLanguageBoundary.js.map +1 -0
  3. package/lib/commonjs/ui/components/LanguageBridge.js +37 -34
  4. package/lib/commonjs/ui/components/LanguageBridge.js.map +1 -1
  5. package/lib/commonjs/ui/components/OxyProvider.js +19 -26
  6. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  7. package/lib/commonjs/ui/components/signIn/OxySignUpPanel.js +8 -2
  8. package/lib/commonjs/ui/components/signIn/OxySignUpPanel.js.map +1 -1
  9. package/lib/commonjs/ui/context/OxyContext.js +19 -2
  10. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  11. package/lib/commonjs/ui/hooks/useEntryLink.js +97 -0
  12. package/lib/commonjs/ui/hooks/useEntryLink.js.map +1 -0
  13. package/lib/commonjs/ui/hooks/useLanguageManagement.js +45 -14
  14. package/lib/commonjs/ui/hooks/useLanguageManagement.js.map +1 -1
  15. package/lib/commonjs/ui/utils/entryLink.js +54 -0
  16. package/lib/commonjs/ui/utils/entryLink.js.map +1 -0
  17. package/lib/module/ui/components/AccountLanguageBoundary.js +32 -0
  18. package/lib/module/ui/components/AccountLanguageBoundary.js.map +1 -0
  19. package/lib/module/ui/components/LanguageBridge.js +38 -35
  20. package/lib/module/ui/components/LanguageBridge.js.map +1 -1
  21. package/lib/module/ui/components/OxyProvider.js +18 -27
  22. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  23. package/lib/module/ui/components/signIn/OxySignUpPanel.js +8 -2
  24. package/lib/module/ui/components/signIn/OxySignUpPanel.js.map +1 -1
  25. package/lib/module/ui/context/OxyContext.js +19 -2
  26. package/lib/module/ui/context/OxyContext.js.map +1 -1
  27. package/lib/module/ui/hooks/useEntryLink.js +94 -0
  28. package/lib/module/ui/hooks/useEntryLink.js.map +1 -0
  29. package/lib/module/ui/hooks/useLanguageManagement.js +45 -14
  30. package/lib/module/ui/hooks/useLanguageManagement.js.map +1 -1
  31. package/lib/module/ui/utils/entryLink.js +49 -0
  32. package/lib/module/ui/utils/entryLink.js.map +1 -0
  33. package/lib/typescript/commonjs/ui/components/AccountLanguageBoundary.d.ts +8 -0
  34. package/lib/typescript/commonjs/ui/components/AccountLanguageBoundary.d.ts.map +1 -0
  35. package/lib/typescript/commonjs/ui/components/LanguageBridge.d.ts +7 -17
  36. package/lib/typescript/commonjs/ui/components/LanguageBridge.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  38. package/lib/typescript/commonjs/ui/components/signIn/OxySignUpPanel.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  40. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +5 -0
  41. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/ui/hooks/useEntryLink.d.ts +15 -0
  43. package/lib/typescript/commonjs/ui/hooks/useEntryLink.d.ts.map +1 -0
  44. package/lib/typescript/commonjs/ui/hooks/useLanguageManagement.d.ts +5 -1
  45. package/lib/typescript/commonjs/ui/hooks/useLanguageManagement.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/ui/types/navigation.d.ts +4 -0
  47. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/ui/utils/entryLink.d.ts +8 -0
  49. package/lib/typescript/commonjs/ui/utils/entryLink.d.ts.map +1 -0
  50. package/lib/typescript/module/ui/components/AccountLanguageBoundary.d.ts +8 -0
  51. package/lib/typescript/module/ui/components/AccountLanguageBoundary.d.ts.map +1 -0
  52. package/lib/typescript/module/ui/components/LanguageBridge.d.ts +7 -17
  53. package/lib/typescript/module/ui/components/LanguageBridge.d.ts.map +1 -1
  54. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  55. package/lib/typescript/module/ui/components/signIn/OxySignUpPanel.d.ts.map +1 -1
  56. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  57. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +5 -0
  58. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  59. package/lib/typescript/module/ui/hooks/useEntryLink.d.ts +15 -0
  60. package/lib/typescript/module/ui/hooks/useEntryLink.d.ts.map +1 -0
  61. package/lib/typescript/module/ui/hooks/useLanguageManagement.d.ts +5 -1
  62. package/lib/typescript/module/ui/hooks/useLanguageManagement.d.ts.map +1 -1
  63. package/lib/typescript/module/ui/types/navigation.d.ts +4 -0
  64. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  65. package/lib/typescript/module/ui/utils/entryLink.d.ts +8 -0
  66. package/lib/typescript/module/ui/utils/entryLink.d.ts.map +1 -0
  67. package/package.json +5 -5
  68. package/src/ui/components/AccountLanguageBoundary.tsx +35 -0
  69. package/src/ui/components/LanguageBridge.tsx +43 -39
  70. package/src/ui/components/OxyProvider.tsx +18 -24
  71. package/src/ui/components/__tests__/AccountLanguageBoundary.test.tsx +87 -0
  72. package/src/ui/components/__tests__/LanguageBridge.test.tsx +80 -60
  73. package/src/ui/components/signIn/OxySignUpPanel.tsx +9 -1
  74. package/src/ui/context/OxyContext.tsx +21 -1
  75. package/src/ui/context/oxyContextTypes.ts +5 -0
  76. package/src/ui/hooks/useEntryLink.ts +103 -0
  77. package/src/ui/hooks/useLanguageManagement.ts +53 -18
  78. package/src/ui/types/navigation.ts +4 -0
  79. package/src/ui/utils/__tests__/entryLink.test.ts +30 -0
  80. package/src/ui/utils/entryLink.ts +61 -0
@@ -20,19 +20,10 @@ import { isNetConnectivityOnline } from '../utils/netConnectivity';
20
20
  import { KeyboardBoundary } from './KeyboardBoundary';
21
21
  import { SafeAreaRoot } from './SafeAreaRoot';
22
22
  import { ProductAnalyticsObserver } from '../analytics/productAnalytics';
23
- import { LanguageBridge } from './LanguageBridge';
23
+ import { AccountLanguageBoundary } from './AccountLanguageBoundary';
24
24
  import { BloomLocaleBridge } from './BloomLocaleBridge';
25
- import { useOxy } from '../context/oxyRuntimeContext';
26
- import { useCurrentUser } from '../hooks/queries/useAccountQueries';
27
25
  import { SupervisionStateObserver } from '../hooks/queries/useSupervision';
28
26
 
29
- /** Hydrate owner preferences after the bearer is ready, including on cold boot. */
30
- function AccountProfileObserver() {
31
- const { canUsePrivateApi } = useOxy();
32
- useCurrentUser({ enabled: canUsePrivateApi });
33
- return null;
34
- }
35
-
36
27
  const bootStyles = StyleSheet.create({
37
28
  providerRoot: {
38
29
  flex: 1,
@@ -114,6 +105,7 @@ const OxyProvider: FC<OxyProviderProps> = ({
114
105
  requireAuth = 'off',
115
106
  backgroundSession = false,
116
107
  language,
108
+ campaignAttribution = false,
117
109
  }) => {
118
110
  const support = useMemo(
119
111
  () => ({ onAskAlia, documentationUrl, aliaAvatar, oxyOneArtwork }),
@@ -279,26 +271,28 @@ const OxyProvider: FC<OxyProviderProps> = ({
279
271
  sessionMode={sessionMode}
280
272
  webAuthMode={webAuthMode}
281
273
  backgroundSession={backgroundSession}
274
+ legacyLanguageKeys={language?.legacyStorageKeys}
275
+ captureCampaignAttribution={campaignAttribution}
282
276
  platformStorage={platformStorage}
283
277
  accountQueries={accountQueries}
284
278
  onAuthStateChange={onAuthStateChange as OxyRuntimeProviderProps['onAuthStateChange']}
285
279
  >
286
- <AccountProfileObserver />
287
280
  <SupervisionStateObserver />
288
281
  {productAnalytics ? <ProductAnalyticsObserver analytics={productAnalytics} /> : null}
289
- {language ? <LanguageBridge {...language} /> : null}
290
- <BloomLocaleBridge>
291
- <OxySupportContext.Provider value={support}>
292
- <SurfaceProvider>
293
- {requireAuth === 'off' ? (
294
- children
295
- ) : (
296
- <RequireOxyAuth prompt={requireAuth}>{children}</RequireOxyAuth>
297
- )}
298
- </SurfaceProvider>
299
- </OxySupportContext.Provider>
300
- <ToastOutlet />
301
- </BloomLocaleBridge>
282
+ <AccountLanguageBoundary language={language}>
283
+ <BloomLocaleBridge>
284
+ <OxySupportContext.Provider value={support}>
285
+ <SurfaceProvider>
286
+ {requireAuth === 'off' ? (
287
+ children
288
+ ) : (
289
+ <RequireOxyAuth prompt={requireAuth}>{children}</RequireOxyAuth>
290
+ )}
291
+ </SurfaceProvider>
292
+ </OxySupportContext.Provider>
293
+ <ToastOutlet />
294
+ </BloomLocaleBridge>
295
+ </AccountLanguageBoundary>
302
296
  </OxyRuntimeProvider>
303
297
  </QueryClientProvider>
304
298
  );
@@ -0,0 +1,87 @@
1
+ import React from 'react';
2
+ import { render, waitFor } from '@testing-library/react';
3
+ import { AccountLanguageBoundary } from '../AccountLanguageBoundary';
4
+
5
+ let mockCurrentLanguage = 'en-US';
6
+ let mockCanUsePrivateApi = true;
7
+ const mockUser = { id: 'account', publicKey: '', username: 'ada', name: {} };
8
+ let mockQuery: {
9
+ data?: typeof mockUser & { languages?: string[] };
10
+ dataUpdatedAt: number;
11
+ isError: boolean;
12
+ fetchStatus?: 'fetching' | 'paused' | 'idle';
13
+ };
14
+ jest.mock('../../context/oxyRuntimeContext', () => ({
15
+ useOxy: () => ({
16
+ canUsePrivateApi: mockCanUsePrivateApi,
17
+ user: mockUser,
18
+ currentLanguage: mockCurrentLanguage,
19
+ isLanguageReady: true,
20
+ isAuthResolved: true,
21
+ }),
22
+ }));
23
+ jest.mock('../../hooks/queries/useAccountQueries', () => ({ useCurrentUser: () => mockQuery }));
24
+
25
+ it('waits for the owner preference query and its runtime projection before painting the account app', async () => {
26
+ mockQuery = { data: mockUser, dataUpdatedAt: 0, isError: false };
27
+ const onChange = jest.fn();
28
+ const config = { supportedLocales: ['en-US', 'es-ES'], fallbackLocale: 'en-US', onChange };
29
+ const screen = render(
30
+ <AccountLanguageBoundary language={config}>
31
+ <span>App</span>
32
+ </AccountLanguageBoundary>,
33
+ );
34
+ expect(screen.queryByText('App')).toBeNull();
35
+ mockQuery = { data: { ...mockUser, languages: ['es-ES'] }, dataUpdatedAt: 123, isError: false };
36
+ screen.rerender(
37
+ <AccountLanguageBoundary language={config}>
38
+ <span>App</span>
39
+ </AccountLanguageBoundary>,
40
+ );
41
+ expect(screen.queryByText('App')).toBeNull();
42
+ expect(onChange).not.toHaveBeenCalled();
43
+ mockCurrentLanguage = 'es-ES';
44
+ screen.rerender(
45
+ <AccountLanguageBoundary language={config}>
46
+ <span>App</span>
47
+ </AccountLanguageBoundary>,
48
+ );
49
+ await waitFor(() => expect(screen.getByText('App')).toBeTruthy());
50
+ expect(onChange.mock.calls).toEqual([['es-ES']]);
51
+ });
52
+
53
+ it.each(['paused', 'disabled', 'error'] as const)(
54
+ 'renders the resolved fallback when the account query is %s, then adopts its preference without remounting',
55
+ async (unavailable) => {
56
+ mockCurrentLanguage = 'en-US';
57
+ mockCanUsePrivateApi = unavailable !== 'disabled';
58
+ mockQuery = {
59
+ data: mockUser,
60
+ dataUpdatedAt: 0,
61
+ isError: unavailable === 'error',
62
+ fetchStatus: unavailable === 'paused' ? 'paused' : 'idle',
63
+ };
64
+ const onChange = jest.fn();
65
+ const config = { supportedLocales: ['en-US', 'es-ES'], fallbackLocale: 'en-US', onChange };
66
+ const view = () => (
67
+ <AccountLanguageBoundary language={config}>
68
+ <input aria-label="Draft" defaultValue="Kept" />
69
+ </AccountLanguageBoundary>
70
+ );
71
+ const screen = render(view());
72
+ await waitFor(() => expect(screen.getByLabelText('Draft')).toBeTruthy());
73
+ const draft = screen.getByLabelText('Draft');
74
+ expect(onChange.mock.calls).toEqual([['en-US']]);
75
+ mockCanUsePrivateApi = true;
76
+ mockQuery = {
77
+ data: { ...mockUser, languages: ['es-ES'] },
78
+ dataUpdatedAt: 123,
79
+ isError: false,
80
+ fetchStatus: 'idle',
81
+ };
82
+ mockCurrentLanguage = 'es-ES';
83
+ screen.rerender(view());
84
+ await waitFor(() => expect(onChange.mock.calls).toEqual([['en-US'], ['es-ES']]));
85
+ expect(screen.getByLabelText('Draft')).toBe(draft);
86
+ },
87
+ );
@@ -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);
@@ -62,6 +62,7 @@ import {
62
62
  import { createTokenLossRecovery, TOKEN_RECOVERY_BACKOFF_MS } from '../session/tokenLossRecovery';
63
63
  import { createCachedBootStore, type CachedBootStore } from '../session/cachedBoot';
64
64
  import { bindAuthStoreToRuntime } from '../stores/authStore';
65
+ import { useEntryLink } from '../hooks/useEntryLink';
65
66
  import { useLanguageManagement } from '../hooks/useLanguageManagement';
66
67
  import { useSessionManagement } from '../hooks/useSessionManagement';
67
68
  import { useAuthOperations, clearPersistedAuthSafe } from './hooks/useAuthOperations';
@@ -153,6 +154,8 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
153
154
  sessionMode = 'account',
154
155
  webAuthMode = 'popup',
155
156
  backgroundSession = false,
157
+ legacyLanguageKeys,
158
+ captureCampaignAttribution = false,
156
159
  platformStorage,
157
160
  accountQueries,
158
161
  onAuthStateChange,
@@ -415,8 +418,10 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
415
418
  .catch((err) => {
416
419
  // Nothing persisted can be restored; the account cache still has to
417
420
  // become ready, or RequireOxyAuth would wait forever.
418
- accountQueryPersistence.setStorage(createMemoryStorage());
421
+ const memoryStorage = createMemoryStorage();
422
+ accountQueryPersistence.setStorage(memoryStorage);
419
423
  if (mounted) {
424
+ setStorage(memoryStorage);
420
425
  logger('Failed to initialize storage', err);
421
426
  onError?.({
422
427
  message: 'Failed to initialize storage',
@@ -430,8 +435,15 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
430
435
  };
431
436
  }, [accountQueryPersistence, logger, onError, platformStorage, runtime, storageReady]);
432
437
 
438
+ const entryLink = useEntryLink({
439
+ storage,
440
+ storageKey: `${storageKeyPrefix}_campaign_attribution`,
441
+ captureCampaign: captureCampaignAttribution,
442
+ onError: logger,
443
+ });
433
444
  const {
434
445
  currentLanguage,
446
+ isLanguageReady,
435
447
  languages: currentLanguages,
436
448
  metadata: currentLanguageMetadata,
437
449
  languageName: currentLanguageName,
@@ -441,6 +453,9 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
441
453
  } = useLanguageManagement({
442
454
  storage,
443
455
  languageKey: storageKeys.language,
456
+ entryUrl: entryLink.url,
457
+ entryLinkReady: entryLink.ready,
458
+ legacyLanguageKeys,
444
459
  user,
445
460
  onError,
446
461
  logger,
@@ -1751,6 +1766,8 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
1751
1766
  webAuthMode,
1752
1767
  error,
1753
1768
  currentLanguage,
1769
+ isLanguageReady: isLanguageReady && entryLink.campaignReady,
1770
+ campaignAttribution: entryLink.campaignAttribution,
1754
1771
  currentLanguages,
1755
1772
  currentLanguageMetadata,
1756
1773
  currentLanguageName,
@@ -1808,6 +1825,9 @@ export const OxyRuntimeProvider: React.FC<OxyRuntimeProviderProps> = ({
1808
1825
  webAuthMode,
1809
1826
  error,
1810
1827
  currentLanguage,
1828
+ isLanguageReady,
1829
+ entryLink.campaignReady,
1830
+ entryLink.campaignAttribution,
1811
1831
  currentLanguages,
1812
1832
  currentLanguageMetadata,
1813
1833
  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
+ }