@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.
- package/lib/commonjs/ui/components/AccountLanguageBoundary.js +35 -0
- package/lib/commonjs/ui/components/AccountLanguageBoundary.js.map +1 -0
- package/lib/commonjs/ui/components/LanguageBridge.js +37 -34
- package/lib/commonjs/ui/components/LanguageBridge.js.map +1 -1
- package/lib/commonjs/ui/components/OxyProvider.js +19 -26
- package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
- package/lib/commonjs/ui/components/signIn/OxySignUpPanel.js +8 -2
- package/lib/commonjs/ui/components/signIn/OxySignUpPanel.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +19 -2
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/hooks/useEntryLink.js +97 -0
- package/lib/commonjs/ui/hooks/useEntryLink.js.map +1 -0
- package/lib/commonjs/ui/hooks/useLanguageManagement.js +45 -14
- package/lib/commonjs/ui/hooks/useLanguageManagement.js.map +1 -1
- package/lib/commonjs/ui/utils/entryLink.js +54 -0
- package/lib/commonjs/ui/utils/entryLink.js.map +1 -0
- package/lib/module/ui/components/AccountLanguageBoundary.js +32 -0
- package/lib/module/ui/components/AccountLanguageBoundary.js.map +1 -0
- package/lib/module/ui/components/LanguageBridge.js +38 -35
- package/lib/module/ui/components/LanguageBridge.js.map +1 -1
- package/lib/module/ui/components/OxyProvider.js +18 -27
- package/lib/module/ui/components/OxyProvider.js.map +1 -1
- package/lib/module/ui/components/signIn/OxySignUpPanel.js +8 -2
- package/lib/module/ui/components/signIn/OxySignUpPanel.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +19 -2
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/hooks/useEntryLink.js +94 -0
- package/lib/module/ui/hooks/useEntryLink.js.map +1 -0
- package/lib/module/ui/hooks/useLanguageManagement.js +45 -14
- package/lib/module/ui/hooks/useLanguageManagement.js.map +1 -1
- package/lib/module/ui/utils/entryLink.js +49 -0
- package/lib/module/ui/utils/entryLink.js.map +1 -0
- package/lib/typescript/commonjs/ui/components/AccountLanguageBoundary.d.ts +8 -0
- package/lib/typescript/commonjs/ui/components/AccountLanguageBoundary.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/LanguageBridge.d.ts +7 -17
- package/lib/typescript/commonjs/ui/components/LanguageBridge.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/signIn/OxySignUpPanel.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +5 -0
- package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useEntryLink.d.ts +15 -0
- package/lib/typescript/commonjs/ui/hooks/useEntryLink.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/hooks/useLanguageManagement.d.ts +5 -1
- package/lib/typescript/commonjs/ui/hooks/useLanguageManagement.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/types/navigation.d.ts +4 -0
- package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/entryLink.d.ts +8 -0
- package/lib/typescript/commonjs/ui/utils/entryLink.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/AccountLanguageBoundary.d.ts +8 -0
- package/lib/typescript/module/ui/components/AccountLanguageBoundary.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/LanguageBridge.d.ts +7 -17
- package/lib/typescript/module/ui/components/LanguageBridge.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/signIn/OxySignUpPanel.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +5 -0
- package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useEntryLink.d.ts +15 -0
- package/lib/typescript/module/ui/hooks/useEntryLink.d.ts.map +1 -0
- package/lib/typescript/module/ui/hooks/useLanguageManagement.d.ts +5 -1
- package/lib/typescript/module/ui/hooks/useLanguageManagement.d.ts.map +1 -1
- package/lib/typescript/module/ui/types/navigation.d.ts +4 -0
- package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/entryLink.d.ts +8 -0
- package/lib/typescript/module/ui/utils/entryLink.d.ts.map +1 -0
- package/package.json +5 -5
- package/src/ui/components/AccountLanguageBoundary.tsx +35 -0
- package/src/ui/components/LanguageBridge.tsx +43 -39
- package/src/ui/components/OxyProvider.tsx +18 -24
- package/src/ui/components/__tests__/AccountLanguageBoundary.test.tsx +87 -0
- package/src/ui/components/__tests__/LanguageBridge.test.tsx +80 -60
- package/src/ui/components/signIn/OxySignUpPanel.tsx +9 -1
- package/src/ui/context/OxyContext.tsx +21 -1
- package/src/ui/context/oxyContextTypes.ts +5 -0
- package/src/ui/hooks/useEntryLink.ts +103 -0
- package/src/ui/hooks/useLanguageManagement.ts +53 -18
- package/src/ui/types/navigation.ts +4 -0
- package/src/ui/utils/__tests__/entryLink.test.ts +30 -0
- 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 {
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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: () => ({
|
|
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(
|
|
25
|
-
|
|
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
|
|
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('
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
expect(onChange).toHaveBeenCalledWith('
|
|
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('
|
|
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 {
|
|
57
|
-
|
|
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(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
87
|
+
screen.rerender(
|
|
88
|
+
<LanguageBridge config={config}>
|
|
89
|
+
<Page />
|
|
90
|
+
</LanguageBridge>,
|
|
71
91
|
);
|
|
72
|
-
expect(onChange).
|
|
73
|
-
expect(
|
|
92
|
+
await waitFor(() => expect(onChange).toHaveBeenLastCalledWith('fr-FR'));
|
|
93
|
+
expect(mounts).toBe(1);
|
|
74
94
|
});
|
|
75
95
|
|
|
76
|
-
it('reports a
|
|
77
|
-
|
|
78
|
-
const
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
/>,
|
|
103
|
+
config={{ supportedLocales: HOST_CATALOG, fallbackLocale: 'en-US', onChange, onError }}
|
|
104
|
+
>
|
|
105
|
+
<span>App</span>
|
|
106
|
+
</LanguageBridge>,
|
|
87
107
|
);
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
expect(
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|