@usergist/feedback-react-native 0.2.0-beta.0 → 0.2.0-beta.2

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 (83) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +65 -3
  3. package/dist/Provider.d.ts.map +1 -1
  4. package/dist/Provider.js +51 -23
  5. package/dist/Provider.js.map +1 -1
  6. package/dist/Push.d.ts +2 -0
  7. package/dist/Push.d.ts.map +1 -1
  8. package/dist/Push.js +41 -2
  9. package/dist/Push.js.map +1 -1
  10. package/dist/UserGist.d.ts +17 -2
  11. package/dist/UserGist.d.ts.map +1 -1
  12. package/dist/UserGist.js +431 -139
  13. package/dist/UserGist.js.map +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/internal/context.d.ts +1 -1
  17. package/dist/internal/context.js +1 -1
  18. package/dist/internal/engine.d.ts +16 -2
  19. package/dist/internal/engine.d.ts.map +1 -1
  20. package/dist/internal/engine.js +278 -76
  21. package/dist/internal/engine.js.map +1 -1
  22. package/dist/internal/events.d.ts +6 -1
  23. package/dist/internal/events.d.ts.map +1 -1
  24. package/dist/internal/events.js +39 -18
  25. package/dist/internal/events.js.map +1 -1
  26. package/dist/internal/identity-lifecycle.d.ts +16 -0
  27. package/dist/internal/identity-lifecycle.d.ts.map +1 -0
  28. package/dist/internal/identity-lifecycle.js +96 -0
  29. package/dist/internal/identity-lifecycle.js.map +1 -0
  30. package/dist/internal/inapp-rules-cache.d.ts +1 -0
  31. package/dist/internal/inapp-rules-cache.d.ts.map +1 -1
  32. package/dist/internal/inapp-rules-cache.js +7 -0
  33. package/dist/internal/inapp-rules-cache.js.map +1 -1
  34. package/dist/internal/mutation-queue.d.ts +2 -2
  35. package/dist/internal/mutation-queue.d.ts.map +1 -1
  36. package/dist/internal/mutation-queue.js +43 -5
  37. package/dist/internal/mutation-queue.js.map +1 -1
  38. package/dist/internal/push-subscription.d.ts +11 -0
  39. package/dist/internal/push-subscription.d.ts.map +1 -0
  40. package/dist/internal/push-subscription.js +20 -0
  41. package/dist/internal/push-subscription.js.map +1 -0
  42. package/dist/internal/rules-cache.d.ts +1 -0
  43. package/dist/internal/rules-cache.d.ts.map +1 -1
  44. package/dist/internal/rules-cache.js +7 -0
  45. package/dist/internal/rules-cache.js.map +1 -1
  46. package/dist/internal/session-revocations.d.ts +5 -0
  47. package/dist/internal/session-revocations.d.ts.map +1 -0
  48. package/dist/internal/session-revocations.js +58 -0
  49. package/dist/internal/session-revocations.js.map +1 -0
  50. package/dist/internal/storage.d.ts +2 -0
  51. package/dist/internal/storage.d.ts.map +1 -1
  52. package/dist/internal/storage.js +75 -60
  53. package/dist/internal/storage.js.map +1 -1
  54. package/dist/internal/survey-invitations.d.ts +25 -0
  55. package/dist/internal/survey-invitations.d.ts.map +1 -0
  56. package/dist/internal/survey-invitations.js +54 -0
  57. package/dist/internal/survey-invitations.js.map +1 -0
  58. package/dist/internal/survey-matcher.d.ts +1 -0
  59. package/dist/internal/survey-matcher.d.ts.map +1 -1
  60. package/dist/internal/survey-matcher.js +3 -0
  61. package/dist/internal/survey-matcher.js.map +1 -1
  62. package/dist/internal/survey-rules-cache.d.ts +1 -0
  63. package/dist/internal/survey-rules-cache.d.ts.map +1 -1
  64. package/dist/internal/survey-rules-cache.js +7 -0
  65. package/dist/internal/survey-rules-cache.js.map +1 -1
  66. package/dist/internal/survey-store.d.ts +9 -2
  67. package/dist/internal/survey-store.d.ts.map +1 -1
  68. package/dist/internal/survey-store.js +37 -16
  69. package/dist/internal/survey-store.js.map +1 -1
  70. package/dist/internal/transport.d.ts +17 -4
  71. package/dist/internal/transport.d.ts.map +1 -1
  72. package/dist/internal/transport.js +40 -19
  73. package/dist/internal/transport.js.map +1 -1
  74. package/dist/internal/types.d.ts +2 -0
  75. package/dist/internal/types.d.ts.map +1 -1
  76. package/dist/internal/user-state.d.ts +2 -1
  77. package/dist/internal/user-state.d.ts.map +1 -1
  78. package/dist/internal/user-state.js +3 -2
  79. package/dist/internal/user-state.js.map +1 -1
  80. package/dist/ui/SurveyView.d.ts.map +1 -1
  81. package/dist/ui/SurveyView.js +5 -5
  82. package/dist/ui/SurveyView.js.map +1 -1
  83. package/package.json +2 -2
package/dist/UserGist.js CHANGED
@@ -1,3 +1,7 @@
1
+ import { drainRevocations, hasPendingRevocations } from './internal/session-revocations.js';
2
+ import { setPushSubscriptionStateHandler, pushSubscriptionChanged } from './internal/push-subscription.js';
3
+ import { cancelIdentityRecovery, identityChanged, setIdentityStateHandler, setSubjectTokenProvider, getIdentityState } from './internal/identity-lifecycle.js';
4
+ import { userPropertiesUpdateSchema } from '@usergist/sdk-core/mobile';
1
5
  import { hydratePushDedupe } from './internal/push-dedupe.js';
2
6
  import { REQUEST_COMMENTED_EVENT_NAME, REQUEST_COMMENT_EDITED_EVENT_NAME, REQUEST_COMMENT_DELETED_EVENT_NAME, } from '@usergist/sdk-core/mobile';
3
7
  import { asEventProps, clearAllState, createEngine, emitAppOpenWhenConsentReady, emitAppVersionChanged, ensureHydrated, enqueueAndEvaluate, flushNow, flushMutations, pollInstructions, refreshTargetingRules, recordServerBackedEventLocally, submitResponse, } from './internal/engine.js';
@@ -7,7 +11,7 @@ import { createSurveyStore } from './internal/survey-store.js';
7
11
  import { UserGistPushNative, onTokenReceived, onNotificationReceived, onNotificationOpened, onNotificationDisplayed, onNotificationDismissed, onTokenError, } from './native/push-bridge.js';
8
12
  import { Push } from './Push.js';
9
13
  import { AppState, Platform } from 'react-native';
10
- import { generateEventId, validateIdentifyTransition } from './internal/identity.js';
14
+ import { generateEventId, validateIdentifyTransition, } from './internal/identity.js';
11
15
  import { configureStorageAdapter, STORAGE_KEYS, } from './internal/storage.js';
12
16
  import { surveyCompletionOutcome } from './internal/survey-completion.js';
13
17
  import { USERGIST_SDK_VERSION } from './internal/context.js';
@@ -21,6 +25,7 @@ function serializePushRegistration(operation) {
21
25
  let runtimeStarted = false;
22
26
  let runtimeStartPromise = null;
23
27
  let resetPromise = null;
28
+ const surveyStarts = new Map();
24
29
  let surveyStore = null;
25
30
  let surveyHandlers = {};
26
31
  let inAppHandlers = {};
@@ -39,8 +44,10 @@ function requestSessionIsCurrent(e) {
39
44
  const generation = e.resetGeneration;
40
45
  const consentVersion = e.consent.get().version;
41
46
  const identity = e.identity.get();
42
- return () => !e.resetting && generation === e.resetGeneration &&
43
- consentVersion === e.consent.get().version && e.consent.allowsFeedback() &&
47
+ return () => !e.resetting &&
48
+ generation === e.resetGeneration &&
49
+ consentVersion === e.consent.get().version &&
50
+ e.consent.allowsFeedback() &&
44
51
  identity.anonymousId === e.identity.get().anonymousId &&
45
52
  identity.externalId === e.identity.get().externalId;
46
53
  }
@@ -49,9 +56,16 @@ async function syncNativePushState(e, push = e.consent.allowsPush()) {
49
56
  const generation = e.resetGeneration;
50
57
  const version = e.consent.get().version;
51
58
  const operation = nativeStateQueue.then(async () => {
52
- if (generation !== e.resetGeneration || anonymousId !== e.identity.get().anonymousId || version !== e.consent.get().version)
59
+ if (generation !== e.resetGeneration ||
60
+ anonymousId !== e.identity.get().anonymousId ||
61
+ version !== e.consent.get().version)
53
62
  return;
54
- await UserGistPushNative.syncState({ writeKey: e.config.writeKey, apiUrl: e.config.apiUrl, anonymousId, push: push && !e.resetting });
63
+ await UserGistPushNative.syncState({
64
+ writeKey: e.config.writeKey,
65
+ apiUrl: e.config.apiUrl,
66
+ anonymousId,
67
+ push: push && !e.resetting,
68
+ });
55
69
  });
56
70
  nativeStateQueue = operation.catch(() => undefined);
57
71
  await operation;
@@ -75,8 +89,24 @@ function requireEngine() {
75
89
  function ensureEngine(config) {
76
90
  if (engine)
77
91
  return engine;
78
- engine = createEngine(config);
79
- surveyStore = createSurveyStore(config.writeKey);
92
+ // A host-owned invitation is not a survey start. Only automatic rendering
93
+ // requests a session bundled with the delivery authorization.
94
+ engine = createEngine(config, () => !surveyHandlers.onInvite);
95
+ const pushEngine = engine;
96
+ pushEngine.retryPushRegistration = async () => {
97
+ if (pushEngine.resetting)
98
+ return;
99
+ const saved = await pushEngine.storage.getJson(STORAGE_KEYS.pushDevice);
100
+ if (!saved)
101
+ return;
102
+ pushEngine.pushTokenAvailable = true;
103
+ if (pushEngine !== engine || pushEngine.resetting)
104
+ return;
105
+ pushSubscriptionChanged(pushEngine);
106
+ if (pushEngine.consent.allowsPush())
107
+ await registerPushDevice(saved.token, saved.platform, { environment: saved.environment }, true);
108
+ };
109
+ surveyStore = createSurveyStore(config.writeKey, () => engine.identity.get());
80
110
  return engine;
81
111
  }
82
112
  async function startRuntime(e) {
@@ -145,7 +175,9 @@ function attachEnginePushListeners() {
145
175
  try {
146
176
  const platform = (p.platform === 'ios' ? 'ios' : 'android');
147
177
  await UserGist.registerPushToken(p.token, platform, {
148
- environment: lastEnablePushOptions?.environment ?? await UserGistPushNative.defaultEnvironment() ?? defaultEnvironment(),
178
+ environment: lastEnablePushOptions?.environment ??
179
+ (await UserGistPushNative.defaultEnvironment()) ??
180
+ defaultEnvironment(),
149
181
  });
150
182
  }
151
183
  catch (err) {
@@ -159,8 +191,8 @@ function attachEnginePushListeners() {
159
191
  onNotificationReceived((p) => {
160
192
  forwardToPushHandler(p, 'received');
161
193
  });
162
- onNotificationDisplayed(p => forwardToPushHandler(p, 'displayed'));
163
- onNotificationDismissed(p => forwardToPushHandler(p, 'dismissed'));
194
+ onNotificationDisplayed((p) => forwardToPushHandler(p, 'displayed'));
195
+ onNotificationDismissed((p) => forwardToPushHandler(p, 'dismissed'));
164
196
  onNotificationOpened((p) => {
165
197
  forwardToPushHandler(p, 'opened');
166
198
  });
@@ -169,7 +201,8 @@ function forwardToPushHandler(p, kind) {
169
201
  if (!engine?.consent.allowsPush() || engine.resetting)
170
202
  return;
171
203
  const data = p.data ?? {};
172
- if (data.usergist_anonymous_id && data.usergist_anonymous_id !== engine.identity.get().anonymousId)
204
+ if (data.usergist_anonymous_id &&
205
+ data.usergist_anonymous_id !== engine.identity.get().anonymousId)
173
206
  return;
174
207
  if (p.deliveryId) {
175
208
  ;
@@ -186,7 +219,10 @@ function forwardToPushHandler(p, kind) {
186
219
  };
187
220
  Object.assign(userInfo, data);
188
221
  if (kind === 'received') {
189
- Push.handleReceived({ userInfo, notification: { title: p.title, body: p.body } });
222
+ Push.handleReceived({
223
+ userInfo,
224
+ notification: { title: p.title, body: p.body },
225
+ });
190
226
  }
191
227
  else if (kind === 'displayed') {
192
228
  Push.handleDisplayed({ userInfo });
@@ -204,7 +240,10 @@ function forwardToPushHandler(p, kind) {
204
240
  dataStr[k] = String(v);
205
241
  }
206
242
  if (kind === 'received') {
207
- Push.handleReceived({ data: dataStr, notification: { title: p.title, body: p.body } });
243
+ Push.handleReceived({
244
+ data: dataStr,
245
+ notification: { title: p.title, body: p.body },
246
+ });
208
247
  }
209
248
  else if (kind === 'displayed') {
210
249
  Push.handleDisplayed({ data: dataStr });
@@ -240,6 +279,11 @@ export const UserGist = {
240
279
  init(config) {
241
280
  void UserGist.initAsync(config);
242
281
  },
282
+ /** Defer campaign UI without interrupting analytics or an active modal. */
283
+ pausePresentation() { engine?.presentation.setPaused(true); },
284
+ /** Call after the host's loaded screen and navigation are ready. */
285
+ resumePresentation() { engine?.presentation.setPaused(false); },
286
+ __internal_presentationGate() { return requireEngine().presentation; },
243
287
  async initAsync(config, initialIdentity) {
244
288
  try {
245
289
  const e = ensureEngine(config);
@@ -259,6 +303,10 @@ export const UserGist = {
259
303
  return 'rejected';
260
304
  }
261
305
  },
306
+ setPushSubscriptionStateHandler,
307
+ setIdentityStateHandler,
308
+ setSubjectTokenProvider,
309
+ getIdentityState,
262
310
  identify(userId, properties, subjectToken) {
263
311
  void UserGist.identifyAsync(userId, properties, subjectToken);
264
312
  },
@@ -269,18 +317,26 @@ export const UserGist = {
269
317
  return 'rejected';
270
318
  }
271
319
  const e = requireEngine();
320
+ const generation = e.resetGeneration;
272
321
  await ensureHydrated(e);
273
- if (e.resetting)
322
+ if (e.resetting || e.resetGeneration !== generation)
274
323
  return 'rejected';
275
324
  const identity = e.identity.get();
276
325
  const pendingIdentity = e.mutations.peek();
277
- const transition = validateIdentifyTransition(identity.externalId, pendingIdentity?.kind === 'identify' ? pendingIdentity.payload.externalId : null, userId);
326
+ const transition = validateIdentifyTransition(identity.externalId, pendingIdentity?.kind === 'identify'
327
+ ? pendingIdentity.payload.externalId
328
+ : null, userId);
278
329
  if (transition === 'reset_required') {
279
330
  reportError('identify rejected: call reset() before switching users');
280
331
  return 'rejected';
281
332
  }
282
- const cleanProps = e.consent.allowsAnalytics() ? asEventProps(properties) : undefined;
333
+ const cleanProps = e.consent.allowsAnalytics()
334
+ ? (properties && Object.keys(properties).length
335
+ ? userPropertiesUpdateSchema.parse({ mutationId: generateEventId(), set: properties }).set
336
+ : undefined)
337
+ : undefined;
283
338
  if (transition === 'already_identified' &&
339
+ (!subjectToken || subjectToken === e.subjectToken) &&
284
340
  (!cleanProps || Object.keys(cleanProps).length === 0)) {
285
341
  return 'synced';
286
342
  }
@@ -288,6 +344,7 @@ export const UserGist = {
288
344
  reportError('identify requires a server-minted subject token');
289
345
  return 'rejected';
290
346
  }
347
+ identityChanged(e, 'identifying');
291
348
  const mutationId = await e.mutations.enqueue('identify', 'essential', {
292
349
  subjectToken,
293
350
  anonymousId: identity.anonymousId,
@@ -295,7 +352,10 @@ export const UserGist = {
295
352
  ...(cleanProps ? { properties: cleanProps } : {}),
296
353
  }, `identify:${userId}`);
297
354
  const result = await flushMutations(e);
355
+ if (e.resetting || e.resetGeneration !== generation)
356
+ return 'rejected';
298
357
  if (result.permanentlyRejectedIds.has(mutationId)) {
358
+ identityChanged(e, 'rejected');
299
359
  reportError('identify was rejected by the server');
300
360
  return 'rejected';
301
361
  }
@@ -312,6 +372,33 @@ export const UserGist = {
312
372
  return 'rejected';
313
373
  }
314
374
  },
375
+ /** Update the current user, including anonymous users. Durable and retried on reconnect. */
376
+ async setUserProperties(properties, unset = []) {
377
+ try {
378
+ const e = requireEngine();
379
+ const generation = e.resetGeneration;
380
+ await ensureHydrated(e);
381
+ if (e !== engine ||
382
+ e.resetting ||
383
+ e.resetGeneration !== generation ||
384
+ !e.consent.allowsAnalytics())
385
+ return 'rejected';
386
+ const update = userPropertiesUpdateSchema.parse({
387
+ mutationId: generateEventId(),
388
+ set: properties,
389
+ unset: [...unset],
390
+ });
391
+ const mutationId = await e.mutations.enqueue('user-properties', 'analytics', { ...update, anonymousId: e.identity.get().anonymousId });
392
+ const result = await flushMutations(e);
393
+ if (result.permanentlyRejectedIds.has(mutationId))
394
+ return 'rejected';
395
+ return e.mutations.has(mutationId) ? 'queued' : 'synced';
396
+ }
397
+ catch (error) {
398
+ reportError('setUserProperties failed', error);
399
+ return 'rejected';
400
+ }
401
+ },
315
402
  track(eventName, properties) {
316
403
  try {
317
404
  if (typeof eventName !== 'string' || eventName.length === 0) {
@@ -329,6 +416,10 @@ export const UserGist = {
329
416
  async setConsent(purposes) {
330
417
  try {
331
418
  const e = requireEngine();
419
+ if (purposes.feedback === false)
420
+ e.presentation.invalidate('feedback');
421
+ if (purposes.survey === false)
422
+ e.presentation.invalidate('survey');
332
423
  await ensureHydrated(e);
333
424
  if (e.resetting)
334
425
  return false;
@@ -348,7 +439,7 @@ export const UserGist = {
348
439
  if (e.resetting || next.version !== e.consent.get().version)
349
440
  return false;
350
441
  if (e.lastPushToken)
351
- await UserGist.invalidatePushToken(e.lastPushToken);
442
+ await invalidatePushDevice(e.lastPushToken, true);
352
443
  await disableNativePushForConsent(e, next.version);
353
444
  }
354
445
  if (e.resetting || next.version !== e.consent.get().version)
@@ -372,6 +463,9 @@ export const UserGist = {
372
463
  reportError('transport.consent failed', err);
373
464
  return false;
374
465
  }
466
+ pushSubscriptionChanged(e);
467
+ if (next.push)
468
+ void e.retryPushRegistration?.().catch(error => reportError('push registration deferred', error));
375
469
  if (next.feedback || next.survey) {
376
470
  await refreshTargetingRules(e, true);
377
471
  }
@@ -388,27 +482,30 @@ export const UserGist = {
388
482
  reset() {
389
483
  try {
390
484
  const e = requireEngine();
391
- if (e.resetting)
392
- return resetPromise ?? Promise.resolve();
485
+ if (resetPromise)
486
+ return resetPromise;
393
487
  e.resetting = true;
488
+ identityChanged(e, 'resetting');
489
+ pushSubscriptionChanged(e);
394
490
  e.resetGeneration += 1;
491
+ e.transport.cancelAll();
492
+ cancelIdentityRecovery(e);
493
+ e.presentation.invalidate();
395
494
  requestsCache = null;
396
495
  lastEnablePushOptions = null;
397
496
  e.events.emit('resetSurfaces', undefined);
398
497
  const operation = (async () => {
399
498
  await ensureHydrated(e);
400
499
  await syncNativePushState(e, false);
401
- await pushRegistrationQueue;
402
- if (e.lastPushToken)
403
- await UserGist.invalidatePushToken(e.lastPushToken);
404
- await UserGistPushNative.disablePush().catch(() => undefined);
500
+ void UserGistPushNative.disablePush().catch(() => undefined);
405
501
  await clearAllState(e);
502
+ await surveyStore?.clear();
406
503
  await hydratePushDedupe(e.config.writeKey, e.identity.get().anonymousId);
407
504
  })();
408
505
  const finalized = operation
409
- .catch((err) => reportError('reset failed', err))
506
+ .then(() => { e.resetting = false; pushSubscriptionChanged(e); })
507
+ .catch((err) => { identityChanged(e, 'reset-failed'); reportError('reset failed', err); throw err; })
410
508
  .finally(() => {
411
- e.resetting = false;
412
509
  if (resetPromise === finalized)
413
510
  resetPromise = null;
414
511
  });
@@ -517,75 +614,10 @@ export const UserGist = {
517
614
  }
518
615
  },
519
616
  async registerPushToken(token, platform, opts) {
520
- try {
521
- if (!token)
522
- return;
523
- const e = requireEngine();
524
- await ensureHydrated(e);
525
- if (!e.consent.allowsPush() || e.resetting)
526
- return;
527
- const generation = e.resetGeneration;
528
- await serializePushRegistration(async () => {
529
- if (e.resetting || generation !== e.resetGeneration || !e.consent.allowsPush())
530
- return;
531
- const id = e.identity.get();
532
- const environment = opts?.environment ?? await UserGistPushNative.defaultEnvironment() ??
533
- (e.config.environment === 'production' ? 'production' : 'sandbox');
534
- const registrationKey = [
535
- id.anonymousId,
536
- id.externalId ?? '',
537
- platform,
538
- environment,
539
- token,
540
- ].join(':');
541
- if (e.lastPushRegistrationKey === registrationKey &&
542
- Date.now() - e.lastPushRegistrationAt < 24 * 60 * 60_000)
543
- return;
544
- if (e.resetting || generation !== e.resetGeneration || !e.consent.allowsPush())
545
- return;
546
- // Persist before the request so logout can invalidate a token even when
547
- // the response is lost or the process exits after the server accepts it.
548
- e.lastPushToken = token;
549
- await e.storage.setJson(STORAGE_KEYS.pushToken, token);
550
- await e.transport.pushRegisterToken({
551
- anonymousId: id.anonymousId,
552
- externalId: id.externalId ?? null,
553
- token,
554
- platform,
555
- environment,
556
- language: undefined,
557
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
558
- appVersion: undefined,
559
- sdkVersion: `rn-${USERGIST_SDK_VERSION}`,
560
- optIn: true,
561
- });
562
- e.lastPushToken = token;
563
- e.lastPushRegistrationKey = registrationKey;
564
- e.lastPushRegistrationAt = Date.now();
565
- debugLog('push token registered with server');
566
- });
567
- }
568
- catch (err) {
569
- reportError('registerPushToken failed', err);
570
- }
617
+ await registerPushDevice(token, platform, opts);
571
618
  },
572
619
  async invalidatePushToken(token) {
573
- try {
574
- if (!token)
575
- return;
576
- const e = requireEngine();
577
- await ensureHydrated(e);
578
- const id = e.identity.get();
579
- e.lastPushRegistrationKey = null;
580
- e.lastPushRegistrationAt = 0;
581
- await e.transport.pushInvalidateToken({
582
- anonymousId: id.anonymousId,
583
- token,
584
- });
585
- }
586
- catch (err) {
587
- reportError('invalidatePushToken failed', err);
588
- }
620
+ await invalidatePushDevice(token);
589
621
  },
590
622
  /**
591
623
  * Re-bind the latest registered device token to a newly-identified user.
@@ -603,7 +635,10 @@ export const UserGist = {
603
635
  return;
604
636
  const id = e.identity.get();
605
637
  const token = e.lastPushToken;
606
- if (!token || e.resetting || !e.consent.allowsPush() || id.externalId !== externalId)
638
+ if (!token ||
639
+ e.resetting ||
640
+ !e.consent.allowsPush() ||
641
+ id.externalId !== externalId)
607
642
  return;
608
643
  await e.transport.pushRebind({
609
644
  anonymousId: id.anonymousId,
@@ -703,12 +738,30 @@ export const UserGist = {
703
738
  const e = requireEngine();
704
739
  await ensureHydrated(e);
705
740
  if (!e.consent.allowsPush() || e.resetting)
706
- return { granted: false, status: 'not_determined', platform: Platform.OS === 'ios' ? 'ios' : 'android', error: 'consent_required' };
741
+ return {
742
+ granted: false,
743
+ status: 'not_determined',
744
+ platform: Platform.OS === 'ios' ? 'ios' : 'android',
745
+ error: 'consent_required',
746
+ };
707
747
  const generation = e.resetGeneration;
708
748
  const consentVersion = e.consent.get().version;
709
- const current = () => !e.resetting && generation === e.resetGeneration && consentVersion === e.consent.get().version && e.consent.allowsPush();
710
- const cancelled = () => ({ granted: false, status: 'not_determined', platform: Platform.OS === 'ios' ? 'ios' : 'android', error: 'session_changed' });
711
- opts = { ...opts, environment: opts?.environment ?? await UserGistPushNative.defaultEnvironment() ?? defaultEnvironment() };
749
+ const current = () => !e.resetting &&
750
+ generation === e.resetGeneration &&
751
+ consentVersion === e.consent.get().version &&
752
+ e.consent.allowsPush();
753
+ const cancelled = () => ({
754
+ granted: false,
755
+ status: 'not_determined',
756
+ platform: Platform.OS === 'ios' ? 'ios' : 'android',
757
+ error: 'session_changed',
758
+ });
759
+ opts = {
760
+ ...opts,
761
+ environment: opts?.environment ??
762
+ (await UserGistPushNative.defaultEnvironment()) ??
763
+ defaultEnvironment(),
764
+ };
712
765
  await syncNativePushState(e);
713
766
  if (!current())
714
767
  return cancelled();
@@ -731,7 +784,7 @@ export const UserGist = {
731
784
  if (result.granted && result.token) {
732
785
  const platform = (result.platform === 'ios' ? 'ios' : 'android');
733
786
  await UserGist.registerPushToken(result.token, platform, {
734
- environment: opts?.environment ?? (defaultEnvironment()),
787
+ environment: opts?.environment ?? defaultEnvironment(),
735
788
  });
736
789
  }
737
790
  return result;
@@ -811,10 +864,20 @@ export const UserGist = {
811
864
  debugLog('openSurvey: survey consent not granted; ignoring');
812
865
  return;
813
866
  }
867
+ const generation = e.resetGeneration;
868
+ const identity = e.identity.get();
869
+ const consentVersion = e.consent.get().version;
870
+ const invitation = await e.surveyInvitations.find(surveyId);
871
+ if (e.resetting || e.resetGeneration !== generation ||
872
+ !e.consent.allowsSurvey() || e.consent.get().version !== consentVersion ||
873
+ e.identity.get().anonymousId !== identity.anonymousId ||
874
+ e.identity.get().externalId !== identity.externalId)
875
+ return;
814
876
  e.events.emit('showSurvey', {
815
877
  surveyId,
816
- source: context?.source ?? 'on_demand',
878
+ source: context?.source ?? invitation?.source ?? 'on_demand',
817
879
  ...(context?.language ? { language: context.language } : {}),
880
+ ...(invitation ? { survey: invitation.survey, attempt: invitation.attempt } : {}),
818
881
  });
819
882
  }
820
883
  catch (err) {
@@ -832,21 +895,28 @@ export const UserGist = {
832
895
  const e = requireEngine();
833
896
  await ensureHydrated(e);
834
897
  // Accept URLs of the shape `*://*/s/<token>` or `*survey=<token>`.
835
- const match = url.match(/\/s\/([A-Za-z0-9._-]+)/) ?? url.match(/[?&]survey=([A-Za-z0-9._-]+)/);
898
+ const match = url.match(/\/s\/([A-Za-z0-9._-]+)/) ??
899
+ url.match(/[?&]survey=([A-Za-z0-9._-]+)/);
836
900
  if (!match || !match[1])
837
901
  return false;
838
902
  const token = match[1];
903
+ const presentationIsValid = e.presentation.validator('survey');
839
904
  const id = e.identity.get();
840
905
  const resolved = await e.transport.surveyResolveLink({
841
906
  token,
842
907
  anonymousId: id.anonymousId,
843
908
  externalId: id.externalId ?? null,
844
909
  });
910
+ if (!presentationIsValid() || !e.consent.allowsSurvey())
911
+ return false;
845
912
  if (resolved.consentRequired) {
846
913
  debugLog('survey link resolved but consent required — awaiting consent');
847
914
  return false;
848
915
  }
849
- e.events.emit('showSurvey', { surveyId: resolved.surveyId, source: 'link' });
916
+ e.events.emit('showSurvey', {
917
+ surveyId: resolved.surveyId,
918
+ source: 'link',
919
+ });
850
920
  return true;
851
921
  }
852
922
  catch (err) {
@@ -899,6 +969,9 @@ export const UserGist = {
899
969
  reportError('reportSurveyShown failed', err);
900
970
  }
901
971
  },
972
+ __internal_surveyOpenFailed(surveyId) {
973
+ engine?.surveyMatcher.cancelPending(surveyId);
974
+ },
902
975
  __internal_armedSurveyById(surveyId) {
903
976
  // Local-fire fast-path. If the survey-rules-cache already has the
904
977
  // full survey content (because the matcher just fired), the
@@ -919,6 +992,11 @@ export const UserGist = {
919
992
  const e = requireEngine();
920
993
  await ensureHydrated(e);
921
994
  const id = e.identity.get();
995
+ const pending = await surveyStore?.findForSurvey(surveyId);
996
+ if (pending?.survey &&
997
+ Date.now() - pending.startedAt <
998
+ pending.survey.saveResumeWindowSeconds * 1000)
999
+ return pending.survey;
922
1000
  return await e.transport.surveyGet({
923
1001
  surveyId,
924
1002
  anonymousId: id.anonymousId,
@@ -931,52 +1009,169 @@ export const UserGist = {
931
1009
  return null;
932
1010
  }
933
1011
  },
934
- async __internal_createAttempt(surveyId, source, language) {
1012
+ async __internal_createAttempt(surveyId, source, language, presentationId, content, preparedAttempt) {
1013
+ const active = requireEngine();
1014
+ const identity = active.identity.get();
1015
+ const key = JSON.stringify([
1016
+ active.resetGeneration,
1017
+ identity.anonymousId,
1018
+ identity.externalId,
1019
+ surveyId,
1020
+ language,
1021
+ presentationId,
1022
+ preparedAttempt?.attemptId,
1023
+ ]);
1024
+ const pending = surveyStarts.get(key);
1025
+ if (pending)
1026
+ return pending;
1027
+ const operation = (async () => {
1028
+ try {
1029
+ const e = requireEngine();
1030
+ await ensureHydrated(e);
1031
+ const id = e.identity.get();
1032
+ const generation = e.resetGeneration;
1033
+ const consentVersion = e.consent.get().version;
1034
+ const current = () => !e.resetting &&
1035
+ e.resetGeneration === generation &&
1036
+ e.consent.allowsSurvey() &&
1037
+ e.consent.get().version === consentVersion &&
1038
+ e.identity.get().anonymousId === id.anonymousId &&
1039
+ e.identity.get().externalId === id.externalId;
1040
+ if (!current())
1041
+ return null;
1042
+ const saved = await surveyStore?.findForSurvey(surveyId);
1043
+ if (!current())
1044
+ return null;
1045
+ // Resuming a durable device session preserves its original content and
1046
+ // answers. Never replace it with a stale network snapshot.
1047
+ if (!preparedAttempt &&
1048
+ (!presentationId || saved?.attemptId === presentationId) &&
1049
+ saved?.survey &&
1050
+ Date.now() - saved.startedAt <
1051
+ saved.survey.saveResumeWindowSeconds * 1000) {
1052
+ if (saved.startRequest)
1053
+ await e.mutations.enqueue('survey-start', 'survey', { surveyId, body: saved.startRequest }, `survey-start:${saved.attemptId}`);
1054
+ if (!current())
1055
+ return null;
1056
+ void flushMutations(e);
1057
+ return {
1058
+ attemptId: saved.attemptId,
1059
+ startQuestionId: saved.survey.flow.startQuestionId,
1060
+ currentQuestionId: saved.currentQuestionId,
1061
+ snapshot: saved.snapshot,
1062
+ resumed: true,
1063
+ resolvedContent: saved.survey,
1064
+ };
1065
+ }
1066
+ const armed = !language && !presentationId
1067
+ ? e.surveyRules.getById(surveyId)
1068
+ : null;
1069
+ const grant = armed?.localStart &&
1070
+ Date.parse(armed.localStart.expiresAt) > Date.now()
1071
+ ? armed.localStart
1072
+ : null;
1073
+ const body = {
1074
+ presentationId,
1075
+ anonymousId: id.anonymousId,
1076
+ externalId: id.externalId ?? null,
1077
+ source,
1078
+ ...(language ? { language } : {}),
1079
+ sdkVersion: `rn-${USERGIST_SDK_VERSION}`,
1080
+ platform: e.context.platform(),
1081
+ ...(presentationId || grant || source === 'triggered' || source === 'scheduled'
1082
+ ? { resume: false, clientAttemptId: presentationId ?? generateEventId() }
1083
+ : { resume: true }),
1084
+ ...(grant
1085
+ ? {
1086
+ localStart: {
1087
+ token: grant.token,
1088
+ startedAt: new Date().toISOString(),
1089
+ },
1090
+ }
1091
+ : {}),
1092
+ };
1093
+ const res = preparedAttempt ??
1094
+ (grant && armed
1095
+ ? {
1096
+ attemptId: body.clientAttemptId,
1097
+ startQuestionId: armed.survey.flow.startQuestionId,
1098
+ currentQuestionId: armed.survey.flow.startQuestionId,
1099
+ progressSnapshot: {},
1100
+ resumed: false,
1101
+ resolvedContent: armed.survey,
1102
+ }
1103
+ : await e.transport.surveyCreateAttempt(surveyId, body));
1104
+ if (!current())
1105
+ return null;
1106
+ await surveyStore?.upsert({
1107
+ anonymousId: id.anonymousId,
1108
+ externalId: id.externalId,
1109
+ survey: res.resolvedContent ?? content,
1110
+ ...(grant && !preparedAttempt ? { startRequest: body } : {}),
1111
+ surveyId,
1112
+ attemptId: res.attemptId,
1113
+ startedAt: Date.now(),
1114
+ currentQuestionId: res.currentQuestionId,
1115
+ snapshot: (res.progressSnapshot ??
1116
+ {}),
1117
+ language: language ?? null,
1118
+ });
1119
+ if (!current())
1120
+ return null;
1121
+ if (presentationId) {
1122
+ await e.surveyInvitations.remove(presentationId);
1123
+ if (!current())
1124
+ return null;
1125
+ }
1126
+ if (grant && !preparedAttempt) {
1127
+ await e.mutations.enqueue('survey-start', 'survey', { surveyId, body }, `survey-start:${res.attemptId}`);
1128
+ if (!current())
1129
+ return null;
1130
+ void flushMutations(e);
1131
+ }
1132
+ return {
1133
+ attemptId: res.attemptId,
1134
+ startQuestionId: res.startQuestionId,
1135
+ currentQuestionId: res.currentQuestionId,
1136
+ snapshot: (res.progressSnapshot ??
1137
+ {}),
1138
+ resumed: res.resumed,
1139
+ resolvedContent: res.resolvedContent,
1140
+ };
1141
+ }
1142
+ catch (err) {
1143
+ reportError('createAttempt failed', err);
1144
+ return null;
1145
+ }
1146
+ })();
1147
+ surveyStarts.set(key, operation);
935
1148
  try {
936
- const e = requireEngine();
937
- await ensureHydrated(e);
938
- // Give a previously deferred completion a chance to reach the server
939
- // before asking it to create or resume another attempt.
940
- await flushMutations(e);
941
- const id = e.identity.get();
942
- const res = await e.transport.surveyCreateAttempt(surveyId, {
943
- anonymousId: id.anonymousId,
944
- externalId: id.externalId ?? null,
945
- source,
946
- ...(language ? { language } : {}),
947
- sdkVersion: `rn-${USERGIST_SDK_VERSION}`,
948
- });
949
- await surveyStore?.upsert({
950
- surveyId,
951
- attemptId: res.attemptId,
952
- startedAt: Date.now(),
953
- currentQuestionId: res.currentQuestionId,
954
- snapshot: (res.progressSnapshot ?? {}),
955
- language: language ?? null,
956
- });
957
- return {
958
- attemptId: res.attemptId,
959
- startQuestionId: res.startQuestionId,
960
- currentQuestionId: res.currentQuestionId,
961
- snapshot: (res.progressSnapshot ?? {}),
962
- resumed: res.resumed,
963
- };
1149
+ return await operation;
964
1150
  }
965
- catch (err) {
966
- reportError('createAttempt failed', err);
967
- return null;
1151
+ finally {
1152
+ if (surveyStarts.get(key) === operation)
1153
+ surveyStarts.delete(key);
968
1154
  }
969
1155
  },
970
1156
  async __internal_saveProgress(attemptId, currentQuestionId, snapshot) {
971
1157
  // Persist the resume point before attempting the network. Progress is
972
1158
  // best-effort transport; losing connectivity must not lose local answers.
1159
+ const e = requireEngine();
1160
+ if (e.resetting || !e.consent.allowsSurvey())
1161
+ return;
1162
+ const generation = e.resetGeneration;
1163
+ const pending = (await surveyStore?.list())?.find((a) => a.attemptId === attemptId);
1164
+ if (!pending || e.resetting || e.resetGeneration !== generation)
1165
+ return;
973
1166
  await surveyStore?.updateProgress(attemptId, currentQuestionId, snapshot);
974
1167
  try {
975
- const e = requireEngine();
976
- await e.transport.surveyUpdateProgress(attemptId, {
977
- currentQuestionId,
978
- progressSnapshot: snapshot,
1168
+ if (e.resetting || e.resetGeneration !== generation)
1169
+ return;
1170
+ await e.mutations.enqueue('survey-progress', 'survey', {
1171
+ attemptId,
1172
+ body: { currentQuestionId, progressSnapshot: snapshot },
979
1173
  });
1174
+ void flushMutations(e);
980
1175
  }
981
1176
  catch (err) {
982
1177
  // Completion can win a race with an in-flight progress PATCH, making a
@@ -1016,7 +1211,9 @@ export const UserGist = {
1016
1211
  throw new Error('Survey submission was rejected by the server');
1017
1212
  }
1018
1213
  if (outcome === 'deferred') {
1019
- debugLog('survey completion saved locally; delivery deferred', { attemptId });
1214
+ debugLog('survey completion saved locally; delivery deferred', {
1215
+ attemptId,
1216
+ });
1020
1217
  }
1021
1218
  // Once the completion is durably queued, the attempt must not reopen as
1022
1219
  // resumable work. The mutation queue owns delivery from this point.
@@ -1096,7 +1293,9 @@ export const UserGist = {
1096
1293
  submitRequest(title, description, callback) {
1097
1294
  try {
1098
1295
  const e = requireEngine();
1099
- if (typeof title !== 'string' || title.length === 0 || title.length > 120) {
1296
+ if (typeof title !== 'string' ||
1297
+ title.length === 0 ||
1298
+ title.length > 120) {
1100
1299
  callback?.(new Error('title required, max 120 chars'));
1101
1300
  return;
1102
1301
  }
@@ -1382,4 +1581,97 @@ export const UserGist = {
1382
1581
  }
1383
1582
  },
1384
1583
  };
1584
+ /** Registration and invalidation share an ordered lane. Automatic retries may
1585
+ * consume a retained token, but must never recreate an invalidated candidate. */
1586
+ async function registerPushDevice(token, platform, opts, retry = false) {
1587
+ try {
1588
+ if (!token)
1589
+ return;
1590
+ const e = requireEngine();
1591
+ if (e.resetting)
1592
+ return;
1593
+ const generation = e.resetGeneration;
1594
+ await serializePushRegistration(async () => {
1595
+ await ensureHydrated(e);
1596
+ if (e.resetting || generation !== e.resetGeneration)
1597
+ return;
1598
+ const environment = opts?.environment ?? (await UserGistPushNative.defaultEnvironment()) ??
1599
+ (e.config.environment === 'production' ? 'production' : 'sandbox');
1600
+ if (e.resetting || generation !== e.resetGeneration)
1601
+ return;
1602
+ const descriptor = { token, platform, environment };
1603
+ const stored = await e.storage.getJson(STORAGE_KEYS.pushDevice);
1604
+ if (JSON.stringify(stored) !== JSON.stringify(descriptor)) {
1605
+ if (retry)
1606
+ return;
1607
+ await e.storage.setJsonStrict(STORAGE_KEYS.pushDevice, descriptor);
1608
+ }
1609
+ e.pushTokenAvailable = true;
1610
+ pushSubscriptionChanged(e);
1611
+ if (!e.consent.allowsPush())
1612
+ return;
1613
+ await drainRevocations(e);
1614
+ if (await hasPendingRevocations(e) || e.resetting || generation !== e.resetGeneration || !e.consent.allowsPush())
1615
+ return;
1616
+ const id = e.identity.get();
1617
+ const registrationKey = [id.anonymousId, id.externalId ?? '', platform, environment, token].join(':');
1618
+ if (e.lastPushRegistrationKey === registrationKey && Date.now() - e.lastPushRegistrationAt < 24 * 60 * 60_000)
1619
+ return;
1620
+ // Persist before delivery so logout still owns a token if its response is lost.
1621
+ e.lastPushToken = token;
1622
+ await e.storage.setJson(STORAGE_KEYS.pushToken, token);
1623
+ if (e.resetting || generation !== e.resetGeneration || !e.consent.allowsPush())
1624
+ return;
1625
+ const registration = await e.transport.pushRegisterToken({
1626
+ anonymousId: id.anonymousId, externalId: id.externalId ?? null,
1627
+ token, platform, environment, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
1628
+ sdkVersion: `rn-${USERGIST_SDK_VERSION}`, optIn: true,
1629
+ });
1630
+ if (e.resetting || generation !== e.resetGeneration || !e.consent.allowsPush())
1631
+ return;
1632
+ if (!(registration && typeof registration === 'object' && 'registered' in registration && registration.registered === true)) {
1633
+ pushSubscriptionChanged(e);
1634
+ return;
1635
+ }
1636
+ e.lastPushRegistrationKey = registrationKey;
1637
+ e.lastPushRegistrationAt = Date.now();
1638
+ pushSubscriptionChanged(e);
1639
+ debugLog('push token registered with server');
1640
+ });
1641
+ }
1642
+ catch (err) {
1643
+ reportError('registerPushToken failed', err);
1644
+ }
1645
+ }
1646
+ async function invalidatePushDevice(token, retainDevice = false) {
1647
+ try {
1648
+ if (!token)
1649
+ return;
1650
+ const e = requireEngine();
1651
+ const generation = e.resetGeneration;
1652
+ await serializePushRegistration(async () => {
1653
+ await ensureHydrated(e);
1654
+ if (e.resetting || generation !== e.resetGeneration)
1655
+ return;
1656
+ const saved = await e.storage.getJson(STORAGE_KEYS.pushDevice);
1657
+ if (saved?.token === token) {
1658
+ // Retire the automatic retry candidate before invalidating it remotely.
1659
+ if (!retainDevice)
1660
+ await e.storage.setJsonStrict(STORAGE_KEYS.pushDevice, null);
1661
+ e.pushTokenAvailable = retainDevice;
1662
+ e.lastPushRegistrationKey = null;
1663
+ e.lastPushRegistrationAt = 0;
1664
+ }
1665
+ if (e.lastPushToken === token) {
1666
+ await e.storage.setJsonStrict(STORAGE_KEYS.pushToken, null);
1667
+ e.lastPushToken = null;
1668
+ }
1669
+ pushSubscriptionChanged(e);
1670
+ await e.transport.pushInvalidateToken({ anonymousId: e.identity.get().anonymousId, token });
1671
+ });
1672
+ }
1673
+ catch (err) {
1674
+ reportError('invalidatePushToken failed', err);
1675
+ }
1676
+ }
1385
1677
  //# sourceMappingURL=UserGist.js.map