@usergist/feedback-react-native 0.1.1 → 0.1.4

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 (50) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +14 -1
  3. package/dist/Provider.d.ts.map +1 -1
  4. package/dist/Provider.js +29 -12
  5. package/dist/Provider.js.map +1 -1
  6. package/dist/UserGist.d.ts +14 -0
  7. package/dist/UserGist.d.ts.map +1 -1
  8. package/dist/UserGist.js +320 -104
  9. package/dist/UserGist.js.map +1 -1
  10. package/dist/index.d.ts +2 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/internal/context.d.ts +1 -1
  13. package/dist/internal/context.js +1 -1
  14. package/dist/internal/engine.d.ts +8 -0
  15. package/dist/internal/engine.d.ts.map +1 -1
  16. package/dist/internal/engine.js +116 -39
  17. package/dist/internal/engine.js.map +1 -1
  18. package/dist/internal/events.d.ts +7 -1
  19. package/dist/internal/events.d.ts.map +1 -1
  20. package/dist/internal/events.js +43 -16
  21. package/dist/internal/events.js.map +1 -1
  22. package/dist/internal/identity-lifecycle.d.ts +16 -0
  23. package/dist/internal/identity-lifecycle.d.ts.map +1 -0
  24. package/dist/internal/identity-lifecycle.js +96 -0
  25. package/dist/internal/identity-lifecycle.js.map +1 -0
  26. package/dist/internal/mutation-queue.d.ts +2 -2
  27. package/dist/internal/mutation-queue.d.ts.map +1 -1
  28. package/dist/internal/mutation-queue.js +19 -2
  29. package/dist/internal/mutation-queue.js.map +1 -1
  30. package/dist/internal/push-subscription.d.ts +11 -0
  31. package/dist/internal/push-subscription.d.ts.map +1 -0
  32. package/dist/internal/push-subscription.js +20 -0
  33. package/dist/internal/push-subscription.js.map +1 -0
  34. package/dist/internal/session-revocations.d.ts +5 -0
  35. package/dist/internal/session-revocations.d.ts.map +1 -0
  36. package/dist/internal/session-revocations.js +58 -0
  37. package/dist/internal/session-revocations.js.map +1 -0
  38. package/dist/internal/storage.d.ts +3 -0
  39. package/dist/internal/storage.d.ts.map +1 -1
  40. package/dist/internal/storage.js +76 -60
  41. package/dist/internal/storage.js.map +1 -1
  42. package/dist/internal/transport.d.ts +14 -2
  43. package/dist/internal/transport.d.ts.map +1 -1
  44. package/dist/internal/transport.js +31 -14
  45. package/dist/internal/transport.js.map +1 -1
  46. package/dist/internal/user-state.d.ts +2 -1
  47. package/dist/internal/user-state.d.ts.map +1 -1
  48. package/dist/internal/user-state.js +3 -2
  49. package/dist/internal/user-state.js.map +1 -1
  50. package/package.json +2 -2
package/dist/UserGist.js CHANGED
@@ -1,12 +1,7 @@
1
- // UserGist — public singleton facade.
2
- //
3
- // Philosophy:
4
- // - `init()` remains synchronous and backwards compatible. `initAsync()` can
5
- // bind a server-proven identity before any lifecycle event is emitted.
6
- // - All public methods catch their own errors and report via debug. The
7
- // SDK never throws across its boundary.
8
- // - Track is synchronous for the caller: it enqueues, schedules a flush,
9
- // and runs the trigger matcher — zero awaits.
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';
10
5
  import { REQUEST_COMMENTED_EVENT_NAME, REQUEST_COMMENT_EDITED_EVENT_NAME, REQUEST_COMMENT_DELETED_EVENT_NAME, } from '@usergist/sdk-core/mobile';
11
6
  import { asEventProps, clearAllState, createEngine, emitAppOpenWhenConsentReady, emitAppVersionChanged, ensureHydrated, enqueueAndEvaluate, flushNow, flushMutations, pollInstructions, refreshTargetingRules, recordServerBackedEventLocally, submitResponse, } from './internal/engine.js';
12
7
  import { setDebugEnabled, reportError, debugLog, logTrace, getTraces, setDiagnosticHandler, } from './internal/debug.js';
@@ -16,10 +11,16 @@ import { UserGistPushNative, onTokenReceived, onNotificationReceived, onNotifica
16
11
  import { Push } from './Push.js';
17
12
  import { AppState, Platform } from 'react-native';
18
13
  import { generateEventId, validateIdentifyTransition } from './internal/identity.js';
19
- import { configureStorageAdapter, } from './internal/storage.js';
14
+ import { STORAGE_KEYS, configureStorageAdapter, } from './internal/storage.js';
20
15
  import { surveyCompletionOutcome } from './internal/survey-completion.js';
21
16
  import { USERGIST_SDK_VERSION } from './internal/context.js';
22
17
  let engine = null;
18
+ let pushRegistrationQueue = Promise.resolve();
19
+ function serializePushRegistration(operation) {
20
+ const task = pushRegistrationQueue.then(operation);
21
+ pushRegistrationQueue = task.catch(() => undefined);
22
+ return task;
23
+ }
23
24
  let runtimeStarted = false;
24
25
  let runtimeStartPromise = null;
25
26
  let resetPromise = null;
@@ -37,6 +38,17 @@ function ensureRequestsCache() {
37
38
  }
38
39
  return requestsCache;
39
40
  }
41
+ function requestSessionIsCurrent(e) {
42
+ const generation = e.resetGeneration;
43
+ const consentVersion = e.consent.get().version;
44
+ const identity = e.identity.get();
45
+ return () => !e.resetting &&
46
+ generation === e.resetGeneration &&
47
+ consentVersion === e.consent.get().version &&
48
+ e.consent.allowsFeedback() &&
49
+ identity.anonymousId === e.identity.get().anonymousId &&
50
+ identity.externalId === e.identity.get().externalId;
51
+ }
40
52
  function requireEngine() {
41
53
  if (!engine) {
42
54
  throw new Error('UserGist.init must be called before using the SDK');
@@ -47,6 +59,20 @@ function ensureEngine(config) {
47
59
  if (engine)
48
60
  return engine;
49
61
  engine = createEngine(config);
62
+ const pushEngine = engine;
63
+ pushEngine.retryPushRegistration = async () => {
64
+ if (pushEngine.resetting)
65
+ return;
66
+ const saved = await pushEngine.storage.getJson(STORAGE_KEYS.pushDevice);
67
+ if (!saved)
68
+ return;
69
+ pushEngine.pushTokenAvailable = true;
70
+ if (pushEngine !== engine || pushEngine.resetting)
71
+ return;
72
+ pushSubscriptionChanged(pushEngine);
73
+ if (pushEngine.consent.allowsPush())
74
+ await registerPushDevice(saved.token, saved.platform, { environment: saved.environment }, true);
75
+ };
50
76
  surveyStore = createSurveyStore(config.writeKey);
51
77
  return engine;
52
78
  }
@@ -135,6 +161,10 @@ function attachEnginePushListeners() {
135
161
  });
136
162
  }
137
163
  function forwardToPushHandler(p, kind) {
164
+ if (!engine?.consent.allowsPush() || engine.resetting)
165
+ return;
166
+ if (p.data?.usergist_anonymous_id && p.data.usergist_anonymous_id !== engine.identity.get().anonymousId)
167
+ return;
138
168
  const data = p.data ?? {};
139
169
  if (p.deliveryId) {
140
170
  ;
@@ -193,6 +223,11 @@ export const UserGist = {
193
223
  init(config) {
194
224
  void UserGist.initAsync(config);
195
225
  },
226
+ /** Defer campaign UI without stopping analytics or closing active UI. */
227
+ pausePresentation() { engine?.presentation.setPaused(true); },
228
+ /** Call after startup loading and navigation have completed. */
229
+ resumePresentation() { engine?.presentation.setPaused(false); },
230
+ __internal_presentationGate() { return requireEngine().presentation; },
196
231
  async initAsync(config, initialIdentity) {
197
232
  try {
198
233
  const e = ensureEngine(config);
@@ -210,6 +245,10 @@ export const UserGist = {
210
245
  return 'rejected';
211
246
  }
212
247
  },
248
+ setPushSubscriptionStateHandler,
249
+ setIdentityStateHandler,
250
+ setSubjectTokenProvider,
251
+ getIdentityState,
213
252
  identify(userId, properties, subjectToken) {
214
253
  void UserGist.identifyAsync(userId, properties, subjectToken);
215
254
  },
@@ -220,7 +259,10 @@ export const UserGist = {
220
259
  return 'rejected';
221
260
  }
222
261
  const e = requireEngine();
262
+ const generation = e.resetGeneration;
223
263
  await ensureHydrated(e);
264
+ if (e.resetting || e.resetGeneration !== generation)
265
+ return 'rejected';
224
266
  const identity = e.identity.get();
225
267
  const pendingIdentity = e.mutations.peek();
226
268
  const transition = validateIdentifyTransition(identity.externalId, pendingIdentity?.kind === 'identify' ? pendingIdentity.payload.externalId : null, userId);
@@ -228,8 +270,13 @@ export const UserGist = {
228
270
  reportError('identify rejected: call reset() before switching users');
229
271
  return 'rejected';
230
272
  }
231
- const cleanProps = e.consent.allowsAnalytics() ? asEventProps(properties) : undefined;
273
+ const cleanProps = e.consent.allowsAnalytics()
274
+ ? (properties && Object.keys(properties).length
275
+ ? userPropertiesUpdateSchema.parse({ mutationId: generateEventId(), set: properties }).set
276
+ : undefined)
277
+ : undefined;
232
278
  if (transition === 'already_identified' &&
279
+ (!subjectToken || subjectToken === e.subjectToken) &&
233
280
  (!cleanProps || Object.keys(cleanProps).length === 0)) {
234
281
  return 'synced';
235
282
  }
@@ -237,6 +284,7 @@ export const UserGist = {
237
284
  reportError('identify requires a server-minted subject token');
238
285
  return 'rejected';
239
286
  }
287
+ identityChanged(e, 'identifying');
240
288
  const mutationId = await e.mutations.enqueue('identify', 'essential', {
241
289
  subjectToken,
242
290
  anonymousId: identity.anonymousId,
@@ -244,7 +292,10 @@ export const UserGist = {
244
292
  ...(cleanProps ? { properties: cleanProps } : {}),
245
293
  }, `identify:${userId}`);
246
294
  const result = await flushMutations(e);
295
+ if (e.resetting || e.resetGeneration !== generation)
296
+ return 'rejected';
247
297
  if (result.permanentlyRejectedIds.has(mutationId)) {
298
+ identityChanged(e, 'rejected');
248
299
  reportError('identify was rejected by the server');
249
300
  return 'rejected';
250
301
  }
@@ -260,6 +311,33 @@ export const UserGist = {
260
311
  return 'rejected';
261
312
  }
262
313
  },
314
+ /** Update the current user, including anonymous users. Durable and retried on reconnect. */
315
+ async setUserProperties(properties, unset = []) {
316
+ try {
317
+ const e = requireEngine();
318
+ const generation = e.resetGeneration;
319
+ await ensureHydrated(e);
320
+ if (e !== engine ||
321
+ e.resetting ||
322
+ e.resetGeneration !== generation ||
323
+ !e.consent.allowsAnalytics())
324
+ return 'rejected';
325
+ const update = userPropertiesUpdateSchema.parse({
326
+ mutationId: generateEventId(),
327
+ set: properties,
328
+ unset: [...unset],
329
+ });
330
+ const mutationId = await e.mutations.enqueue('user-properties', 'analytics', { ...update, anonymousId: e.identity.get().anonymousId });
331
+ const result = await flushMutations(e);
332
+ if (result.permanentlyRejectedIds.has(mutationId))
333
+ return 'rejected';
334
+ return e.mutations.has(mutationId) ? 'queued' : 'synced';
335
+ }
336
+ catch (error) {
337
+ reportError('setUserProperties failed', error);
338
+ return 'rejected';
339
+ }
340
+ },
263
341
  track(eventName, properties) {
264
342
  try {
265
343
  if (typeof eventName !== 'string' || eventName.length === 0) {
@@ -277,8 +355,34 @@ export const UserGist = {
277
355
  async setConsent(purposes) {
278
356
  try {
279
357
  const e = requireEngine();
358
+ if (purposes.feedback === false)
359
+ e.presentation.invalidate('feedback');
360
+ if (purposes.survey === false)
361
+ e.presentation.invalidate('survey');
280
362
  await ensureHydrated(e);
363
+ if (e.resetting)
364
+ return false;
281
365
  const next = await e.consent.set(purposes);
366
+ if (e.resetting || next.version !== e.consent.get().version)
367
+ return false;
368
+ if (!next.feedback) {
369
+ requestsCache = null;
370
+ e.events.emit('dismissRequests', undefined);
371
+ }
372
+ if (e.resetting || next.version !== e.consent.get().version)
373
+ return false;
374
+ if (purposes.push === false) {
375
+ lastEnablePushOptions = null;
376
+ await pushRegistrationQueue;
377
+ if (e.resetting || next.version !== e.consent.get().version)
378
+ return false;
379
+ if (e.lastPushToken)
380
+ await invalidatePushDevice(e.lastPushToken, true);
381
+ if (!e.consent.allowsPush())
382
+ await UserGistPushNative.disablePush();
383
+ }
384
+ if (e.resetting || next.version !== e.consent.get().version)
385
+ return false;
282
386
  const id = e.identity.get();
283
387
  try {
284
388
  await e.transport.consent({
@@ -298,6 +402,9 @@ export const UserGist = {
298
402
  reportError('transport.consent failed', err);
299
403
  return false;
300
404
  }
405
+ pushSubscriptionChanged(e);
406
+ if (next.push)
407
+ void e.retryPushRegistration?.().catch(error => reportError('push registration deferred', error));
301
408
  if (next.feedback || next.survey) {
302
409
  await refreshTargetingRules(e, true);
303
410
  }
@@ -314,22 +421,27 @@ export const UserGist = {
314
421
  reset() {
315
422
  try {
316
423
  const e = requireEngine();
317
- if (e.resetting)
318
- return resetPromise ?? Promise.resolve();
424
+ if (resetPromise)
425
+ return resetPromise;
319
426
  e.resetting = true;
427
+ identityChanged(e, 'resetting');
428
+ pushSubscriptionChanged(e);
429
+ lastEnablePushOptions = null;
320
430
  e.resetGeneration += 1;
431
+ e.transport.cancelAll();
432
+ cancelIdentityRecovery(e);
433
+ e.presentation.invalidate();
321
434
  e.events.emit('resetSurfaces', undefined);
322
435
  const operation = (async () => {
323
436
  await ensureHydrated(e);
324
- if (e.lastPushToken)
325
- await UserGist.invalidatePushToken(e.lastPushToken);
326
- await UserGistPushNative.disablePush().catch(() => undefined);
437
+ void UserGistPushNative.disablePush().catch(() => undefined);
327
438
  await clearAllState(e);
439
+ requestsCache = null;
328
440
  })();
329
441
  const finalized = operation
330
- .catch((err) => reportError('reset failed', err))
442
+ .then(() => { e.resetting = false; pushSubscriptionChanged(e); })
443
+ .catch((err) => { identityChanged(e, 'reset-failed'); reportError('reset failed', err); throw err; })
331
444
  .finally(() => {
332
- e.resetting = false;
333
445
  if (resetPromise === finalized)
334
446
  resetPromise = null;
335
447
  });
@@ -438,67 +550,10 @@ export const UserGist = {
438
550
  }
439
551
  },
440
552
  async registerPushToken(token, platform, opts) {
441
- try {
442
- if (!token)
443
- return;
444
- const e = requireEngine();
445
- await ensureHydrated(e);
446
- if (!e.consent.allowsPush())
447
- return;
448
- // Consent is enforced server-side (the register-token route reads
449
- // consent_log and refuses if push was explicitly declined). We no
450
- // longer short-circuit here because the host app typically calls
451
- // setConsent and registerPushToken in parallel; racing the local
452
- // consent hydration is not our problem to solve.
453
- const id = e.identity.get();
454
- const environment = opts?.environment ??
455
- (e.config.environment === 'production' ? 'production' : 'sandbox');
456
- const registrationKey = [
457
- id.anonymousId,
458
- id.externalId ?? '',
459
- platform,
460
- environment,
461
- token,
462
- ].join(':');
463
- if (e.lastPushRegistrationKey === registrationKey &&
464
- Date.now() - e.lastPushRegistrationAt < 24 * 60 * 60_000)
465
- return;
466
- await e.transport.pushRegisterToken({
467
- anonymousId: id.anonymousId,
468
- externalId: id.externalId ?? null,
469
- token,
470
- platform,
471
- environment,
472
- language: undefined,
473
- timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
474
- appVersion: undefined,
475
- sdkVersion: `rn-${USERGIST_SDK_VERSION}`,
476
- optIn: true,
477
- });
478
- e.lastPushToken = token;
479
- e.lastPushRegistrationKey = registrationKey;
480
- e.lastPushRegistrationAt = Date.now();
481
- debugLog('push token registered with server');
482
- }
483
- catch (err) {
484
- reportError('registerPushToken failed', err);
485
- }
553
+ await registerPushDevice(token, platform, opts);
486
554
  },
487
555
  async invalidatePushToken(token) {
488
- try {
489
- if (!token)
490
- return;
491
- const e = requireEngine();
492
- await ensureHydrated(e);
493
- const id = e.identity.get();
494
- await e.transport.pushInvalidateToken({
495
- anonymousId: id.anonymousId,
496
- token,
497
- });
498
- }
499
- catch (err) {
500
- reportError('invalidatePushToken failed', err);
501
- }
556
+ await invalidatePushDevice(token);
502
557
  },
503
558
  /**
504
559
  * Re-bind the latest registered device token to a newly-identified user.
@@ -608,6 +663,34 @@ export const UserGist = {
608
663
  // react-native-onesignal, WonderPush, or any other push SDK.
609
664
  async enablePush(opts) {
610
665
  try {
666
+ const e = requireEngine();
667
+ await ensureHydrated(e);
668
+ if (!e.consent.allowsPush() || e.resetting)
669
+ return {
670
+ granted: false,
671
+ status: 'not_determined',
672
+ platform: Platform.OS === 'ios' ? 'ios' : 'android',
673
+ error: 'consent_required',
674
+ };
675
+ const generation = e.resetGeneration;
676
+ const consentVersion = e.consent.get().version;
677
+ const current = () => !e.resetting &&
678
+ generation === e.resetGeneration &&
679
+ consentVersion === e.consent.get().version &&
680
+ e.consent.allowsPush();
681
+ const cancelled = () => ({
682
+ granted: false,
683
+ status: 'not_determined',
684
+ platform: Platform.OS === 'ios' ? 'ios' : 'android',
685
+ error: 'session_changed',
686
+ });
687
+ opts = {
688
+ ...opts,
689
+ environment: opts?.environment ??
690
+ defaultEnvironment(),
691
+ };
692
+ if (!current())
693
+ return cancelled();
611
694
  if (!enginePushListenersAttached)
612
695
  attachEnginePushListeners();
613
696
  // Snapshot the caller's options so the AppState foreground listener
@@ -618,12 +701,16 @@ export const UserGist = {
618
701
  installDelegateProxy: opts?.installDelegateProxy,
619
702
  };
620
703
  const result = await UserGistPushNative.enablePush(opts ?? {});
704
+ // Reset/withdrawal owns native cleanup. An old permission callback must
705
+ // not disable a new account's subsequently enabled notification adapter.
706
+ if (!current())
707
+ return cancelled();
621
708
  // Server-side registration runs on the tokenReceived event; if the
622
709
  // native module already had a cached token it surfaces here.
623
710
  if (result.granted && result.token) {
624
711
  const platform = (result.platform === 'ios' ? 'ios' : 'android');
625
712
  await UserGist.registerPushToken(result.token, platform, {
626
- environment: opts?.environment ?? (defaultEnvironment()),
713
+ environment: opts?.environment ?? defaultEnvironment(),
627
714
  });
628
715
  }
629
716
  return result;
@@ -632,23 +719,19 @@ export const UserGist = {
632
719
  reportError('enablePush failed', err);
633
720
  return {
634
721
  granted: false,
635
- status: 'denied',
636
- platform: (typeof navigator === 'object' && navigator.product === 'ReactNative' ? 'ios' : 'android'),
722
+ status: 'not_determined',
723
+ error: 'push_setup_failed',
724
+ platform: Platform.OS === 'ios' ? 'ios' : 'android',
637
725
  };
638
726
  }
639
727
  },
640
728
  async disablePush() {
729
+ lastEnablePushOptions = null;
641
730
  try {
642
- const e = engine;
643
- if (e?.lastPushToken)
644
- await UserGist.invalidatePushToken(e.lastPushToken);
645
- await UserGistPushNative.disablePush();
646
- if (e) {
647
- e.lastPushToken = null;
648
- e.lastPushRegistrationKey = null;
649
- e.lastPushRegistrationAt = 0;
650
- void UserGist.setConsent({ push: false });
651
- }
731
+ if (engine)
732
+ await UserGist.setConsent({ push: false });
733
+ else
734
+ await UserGistPushNative.disablePush();
652
735
  }
653
736
  catch (err) {
654
737
  reportError('disablePush failed', err);
@@ -732,12 +815,15 @@ export const UserGist = {
732
815
  if (!match || !match[1])
733
816
  return false;
734
817
  const token = match[1];
818
+ const presentationIsValid = e.presentation.validator('survey');
735
819
  const id = e.identity.get();
736
820
  const resolved = await e.transport.surveyResolveLink({
737
821
  token,
738
822
  anonymousId: id.anonymousId,
739
823
  externalId: id.externalId ?? null,
740
824
  });
825
+ if (!presentationIsValid() || !e.consent.allowsSurvey())
826
+ return false;
741
827
  if (resolved.consentRequired) {
742
828
  debugLog('survey link resolved but consent required — awaiting consent');
743
829
  return false;
@@ -949,6 +1035,9 @@ export const UserGist = {
949
1035
  try {
950
1036
  const e = requireEngine();
951
1037
  await ensureHydrated(e);
1038
+ if (e.resetting || !e.consent.allowsFeedback())
1039
+ return { items: [], nextCursor: null };
1040
+ const current = requestSessionIsCurrent(e);
952
1041
  const id = e.identity.get();
953
1042
  const result = await e.transport.requestsList({
954
1043
  anonymousId: id.anonymousId,
@@ -960,6 +1049,8 @@ export const UserGist = {
960
1049
  cursor: options.cursor ?? null,
961
1050
  limit: options.limit,
962
1051
  });
1052
+ if (!current())
1053
+ return { items: [], nextCursor: null };
963
1054
  ensureRequestsCache().upsertList(result.items.map((s) => ({
964
1055
  id: s.id,
965
1056
  appId: '',
@@ -987,7 +1078,9 @@ export const UserGist = {
987
1078
  submitRequest(title, description, callback) {
988
1079
  try {
989
1080
  const e = requireEngine();
990
- if (typeof title !== 'string' || title.length === 0 || title.length > 120) {
1081
+ if (typeof title !== 'string' ||
1082
+ title.length === 0 ||
1083
+ title.length > 120) {
991
1084
  callback?.(new Error('title required, max 120 chars'));
992
1085
  return;
993
1086
  }
@@ -1000,10 +1093,11 @@ export const UserGist = {
1000
1093
  void (async () => {
1001
1094
  try {
1002
1095
  await ensureHydrated(e);
1003
- if (!e.consent.allowsFeedback()) {
1096
+ if (e.resetting || !e.consent.allowsFeedback()) {
1004
1097
  callback?.(new Error('feedback consent required'));
1005
1098
  return;
1006
1099
  }
1100
+ const current = requestSessionIsCurrent(e);
1007
1101
  const id = e.identity.get();
1008
1102
  const req = await e.transport.requestSubmit({
1009
1103
  idempotencyKey: generateEventId(),
@@ -1012,6 +1106,10 @@ export const UserGist = {
1012
1106
  title,
1013
1107
  description,
1014
1108
  });
1109
+ if (!current()) {
1110
+ callback?.(new Error('Request cancelled by identity or consent change'));
1111
+ return;
1112
+ }
1015
1113
  ensureRequestsCache().upsert(req);
1016
1114
  recordServerBackedEventLocally(e, '$request_submitted');
1017
1115
  requestsHandlers.onSubmit?.(req);
@@ -1031,11 +1129,12 @@ export const UserGist = {
1031
1129
  try {
1032
1130
  const e = requireEngine();
1033
1131
  await ensureHydrated(e);
1034
- if (!e.consent.allowsFeedback())
1132
+ if (e.resetting || !e.consent.allowsFeedback())
1035
1133
  return;
1036
1134
  const cache = ensureRequestsCache();
1037
1135
  const rollback = cache.applyOptimisticVote(requestId, vote);
1038
1136
  try {
1137
+ const current = requestSessionIsCurrent(e);
1039
1138
  const id = e.identity.get();
1040
1139
  const result = await e.transport.requestVote({
1041
1140
  requestId,
@@ -1043,6 +1142,10 @@ export const UserGist = {
1043
1142
  externalId: id.externalId ?? null,
1044
1143
  vote,
1045
1144
  });
1145
+ if (!current()) {
1146
+ rollback();
1147
+ return;
1148
+ }
1046
1149
  cache.commitVote(requestId, result);
1047
1150
  recordServerBackedEventLocally(e, vote ? '$request_upvoted' : '$request_unupvoted');
1048
1151
  requestsHandlers.onVote?.(result);
@@ -1060,11 +1163,12 @@ export const UserGist = {
1060
1163
  try {
1061
1164
  const e = requireEngine();
1062
1165
  await ensureHydrated(e);
1063
- if (!e.consent.allowsFeedback())
1166
+ if (e.resetting || !e.consent.allowsFeedback())
1064
1167
  return;
1065
1168
  const cache = ensureRequestsCache();
1066
1169
  const rollback = cache.applyOptimisticFollow(requestId, follow);
1067
1170
  try {
1171
+ const current = requestSessionIsCurrent(e);
1068
1172
  const id = e.identity.get();
1069
1173
  const result = await e.transport.requestFollow({
1070
1174
  requestId,
@@ -1072,6 +1176,10 @@ export const UserGist = {
1072
1176
  externalId: id.externalId ?? null,
1073
1177
  follow,
1074
1178
  });
1179
+ if (!current()) {
1180
+ rollback();
1181
+ return;
1182
+ }
1075
1183
  cache.commitFollow(requestId, result);
1076
1184
  recordServerBackedEventLocally(e, follow ? '$request_followed' : '$request_unfollowed');
1077
1185
  requestsHandlers.onFollow?.(result);
@@ -1095,9 +1203,11 @@ export const UserGist = {
1095
1203
  try {
1096
1204
  const e = requireEngine();
1097
1205
  await ensureHydrated(e);
1098
- if (!e.consent.allowsFeedback())
1206
+ if (e.resetting || !e.consent.allowsFeedback())
1099
1207
  return null;
1100
- return await e.transport.requestBranding();
1208
+ const current = requestSessionIsCurrent(e);
1209
+ const branding = await e.transport.requestBranding();
1210
+ return current() ? branding : null;
1101
1211
  }
1102
1212
  catch (err) {
1103
1213
  reportError('getRequestBranding failed', err);
@@ -1114,14 +1224,17 @@ export const UserGist = {
1114
1224
  try {
1115
1225
  const e = requireEngine();
1116
1226
  await ensureHydrated(e);
1117
- if (!e.consent.allowsFeedback())
1227
+ if (e.resetting || !e.consent.allowsFeedback())
1118
1228
  return null;
1229
+ const current = requestSessionIsCurrent(e);
1119
1230
  const id = e.identity.get();
1120
1231
  const req = await e.transport.requestGet({
1121
1232
  requestId,
1122
1233
  anonymousId: id.anonymousId,
1123
1234
  externalId: id.externalId ?? null,
1124
1235
  });
1236
+ if (!current())
1237
+ return null;
1125
1238
  ensureRequestsCache().upsert(req);
1126
1239
  return req;
1127
1240
  }
@@ -1133,7 +1246,7 @@ export const UserGist = {
1133
1246
  openRequestsBoard() {
1134
1247
  try {
1135
1248
  const e = requireEngine();
1136
- if (!e.consent.allowsFeedback())
1249
+ if (e.resetting || !e.consent.allowsFeedback())
1137
1250
  return;
1138
1251
  e.events.emit('showRequestsBoard', undefined);
1139
1252
  }
@@ -1144,7 +1257,7 @@ export const UserGist = {
1144
1257
  openRequestDetail(requestId) {
1145
1258
  try {
1146
1259
  const e = requireEngine();
1147
- if (!e.consent.allowsFeedback())
1260
+ if (e.resetting || !e.consent.allowsFeedback())
1148
1261
  return;
1149
1262
  e.events.emit('showRequestDetail', { requestId });
1150
1263
  }
@@ -1161,15 +1274,16 @@ export const UserGist = {
1161
1274
  try {
1162
1275
  const e = requireEngine();
1163
1276
  await ensureHydrated(e);
1164
- if (!e.consent.allowsFeedback())
1277
+ if (e.resetting || !e.consent.allowsFeedback())
1165
1278
  return [];
1279
+ const current = requestSessionIsCurrent(e);
1166
1280
  const id = e.identity.get();
1167
1281
  const result = await e.transport.requestCommentsList({
1168
1282
  requestId,
1169
1283
  anonymousId: id.anonymousId,
1170
1284
  externalId: id.externalId ?? null,
1171
1285
  });
1172
- return result.items;
1286
+ return current() ? result.items : [];
1173
1287
  }
1174
1288
  catch (err) {
1175
1289
  reportError('getComments failed', err);
@@ -1182,8 +1296,9 @@ export const UserGist = {
1182
1296
  const e = requireEngine();
1183
1297
  validateCommentBody(body);
1184
1298
  await ensureHydrated(e);
1185
- if (!e.consent.allowsFeedback())
1299
+ if (e.resetting || !e.consent.allowsFeedback())
1186
1300
  return null;
1301
+ const current = requestSessionIsCurrent(e);
1187
1302
  const id = e.identity.get();
1188
1303
  const comment = await e.transport.requestCommentPost({
1189
1304
  idempotencyKey: generateEventId(),
@@ -1192,6 +1307,8 @@ export const UserGist = {
1192
1307
  externalId: id.externalId ?? null,
1193
1308
  body,
1194
1309
  });
1310
+ if (!current())
1311
+ return null;
1195
1312
  recordServerBackedEventLocally(e, REQUEST_COMMENTED_EVENT_NAME);
1196
1313
  return comment;
1197
1314
  }
@@ -1206,8 +1323,9 @@ export const UserGist = {
1206
1323
  const e = requireEngine();
1207
1324
  validateCommentBody(body);
1208
1325
  await ensureHydrated(e);
1209
- if (!e.consent.allowsFeedback())
1326
+ if (e.resetting || !e.consent.allowsFeedback())
1210
1327
  return null;
1328
+ const current = requestSessionIsCurrent(e);
1211
1329
  const id = e.identity.get();
1212
1330
  const comment = await e.transport.requestCommentEdit({
1213
1331
  requestId,
@@ -1215,6 +1333,8 @@ export const UserGist = {
1215
1333
  anonymousId: id.anonymousId,
1216
1334
  body,
1217
1335
  });
1336
+ if (!current())
1337
+ return null;
1218
1338
  recordServerBackedEventLocally(e, REQUEST_COMMENT_EDITED_EVENT_NAME);
1219
1339
  return comment;
1220
1340
  }
@@ -1228,14 +1348,17 @@ export const UserGist = {
1228
1348
  try {
1229
1349
  const e = requireEngine();
1230
1350
  await ensureHydrated(e);
1231
- if (!e.consent.allowsFeedback())
1351
+ if (e.resetting || !e.consent.allowsFeedback())
1232
1352
  return;
1353
+ const current = requestSessionIsCurrent(e);
1233
1354
  const id = e.identity.get();
1234
1355
  await e.transport.requestCommentDelete({
1235
1356
  requestId,
1236
1357
  commentId,
1237
1358
  anonymousId: id.anonymousId,
1238
1359
  });
1360
+ if (!current())
1361
+ return;
1239
1362
  recordServerBackedEventLocally(e, REQUEST_COMMENT_DELETED_EVENT_NAME);
1240
1363
  }
1241
1364
  catch (err) {
@@ -1243,4 +1366,97 @@ export const UserGist = {
1243
1366
  }
1244
1367
  },
1245
1368
  };
1369
+ /** Registration and invalidation share an ordered lane. Automatic retries may
1370
+ * consume a retained token, but must never recreate an invalidated candidate. */
1371
+ async function registerPushDevice(token, platform, opts, retry = false) {
1372
+ try {
1373
+ if (!token)
1374
+ return;
1375
+ const e = requireEngine();
1376
+ if (e.resetting)
1377
+ return;
1378
+ const generation = e.resetGeneration;
1379
+ await serializePushRegistration(async () => {
1380
+ await ensureHydrated(e);
1381
+ if (e.resetting || generation !== e.resetGeneration)
1382
+ return;
1383
+ const environment = opts?.environment ??
1384
+ (e.config.environment === 'production' ? 'production' : 'sandbox');
1385
+ if (e.resetting || generation !== e.resetGeneration)
1386
+ return;
1387
+ const descriptor = { token, platform, environment };
1388
+ const stored = await e.storage.getJson(STORAGE_KEYS.pushDevice);
1389
+ if (JSON.stringify(stored) !== JSON.stringify(descriptor)) {
1390
+ if (retry)
1391
+ return;
1392
+ await e.storage.setJsonStrict(STORAGE_KEYS.pushDevice, descriptor);
1393
+ }
1394
+ e.pushTokenAvailable = true;
1395
+ pushSubscriptionChanged(e);
1396
+ if (!e.consent.allowsPush())
1397
+ return;
1398
+ await drainRevocations(e);
1399
+ if (await hasPendingRevocations(e) || e.resetting || generation !== e.resetGeneration || !e.consent.allowsPush())
1400
+ return;
1401
+ const id = e.identity.get();
1402
+ const registrationKey = [id.anonymousId, id.externalId ?? '', platform, environment, token].join(':');
1403
+ if (e.lastPushRegistrationKey === registrationKey && Date.now() - e.lastPushRegistrationAt < 24 * 60 * 60_000)
1404
+ return;
1405
+ // Persist before delivery so logout still owns a token if its response is lost.
1406
+ e.lastPushToken = token;
1407
+ await e.storage.setJson(STORAGE_KEYS.pushToken, token);
1408
+ if (e.resetting || generation !== e.resetGeneration || !e.consent.allowsPush())
1409
+ return;
1410
+ const registration = await e.transport.pushRegisterToken({
1411
+ anonymousId: id.anonymousId, externalId: id.externalId ?? null,
1412
+ token, platform, environment, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
1413
+ sdkVersion: `rn-${USERGIST_SDK_VERSION}`, optIn: true,
1414
+ });
1415
+ if (e.resetting || generation !== e.resetGeneration || !e.consent.allowsPush())
1416
+ return;
1417
+ if (!(registration && typeof registration === 'object' && 'registered' in registration && registration.registered === true)) {
1418
+ pushSubscriptionChanged(e);
1419
+ return;
1420
+ }
1421
+ e.lastPushRegistrationKey = registrationKey;
1422
+ e.lastPushRegistrationAt = Date.now();
1423
+ pushSubscriptionChanged(e);
1424
+ debugLog('push token registered with server');
1425
+ });
1426
+ }
1427
+ catch (err) {
1428
+ reportError('registerPushToken failed', err);
1429
+ }
1430
+ }
1431
+ async function invalidatePushDevice(token, retainDevice = false) {
1432
+ try {
1433
+ if (!token)
1434
+ return;
1435
+ const e = requireEngine();
1436
+ const generation = e.resetGeneration;
1437
+ await serializePushRegistration(async () => {
1438
+ await ensureHydrated(e);
1439
+ if (e.resetting || generation !== e.resetGeneration)
1440
+ return;
1441
+ const saved = await e.storage.getJson(STORAGE_KEYS.pushDevice);
1442
+ if (saved?.token === token) {
1443
+ // Retire the automatic retry candidate before invalidating it remotely.
1444
+ if (!retainDevice)
1445
+ await e.storage.setJsonStrict(STORAGE_KEYS.pushDevice, null);
1446
+ e.pushTokenAvailable = retainDevice;
1447
+ e.lastPushRegistrationKey = null;
1448
+ e.lastPushRegistrationAt = 0;
1449
+ }
1450
+ if (e.lastPushToken === token) {
1451
+ await e.storage.setJsonStrict(STORAGE_KEYS.pushToken, null);
1452
+ e.lastPushToken = null;
1453
+ }
1454
+ pushSubscriptionChanged(e);
1455
+ await e.transport.pushInvalidateToken({ anonymousId: e.identity.get().anonymousId, token });
1456
+ });
1457
+ }
1458
+ catch (err) {
1459
+ reportError('invalidatePushToken failed', err);
1460
+ }
1461
+ }
1246
1462
  //# sourceMappingURL=UserGist.js.map