@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
@@ -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
+ });
@@ -0,0 +1,61 @@
1
+ import { SUPPORTED_LANGUAGES, normalizeLocale } from '@oxy.so/core';
2
+ import {
3
+ CAMPAIGN_LABEL_MAX_LENGTH,
4
+ isLanguageTag,
5
+ hasCampaignControlCharacters,
6
+ CAMPAIGN_CLICK_ID_MAX_LENGTH,
7
+ type CampaignAttribution,
8
+ } from '@oxy.so/contracts/client';
9
+
10
+ const CAMPAIGN_PARAMETERS = {
11
+ utmSource: 'utm_source',
12
+ utmMedium: 'utm_medium',
13
+ utmCampaign: 'utm_campaign',
14
+ utmContent: 'utm_content',
15
+ utmTerm: 'utm_term',
16
+ fbclid: 'fbclid',
17
+ } as const;
18
+
19
+ export function readCampaignAttribution(value: unknown): CampaignAttribution | undefined {
20
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
21
+ const result: CampaignAttribution = {};
22
+ for (const field of Object.keys(CAMPAIGN_PARAMETERS) as (keyof CampaignAttribution)[]) {
23
+ const raw = Reflect.get(value, field);
24
+ if (typeof raw !== 'string') continue;
25
+ const normalized = raw.trim();
26
+ const limit = field === 'fbclid' ? CAMPAIGN_CLICK_ID_MAX_LENGTH : CAMPAIGN_LABEL_MAX_LENGTH;
27
+ if (!normalized || normalized.length > limit || hasCampaignControlCharacters(normalized))
28
+ continue;
29
+ result[field] = normalized;
30
+ }
31
+ return Object.keys(result).length ? result : undefined;
32
+ }
33
+
34
+ /** Consumes only explicit locale/campaign parameters, never the URL or referrer itself. */
35
+ export function readEntryLink(url: string | null): {
36
+ language?: string;
37
+ campaignAttribution?: CampaignAttribution;
38
+ } {
39
+ if (!url) return {};
40
+ let parsed: URL;
41
+ try {
42
+ parsed = new URL(url);
43
+ } catch {
44
+ return {};
45
+ }
46
+ const languages = parsed.searchParams.getAll('lang');
47
+ const requestedLanguage = languages.length === 1 ? languages[0] : undefined;
48
+ const language =
49
+ requestedLanguage && isLanguageTag(requestedLanguage)
50
+ ? (normalizeLocale(requestedLanguage) ??
51
+ SUPPORTED_LANGUAGES.find(
52
+ (candidate) => candidate.language === requestedLanguage.split('-')[0]?.toLowerCase(),
53
+ )?.code)
54
+ : undefined;
55
+ const fields: Record<string, string> = {};
56
+ for (const [field, query] of Object.entries(CAMPAIGN_PARAMETERS)) {
57
+ const values = parsed.searchParams.getAll(query);
58
+ if (values.length === 1) fields[field] = values[0];
59
+ }
60
+ return { language, campaignAttribution: readCampaignAttribution(fields) };
61
+ }