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

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 (61) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +60 -0
  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 +11 -2
  11. package/dist/UserGist.d.ts.map +1 -1
  12. package/dist/UserGist.js +301 -65
  13. package/dist/UserGist.js.map +1 -1
  14. package/dist/internal/context.d.ts +1 -1
  15. package/dist/internal/context.js +1 -1
  16. package/dist/internal/engine.d.ts +10 -2
  17. package/dist/internal/engine.d.ts.map +1 -1
  18. package/dist/internal/engine.js +195 -44
  19. package/dist/internal/engine.js.map +1 -1
  20. package/dist/internal/events.d.ts +6 -1
  21. package/dist/internal/events.d.ts.map +1 -1
  22. package/dist/internal/events.js +39 -18
  23. package/dist/internal/events.js.map +1 -1
  24. package/dist/internal/inapp-rules-cache.d.ts +1 -0
  25. package/dist/internal/inapp-rules-cache.d.ts.map +1 -1
  26. package/dist/internal/inapp-rules-cache.js +7 -0
  27. package/dist/internal/inapp-rules-cache.js.map +1 -1
  28. package/dist/internal/mutation-queue.d.ts +2 -2
  29. package/dist/internal/mutation-queue.d.ts.map +1 -1
  30. package/dist/internal/mutation-queue.js +32 -4
  31. package/dist/internal/mutation-queue.js.map +1 -1
  32. package/dist/internal/rules-cache.d.ts +1 -0
  33. package/dist/internal/rules-cache.d.ts.map +1 -1
  34. package/dist/internal/rules-cache.js +7 -0
  35. package/dist/internal/rules-cache.js.map +1 -1
  36. package/dist/internal/survey-invitations.d.ts +25 -0
  37. package/dist/internal/survey-invitations.d.ts.map +1 -0
  38. package/dist/internal/survey-invitations.js +54 -0
  39. package/dist/internal/survey-invitations.js.map +1 -0
  40. package/dist/internal/survey-matcher.d.ts +1 -0
  41. package/dist/internal/survey-matcher.d.ts.map +1 -1
  42. package/dist/internal/survey-matcher.js +3 -0
  43. package/dist/internal/survey-matcher.js.map +1 -1
  44. package/dist/internal/survey-rules-cache.d.ts +1 -0
  45. package/dist/internal/survey-rules-cache.d.ts.map +1 -1
  46. package/dist/internal/survey-rules-cache.js +7 -0
  47. package/dist/internal/survey-rules-cache.js.map +1 -1
  48. package/dist/internal/survey-store.d.ts +9 -2
  49. package/dist/internal/survey-store.d.ts.map +1 -1
  50. package/dist/internal/survey-store.js +37 -16
  51. package/dist/internal/survey-store.js.map +1 -1
  52. package/dist/internal/transport.d.ts +10 -2
  53. package/dist/internal/transport.d.ts.map +1 -1
  54. package/dist/internal/transport.js +15 -12
  55. package/dist/internal/transport.js.map +1 -1
  56. package/dist/internal/types.d.ts +2 -0
  57. package/dist/internal/types.d.ts.map +1 -1
  58. package/dist/ui/SurveyView.d.ts.map +1 -1
  59. package/dist/ui/SurveyView.js +5 -5
  60. package/dist/ui/SurveyView.js.map +1 -1
  61. package/package.json +2 -2
package/dist/UserGist.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { userPropertiesUpdateSchema } from '@usergist/sdk-core/mobile';
1
2
  import { hydratePushDedupe } from './internal/push-dedupe.js';
2
3
  import { REQUEST_COMMENTED_EVENT_NAME, REQUEST_COMMENT_EDITED_EVENT_NAME, REQUEST_COMMENT_DELETED_EVENT_NAME, } from '@usergist/sdk-core/mobile';
3
4
  import { asEventProps, clearAllState, createEngine, emitAppOpenWhenConsentReady, emitAppVersionChanged, ensureHydrated, enqueueAndEvaluate, flushNow, flushMutations, pollInstructions, refreshTargetingRules, recordServerBackedEventLocally, submitResponse, } from './internal/engine.js';
@@ -7,7 +8,7 @@ import { createSurveyStore } from './internal/survey-store.js';
7
8
  import { UserGistPushNative, onTokenReceived, onNotificationReceived, onNotificationOpened, onNotificationDisplayed, onNotificationDismissed, onTokenError, } from './native/push-bridge.js';
8
9
  import { Push } from './Push.js';
9
10
  import { AppState, Platform } from 'react-native';
10
- import { generateEventId, validateIdentifyTransition } from './internal/identity.js';
11
+ import { generateEventId, validateIdentifyTransition, } from './internal/identity.js';
11
12
  import { configureStorageAdapter, STORAGE_KEYS, } from './internal/storage.js';
12
13
  import { surveyCompletionOutcome } from './internal/survey-completion.js';
13
14
  import { USERGIST_SDK_VERSION } from './internal/context.js';
@@ -21,6 +22,7 @@ function serializePushRegistration(operation) {
21
22
  let runtimeStarted = false;
22
23
  let runtimeStartPromise = null;
23
24
  let resetPromise = null;
25
+ const surveyStarts = new Map();
24
26
  let surveyStore = null;
25
27
  let surveyHandlers = {};
26
28
  let inAppHandlers = {};
@@ -39,8 +41,10 @@ function requestSessionIsCurrent(e) {
39
41
  const generation = e.resetGeneration;
40
42
  const consentVersion = e.consent.get().version;
41
43
  const identity = e.identity.get();
42
- return () => !e.resetting && generation === e.resetGeneration &&
43
- consentVersion === e.consent.get().version && e.consent.allowsFeedback() &&
44
+ return () => !e.resetting &&
45
+ generation === e.resetGeneration &&
46
+ consentVersion === e.consent.get().version &&
47
+ e.consent.allowsFeedback() &&
44
48
  identity.anonymousId === e.identity.get().anonymousId &&
45
49
  identity.externalId === e.identity.get().externalId;
46
50
  }
@@ -49,9 +53,16 @@ async function syncNativePushState(e, push = e.consent.allowsPush()) {
49
53
  const generation = e.resetGeneration;
50
54
  const version = e.consent.get().version;
51
55
  const operation = nativeStateQueue.then(async () => {
52
- if (generation !== e.resetGeneration || anonymousId !== e.identity.get().anonymousId || version !== e.consent.get().version)
56
+ if (generation !== e.resetGeneration ||
57
+ anonymousId !== e.identity.get().anonymousId ||
58
+ version !== e.consent.get().version)
53
59
  return;
54
- await UserGistPushNative.syncState({ writeKey: e.config.writeKey, apiUrl: e.config.apiUrl, anonymousId, push: push && !e.resetting });
60
+ await UserGistPushNative.syncState({
61
+ writeKey: e.config.writeKey,
62
+ apiUrl: e.config.apiUrl,
63
+ anonymousId,
64
+ push: push && !e.resetting,
65
+ });
55
66
  });
56
67
  nativeStateQueue = operation.catch(() => undefined);
57
68
  await operation;
@@ -75,8 +86,10 @@ function requireEngine() {
75
86
  function ensureEngine(config) {
76
87
  if (engine)
77
88
  return engine;
78
- engine = createEngine(config);
79
- surveyStore = createSurveyStore(config.writeKey);
89
+ // A host-owned invitation is not a survey start. Only automatic rendering
90
+ // requests a session bundled with the delivery authorization.
91
+ engine = createEngine(config, () => !surveyHandlers.onInvite);
92
+ surveyStore = createSurveyStore(config.writeKey, () => engine.identity.get());
80
93
  return engine;
81
94
  }
82
95
  async function startRuntime(e) {
@@ -145,7 +158,9 @@ function attachEnginePushListeners() {
145
158
  try {
146
159
  const platform = (p.platform === 'ios' ? 'ios' : 'android');
147
160
  await UserGist.registerPushToken(p.token, platform, {
148
- environment: lastEnablePushOptions?.environment ?? await UserGistPushNative.defaultEnvironment() ?? defaultEnvironment(),
161
+ environment: lastEnablePushOptions?.environment ??
162
+ (await UserGistPushNative.defaultEnvironment()) ??
163
+ defaultEnvironment(),
149
164
  });
150
165
  }
151
166
  catch (err) {
@@ -159,8 +174,8 @@ function attachEnginePushListeners() {
159
174
  onNotificationReceived((p) => {
160
175
  forwardToPushHandler(p, 'received');
161
176
  });
162
- onNotificationDisplayed(p => forwardToPushHandler(p, 'displayed'));
163
- onNotificationDismissed(p => forwardToPushHandler(p, 'dismissed'));
177
+ onNotificationDisplayed((p) => forwardToPushHandler(p, 'displayed'));
178
+ onNotificationDismissed((p) => forwardToPushHandler(p, 'dismissed'));
164
179
  onNotificationOpened((p) => {
165
180
  forwardToPushHandler(p, 'opened');
166
181
  });
@@ -169,7 +184,8 @@ function forwardToPushHandler(p, kind) {
169
184
  if (!engine?.consent.allowsPush() || engine.resetting)
170
185
  return;
171
186
  const data = p.data ?? {};
172
- if (data.usergist_anonymous_id && data.usergist_anonymous_id !== engine.identity.get().anonymousId)
187
+ if (data.usergist_anonymous_id &&
188
+ data.usergist_anonymous_id !== engine.identity.get().anonymousId)
173
189
  return;
174
190
  if (p.deliveryId) {
175
191
  ;
@@ -186,7 +202,10 @@ function forwardToPushHandler(p, kind) {
186
202
  };
187
203
  Object.assign(userInfo, data);
188
204
  if (kind === 'received') {
189
- Push.handleReceived({ userInfo, notification: { title: p.title, body: p.body } });
205
+ Push.handleReceived({
206
+ userInfo,
207
+ notification: { title: p.title, body: p.body },
208
+ });
190
209
  }
191
210
  else if (kind === 'displayed') {
192
211
  Push.handleDisplayed({ userInfo });
@@ -204,7 +223,10 @@ function forwardToPushHandler(p, kind) {
204
223
  dataStr[k] = String(v);
205
224
  }
206
225
  if (kind === 'received') {
207
- Push.handleReceived({ data: dataStr, notification: { title: p.title, body: p.body } });
226
+ Push.handleReceived({
227
+ data: dataStr,
228
+ notification: { title: p.title, body: p.body },
229
+ });
208
230
  }
209
231
  else if (kind === 'displayed') {
210
232
  Push.handleDisplayed({ data: dataStr });
@@ -240,6 +262,11 @@ export const UserGist = {
240
262
  init(config) {
241
263
  void UserGist.initAsync(config);
242
264
  },
265
+ /** Defer campaign UI without interrupting analytics or an active modal. */
266
+ pausePresentation() { engine?.presentation.setPaused(true); },
267
+ /** Call after the host's loaded screen and navigation are ready. */
268
+ resumePresentation() { engine?.presentation.setPaused(false); },
269
+ __internal_presentationGate() { return requireEngine().presentation; },
243
270
  async initAsync(config, initialIdentity) {
244
271
  try {
245
272
  const e = ensureEngine(config);
@@ -274,12 +301,16 @@ export const UserGist = {
274
301
  return 'rejected';
275
302
  const identity = e.identity.get();
276
303
  const pendingIdentity = e.mutations.peek();
277
- const transition = validateIdentifyTransition(identity.externalId, pendingIdentity?.kind === 'identify' ? pendingIdentity.payload.externalId : null, userId);
304
+ const transition = validateIdentifyTransition(identity.externalId, pendingIdentity?.kind === 'identify'
305
+ ? pendingIdentity.payload.externalId
306
+ : null, userId);
278
307
  if (transition === 'reset_required') {
279
308
  reportError('identify rejected: call reset() before switching users');
280
309
  return 'rejected';
281
310
  }
282
- const cleanProps = e.consent.allowsAnalytics() ? asEventProps(properties) : undefined;
311
+ const cleanProps = e.consent.allowsAnalytics()
312
+ ? asEventProps(properties)
313
+ : undefined;
283
314
  if (transition === 'already_identified' &&
284
315
  (!cleanProps || Object.keys(cleanProps).length === 0)) {
285
316
  return 'synced';
@@ -312,6 +343,33 @@ export const UserGist = {
312
343
  return 'rejected';
313
344
  }
314
345
  },
346
+ /** Update the current user, including anonymous users. Durable and retried on reconnect. */
347
+ async setUserProperties(properties, unset = []) {
348
+ try {
349
+ const e = requireEngine();
350
+ const generation = e.resetGeneration;
351
+ await ensureHydrated(e);
352
+ if (e !== engine ||
353
+ e.resetting ||
354
+ e.resetGeneration !== generation ||
355
+ !e.consent.allowsAnalytics())
356
+ return 'rejected';
357
+ const update = userPropertiesUpdateSchema.parse({
358
+ mutationId: generateEventId(),
359
+ set: properties,
360
+ unset: [...unset],
361
+ });
362
+ const mutationId = await e.mutations.enqueue('user-properties', 'analytics', { ...update, anonymousId: e.identity.get().anonymousId });
363
+ const result = await flushMutations(e);
364
+ if (result.permanentlyRejectedIds.has(mutationId))
365
+ return 'rejected';
366
+ return e.mutations.has(mutationId) ? 'queued' : 'synced';
367
+ }
368
+ catch (error) {
369
+ reportError('setUserProperties failed', error);
370
+ return 'rejected';
371
+ }
372
+ },
315
373
  track(eventName, properties) {
316
374
  try {
317
375
  if (typeof eventName !== 'string' || eventName.length === 0) {
@@ -329,6 +387,10 @@ export const UserGist = {
329
387
  async setConsent(purposes) {
330
388
  try {
331
389
  const e = requireEngine();
390
+ if (purposes.feedback === false)
391
+ e.presentation.invalidate('feedback');
392
+ if (purposes.survey === false)
393
+ e.presentation.invalidate('survey');
332
394
  await ensureHydrated(e);
333
395
  if (e.resetting)
334
396
  return false;
@@ -392,6 +454,7 @@ export const UserGist = {
392
454
  return resetPromise ?? Promise.resolve();
393
455
  e.resetting = true;
394
456
  e.resetGeneration += 1;
457
+ e.presentation.invalidate();
395
458
  requestsCache = null;
396
459
  lastEnablePushOptions = null;
397
460
  e.events.emit('resetSurfaces', undefined);
@@ -403,6 +466,7 @@ export const UserGist = {
403
466
  await UserGist.invalidatePushToken(e.lastPushToken);
404
467
  await UserGistPushNative.disablePush().catch(() => undefined);
405
468
  await clearAllState(e);
469
+ await surveyStore?.clear();
406
470
  await hydratePushDedupe(e.config.writeKey, e.identity.get().anonymousId);
407
471
  })();
408
472
  const finalized = operation
@@ -526,10 +590,13 @@ export const UserGist = {
526
590
  return;
527
591
  const generation = e.resetGeneration;
528
592
  await serializePushRegistration(async () => {
529
- if (e.resetting || generation !== e.resetGeneration || !e.consent.allowsPush())
593
+ if (e.resetting ||
594
+ generation !== e.resetGeneration ||
595
+ !e.consent.allowsPush())
530
596
  return;
531
597
  const id = e.identity.get();
532
- const environment = opts?.environment ?? await UserGistPushNative.defaultEnvironment() ??
598
+ const environment = opts?.environment ??
599
+ (await UserGistPushNative.defaultEnvironment()) ??
533
600
  (e.config.environment === 'production' ? 'production' : 'sandbox');
534
601
  const registrationKey = [
535
602
  id.anonymousId,
@@ -541,7 +608,9 @@ export const UserGist = {
541
608
  if (e.lastPushRegistrationKey === registrationKey &&
542
609
  Date.now() - e.lastPushRegistrationAt < 24 * 60 * 60_000)
543
610
  return;
544
- if (e.resetting || generation !== e.resetGeneration || !e.consent.allowsPush())
611
+ if (e.resetting ||
612
+ generation !== e.resetGeneration ||
613
+ !e.consent.allowsPush())
545
614
  return;
546
615
  // Persist before the request so logout can invalidate a token even when
547
616
  // the response is lost or the process exits after the server accepts it.
@@ -603,7 +672,10 @@ export const UserGist = {
603
672
  return;
604
673
  const id = e.identity.get();
605
674
  const token = e.lastPushToken;
606
- if (!token || e.resetting || !e.consent.allowsPush() || id.externalId !== externalId)
675
+ if (!token ||
676
+ e.resetting ||
677
+ !e.consent.allowsPush() ||
678
+ id.externalId !== externalId)
607
679
  return;
608
680
  await e.transport.pushRebind({
609
681
  anonymousId: id.anonymousId,
@@ -703,12 +775,30 @@ export const UserGist = {
703
775
  const e = requireEngine();
704
776
  await ensureHydrated(e);
705
777
  if (!e.consent.allowsPush() || e.resetting)
706
- return { granted: false, status: 'not_determined', platform: Platform.OS === 'ios' ? 'ios' : 'android', error: 'consent_required' };
778
+ return {
779
+ granted: false,
780
+ status: 'not_determined',
781
+ platform: Platform.OS === 'ios' ? 'ios' : 'android',
782
+ error: 'consent_required',
783
+ };
707
784
  const generation = e.resetGeneration;
708
785
  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() };
786
+ const current = () => !e.resetting &&
787
+ generation === e.resetGeneration &&
788
+ consentVersion === e.consent.get().version &&
789
+ e.consent.allowsPush();
790
+ const cancelled = () => ({
791
+ granted: false,
792
+ status: 'not_determined',
793
+ platform: Platform.OS === 'ios' ? 'ios' : 'android',
794
+ error: 'session_changed',
795
+ });
796
+ opts = {
797
+ ...opts,
798
+ environment: opts?.environment ??
799
+ (await UserGistPushNative.defaultEnvironment()) ??
800
+ defaultEnvironment(),
801
+ };
712
802
  await syncNativePushState(e);
713
803
  if (!current())
714
804
  return cancelled();
@@ -731,7 +821,7 @@ export const UserGist = {
731
821
  if (result.granted && result.token) {
732
822
  const platform = (result.platform === 'ios' ? 'ios' : 'android');
733
823
  await UserGist.registerPushToken(result.token, platform, {
734
- environment: opts?.environment ?? (defaultEnvironment()),
824
+ environment: opts?.environment ?? defaultEnvironment(),
735
825
  });
736
826
  }
737
827
  return result;
@@ -811,10 +901,20 @@ export const UserGist = {
811
901
  debugLog('openSurvey: survey consent not granted; ignoring');
812
902
  return;
813
903
  }
904
+ const generation = e.resetGeneration;
905
+ const identity = e.identity.get();
906
+ const consentVersion = e.consent.get().version;
907
+ const invitation = await e.surveyInvitations.find(surveyId);
908
+ if (e.resetting || e.resetGeneration !== generation ||
909
+ !e.consent.allowsSurvey() || e.consent.get().version !== consentVersion ||
910
+ e.identity.get().anonymousId !== identity.anonymousId ||
911
+ e.identity.get().externalId !== identity.externalId)
912
+ return;
814
913
  e.events.emit('showSurvey', {
815
914
  surveyId,
816
- source: context?.source ?? 'on_demand',
915
+ source: context?.source ?? invitation?.source ?? 'on_demand',
817
916
  ...(context?.language ? { language: context.language } : {}),
917
+ ...(invitation ? { survey: invitation.survey, attempt: invitation.attempt } : {}),
818
918
  });
819
919
  }
820
920
  catch (err) {
@@ -832,21 +932,28 @@ export const UserGist = {
832
932
  const e = requireEngine();
833
933
  await ensureHydrated(e);
834
934
  // 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._-]+)/);
935
+ const match = url.match(/\/s\/([A-Za-z0-9._-]+)/) ??
936
+ url.match(/[?&]survey=([A-Za-z0-9._-]+)/);
836
937
  if (!match || !match[1])
837
938
  return false;
838
939
  const token = match[1];
940
+ const presentationIsValid = e.presentation.validator('survey');
839
941
  const id = e.identity.get();
840
942
  const resolved = await e.transport.surveyResolveLink({
841
943
  token,
842
944
  anonymousId: id.anonymousId,
843
945
  externalId: id.externalId ?? null,
844
946
  });
947
+ if (!presentationIsValid() || !e.consent.allowsSurvey())
948
+ return false;
845
949
  if (resolved.consentRequired) {
846
950
  debugLog('survey link resolved but consent required — awaiting consent');
847
951
  return false;
848
952
  }
849
- e.events.emit('showSurvey', { surveyId: resolved.surveyId, source: 'link' });
953
+ e.events.emit('showSurvey', {
954
+ surveyId: resolved.surveyId,
955
+ source: 'link',
956
+ });
850
957
  return true;
851
958
  }
852
959
  catch (err) {
@@ -899,6 +1006,9 @@ export const UserGist = {
899
1006
  reportError('reportSurveyShown failed', err);
900
1007
  }
901
1008
  },
1009
+ __internal_surveyOpenFailed(surveyId) {
1010
+ engine?.surveyMatcher.cancelPending(surveyId);
1011
+ },
902
1012
  __internal_armedSurveyById(surveyId) {
903
1013
  // Local-fire fast-path. If the survey-rules-cache already has the
904
1014
  // full survey content (because the matcher just fired), the
@@ -919,6 +1029,11 @@ export const UserGist = {
919
1029
  const e = requireEngine();
920
1030
  await ensureHydrated(e);
921
1031
  const id = e.identity.get();
1032
+ const pending = await surveyStore?.findForSurvey(surveyId);
1033
+ if (pending?.survey &&
1034
+ Date.now() - pending.startedAt <
1035
+ pending.survey.saveResumeWindowSeconds * 1000)
1036
+ return pending.survey;
922
1037
  return await e.transport.surveyGet({
923
1038
  surveyId,
924
1039
  anonymousId: id.anonymousId,
@@ -931,52 +1046,169 @@ export const UserGist = {
931
1046
  return null;
932
1047
  }
933
1048
  },
934
- async __internal_createAttempt(surveyId, source, language) {
1049
+ async __internal_createAttempt(surveyId, source, language, presentationId, content, preparedAttempt) {
1050
+ const active = requireEngine();
1051
+ const identity = active.identity.get();
1052
+ const key = JSON.stringify([
1053
+ active.resetGeneration,
1054
+ identity.anonymousId,
1055
+ identity.externalId,
1056
+ surveyId,
1057
+ language,
1058
+ presentationId,
1059
+ preparedAttempt?.attemptId,
1060
+ ]);
1061
+ const pending = surveyStarts.get(key);
1062
+ if (pending)
1063
+ return pending;
1064
+ const operation = (async () => {
1065
+ try {
1066
+ const e = requireEngine();
1067
+ await ensureHydrated(e);
1068
+ const id = e.identity.get();
1069
+ const generation = e.resetGeneration;
1070
+ const consentVersion = e.consent.get().version;
1071
+ const current = () => !e.resetting &&
1072
+ e.resetGeneration === generation &&
1073
+ e.consent.allowsSurvey() &&
1074
+ e.consent.get().version === consentVersion &&
1075
+ e.identity.get().anonymousId === id.anonymousId &&
1076
+ e.identity.get().externalId === id.externalId;
1077
+ if (!current())
1078
+ return null;
1079
+ const saved = await surveyStore?.findForSurvey(surveyId);
1080
+ if (!current())
1081
+ return null;
1082
+ // Resuming a durable device session preserves its original content and
1083
+ // answers. Never replace it with a stale network snapshot.
1084
+ if (!preparedAttempt &&
1085
+ (!presentationId || saved?.attemptId === presentationId) &&
1086
+ saved?.survey &&
1087
+ Date.now() - saved.startedAt <
1088
+ saved.survey.saveResumeWindowSeconds * 1000) {
1089
+ if (saved.startRequest)
1090
+ await e.mutations.enqueue('survey-start', 'survey', { surveyId, body: saved.startRequest }, `survey-start:${saved.attemptId}`);
1091
+ if (!current())
1092
+ return null;
1093
+ void flushMutations(e);
1094
+ return {
1095
+ attemptId: saved.attemptId,
1096
+ startQuestionId: saved.survey.flow.startQuestionId,
1097
+ currentQuestionId: saved.currentQuestionId,
1098
+ snapshot: saved.snapshot,
1099
+ resumed: true,
1100
+ resolvedContent: saved.survey,
1101
+ };
1102
+ }
1103
+ const armed = !language && !presentationId
1104
+ ? e.surveyRules.getById(surveyId)
1105
+ : null;
1106
+ const grant = armed?.localStart &&
1107
+ Date.parse(armed.localStart.expiresAt) > Date.now()
1108
+ ? armed.localStart
1109
+ : null;
1110
+ const body = {
1111
+ presentationId,
1112
+ anonymousId: id.anonymousId,
1113
+ externalId: id.externalId ?? null,
1114
+ source,
1115
+ ...(language ? { language } : {}),
1116
+ sdkVersion: `rn-${USERGIST_SDK_VERSION}`,
1117
+ platform: e.context.platform(),
1118
+ ...(presentationId || grant || source === 'triggered' || source === 'scheduled'
1119
+ ? { resume: false, clientAttemptId: presentationId ?? generateEventId() }
1120
+ : { resume: true }),
1121
+ ...(grant
1122
+ ? {
1123
+ localStart: {
1124
+ token: grant.token,
1125
+ startedAt: new Date().toISOString(),
1126
+ },
1127
+ }
1128
+ : {}),
1129
+ };
1130
+ const res = preparedAttempt ??
1131
+ (grant && armed
1132
+ ? {
1133
+ attemptId: body.clientAttemptId,
1134
+ startQuestionId: armed.survey.flow.startQuestionId,
1135
+ currentQuestionId: armed.survey.flow.startQuestionId,
1136
+ progressSnapshot: {},
1137
+ resumed: false,
1138
+ resolvedContent: armed.survey,
1139
+ }
1140
+ : await e.transport.surveyCreateAttempt(surveyId, body));
1141
+ if (!current())
1142
+ return null;
1143
+ await surveyStore?.upsert({
1144
+ anonymousId: id.anonymousId,
1145
+ externalId: id.externalId,
1146
+ survey: res.resolvedContent ?? content,
1147
+ ...(grant && !preparedAttempt ? { startRequest: body } : {}),
1148
+ surveyId,
1149
+ attemptId: res.attemptId,
1150
+ startedAt: Date.now(),
1151
+ currentQuestionId: res.currentQuestionId,
1152
+ snapshot: (res.progressSnapshot ??
1153
+ {}),
1154
+ language: language ?? null,
1155
+ });
1156
+ if (!current())
1157
+ return null;
1158
+ if (presentationId) {
1159
+ await e.surveyInvitations.remove(presentationId);
1160
+ if (!current())
1161
+ return null;
1162
+ }
1163
+ if (grant && !preparedAttempt) {
1164
+ await e.mutations.enqueue('survey-start', 'survey', { surveyId, body }, `survey-start:${res.attemptId}`);
1165
+ if (!current())
1166
+ return null;
1167
+ void flushMutations(e);
1168
+ }
1169
+ return {
1170
+ attemptId: res.attemptId,
1171
+ startQuestionId: res.startQuestionId,
1172
+ currentQuestionId: res.currentQuestionId,
1173
+ snapshot: (res.progressSnapshot ??
1174
+ {}),
1175
+ resumed: res.resumed,
1176
+ resolvedContent: res.resolvedContent,
1177
+ };
1178
+ }
1179
+ catch (err) {
1180
+ reportError('createAttempt failed', err);
1181
+ return null;
1182
+ }
1183
+ })();
1184
+ surveyStarts.set(key, operation);
935
1185
  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
- };
1186
+ return await operation;
964
1187
  }
965
- catch (err) {
966
- reportError('createAttempt failed', err);
967
- return null;
1188
+ finally {
1189
+ if (surveyStarts.get(key) === operation)
1190
+ surveyStarts.delete(key);
968
1191
  }
969
1192
  },
970
1193
  async __internal_saveProgress(attemptId, currentQuestionId, snapshot) {
971
1194
  // Persist the resume point before attempting the network. Progress is
972
1195
  // best-effort transport; losing connectivity must not lose local answers.
1196
+ const e = requireEngine();
1197
+ if (e.resetting || !e.consent.allowsSurvey())
1198
+ return;
1199
+ const generation = e.resetGeneration;
1200
+ const pending = (await surveyStore?.list())?.find((a) => a.attemptId === attemptId);
1201
+ if (!pending || e.resetting || e.resetGeneration !== generation)
1202
+ return;
973
1203
  await surveyStore?.updateProgress(attemptId, currentQuestionId, snapshot);
974
1204
  try {
975
- const e = requireEngine();
976
- await e.transport.surveyUpdateProgress(attemptId, {
977
- currentQuestionId,
978
- progressSnapshot: snapshot,
1205
+ if (e.resetting || e.resetGeneration !== generation)
1206
+ return;
1207
+ await e.mutations.enqueue('survey-progress', 'survey', {
1208
+ attemptId,
1209
+ body: { currentQuestionId, progressSnapshot: snapshot },
979
1210
  });
1211
+ void flushMutations(e);
980
1212
  }
981
1213
  catch (err) {
982
1214
  // Completion can win a race with an in-flight progress PATCH, making a
@@ -1016,7 +1248,9 @@ export const UserGist = {
1016
1248
  throw new Error('Survey submission was rejected by the server');
1017
1249
  }
1018
1250
  if (outcome === 'deferred') {
1019
- debugLog('survey completion saved locally; delivery deferred', { attemptId });
1251
+ debugLog('survey completion saved locally; delivery deferred', {
1252
+ attemptId,
1253
+ });
1020
1254
  }
1021
1255
  // Once the completion is durably queued, the attempt must not reopen as
1022
1256
  // resumable work. The mutation queue owns delivery from this point.
@@ -1096,7 +1330,9 @@ export const UserGist = {
1096
1330
  submitRequest(title, description, callback) {
1097
1331
  try {
1098
1332
  const e = requireEngine();
1099
- if (typeof title !== 'string' || title.length === 0 || title.length > 120) {
1333
+ if (typeof title !== 'string' ||
1334
+ title.length === 0 ||
1335
+ title.length > 120) {
1100
1336
  callback?.(new Error('title required, max 120 chars'));
1101
1337
  return;
1102
1338
  }