@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.
- package/android/build.gradle +1 -0
- package/android/src/main/java/so/oxy/assets/OxyAssetFilesModule.kt +49 -0
- package/android/src/main/java/so/oxy/assets/StreamSha256.kt +23 -0
- package/android/src/test/java/so/oxy/assets/StreamSha256Test.kt +45 -0
- package/expo-module.config.json +3 -2
- package/ios/OxyAssetFilesModule.swift +72 -0
- 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 +23 -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/commonjs/ui/utils/nativeAssetFiles.js +11 -0
- package/lib/commonjs/ui/utils/nativeAssetFiles.js.map +1 -0
- package/lib/commonjs/ui/utils/nativeAssetFiles.macos.js +11 -0
- package/lib/commonjs/ui/utils/nativeAssetFiles.macos.js.map +1 -0
- package/lib/commonjs/ui/utils/nativeAssetFiles.native.js +202 -0
- package/lib/commonjs/ui/utils/nativeAssetFiles.native.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 +24 -3
- 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/module/ui/utils/nativeAssetFiles.js +7 -0
- package/lib/module/ui/utils/nativeAssetFiles.js.map +1 -0
- package/lib/module/ui/utils/nativeAssetFiles.macos.js +7 -0
- package/lib/module/ui/utils/nativeAssetFiles.macos.js.map +1 -0
- package/lib/module/ui/utils/nativeAssetFiles.native.js +197 -0
- package/lib/module/ui/utils/nativeAssetFiles.native.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/commonjs/ui/utils/nativeAssetFiles.d.ts +4 -0
- package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.macos.d.ts +4 -0
- package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.macos.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.native.d.ts +4 -0
- package/lib/typescript/commonjs/ui/utils/nativeAssetFiles.native.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/lib/typescript/module/ui/utils/nativeAssetFiles.d.ts +4 -0
- package/lib/typescript/module/ui/utils/nativeAssetFiles.d.ts.map +1 -0
- package/lib/typescript/module/ui/utils/nativeAssetFiles.macos.d.ts +4 -0
- package/lib/typescript/module/ui/utils/nativeAssetFiles.macos.d.ts.map +1 -0
- package/lib/typescript/module/ui/utils/nativeAssetFiles.native.d.ts +4 -0
- package/lib/typescript/module/ui/utils/nativeAssetFiles.native.d.ts.map +1 -0
- package/package.json +6 -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 +34 -2
- 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/__tests__/nativeAssetFiles.test.ts +270 -0
- package/src/ui/utils/entryLink.ts +61 -0
- package/src/ui/utils/nativeAssetFiles.macos.ts +6 -0
- package/src/ui/utils/nativeAssetFiles.native.ts +212 -0
- 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: () => ({
|
|
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);
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
+
});
|