@usergist/feedback-react-native 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (242) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/LICENSE +21 -0
  3. package/README.md +231 -0
  4. package/UserGistFeedback.podspec +31 -0
  5. package/UserGistFeedbackExtension.podspec +23 -0
  6. package/android/build.gradle +92 -0
  7. package/android/consumer-rules.pro +5 -0
  8. package/android/src/main/AndroidManifest.xml +31 -0
  9. package/android/src/main/java/studio/usergist/feedback/UserGistFirebaseMessagingService.kt +153 -0
  10. package/android/src/main/java/studio/usergist/feedback/UserGistPushModule.kt +344 -0
  11. package/android/src/main/java/studio/usergist/feedback/UserGistPushPackage.kt +18 -0
  12. package/dist/NativeUserGistPush.d.ts +35 -0
  13. package/dist/NativeUserGistPush.d.ts.map +1 -0
  14. package/dist/NativeUserGistPush.js +13 -0
  15. package/dist/NativeUserGistPush.js.map +1 -0
  16. package/dist/Provider.d.ts +7 -0
  17. package/dist/Provider.d.ts.map +1 -0
  18. package/dist/Provider.js +300 -0
  19. package/dist/Provider.js.map +1 -0
  20. package/dist/Push.d.ts +103 -0
  21. package/dist/Push.d.ts.map +1 -0
  22. package/dist/Push.js +225 -0
  23. package/dist/Push.js.map +1 -0
  24. package/dist/UserGist.d.ts +210 -0
  25. package/dist/UserGist.d.ts.map +1 -0
  26. package/dist/UserGist.js +1246 -0
  27. package/dist/UserGist.js.map +1 -0
  28. package/dist/index.d.ts +11 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +4 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/internal/consent.d.ts +17 -0
  33. package/dist/internal/consent.d.ts.map +1 -0
  34. package/dist/internal/consent.js +98 -0
  35. package/dist/internal/consent.js.map +1 -0
  36. package/dist/internal/context.d.ts +12 -0
  37. package/dist/internal/context.d.ts.map +1 -0
  38. package/dist/internal/context.js +70 -0
  39. package/dist/internal/context.js.map +1 -0
  40. package/dist/internal/debug.d.ts +14 -0
  41. package/dist/internal/debug.d.ts.map +1 -0
  42. package/dist/internal/debug.js +73 -0
  43. package/dist/internal/debug.js.map +1 -0
  44. package/dist/internal/engine.d.ts +93 -0
  45. package/dist/internal/engine.d.ts.map +1 -0
  46. package/dist/internal/engine.js +904 -0
  47. package/dist/internal/engine.js.map +1 -0
  48. package/dist/internal/events.d.ts +49 -0
  49. package/dist/internal/events.d.ts.map +1 -0
  50. package/dist/internal/events.js +71 -0
  51. package/dist/internal/events.js.map +1 -0
  52. package/dist/internal/frequency-cap.d.ts +15 -0
  53. package/dist/internal/frequency-cap.d.ts.map +1 -0
  54. package/dist/internal/frequency-cap.js +84 -0
  55. package/dist/internal/frequency-cap.js.map +1 -0
  56. package/dist/internal/identity.d.ts +14 -0
  57. package/dist/internal/identity.d.ts.map +1 -0
  58. package/dist/internal/identity.js +113 -0
  59. package/dist/internal/identity.js.map +1 -0
  60. package/dist/internal/inapp-matcher.d.ts +13 -0
  61. package/dist/internal/inapp-matcher.d.ts.map +1 -0
  62. package/dist/internal/inapp-matcher.js +65 -0
  63. package/dist/internal/inapp-matcher.js.map +1 -0
  64. package/dist/internal/inapp-rules-cache.d.ts +16 -0
  65. package/dist/internal/inapp-rules-cache.d.ts.map +1 -0
  66. package/dist/internal/inapp-rules-cache.js +88 -0
  67. package/dist/internal/inapp-rules-cache.js.map +1 -0
  68. package/dist/internal/instruction-dedupe.d.ts +16 -0
  69. package/dist/internal/instruction-dedupe.d.ts.map +1 -0
  70. package/dist/internal/instruction-dedupe.js +52 -0
  71. package/dist/internal/instruction-dedupe.js.map +1 -0
  72. package/dist/internal/lifecycle.d.ts +12 -0
  73. package/dist/internal/lifecycle.d.ts.map +1 -0
  74. package/dist/internal/lifecycle.js +97 -0
  75. package/dist/internal/lifecycle.js.map +1 -0
  76. package/dist/internal/modal-coordinator.d.ts +4 -0
  77. package/dist/internal/modal-coordinator.d.ts.map +1 -0
  78. package/dist/internal/modal-coordinator.js +43 -0
  79. package/dist/internal/modal-coordinator.js.map +1 -0
  80. package/dist/internal/modal-queue.d.ts +9 -0
  81. package/dist/internal/modal-queue.d.ts.map +1 -0
  82. package/dist/internal/modal-queue.js +32 -0
  83. package/dist/internal/modal-queue.js.map +1 -0
  84. package/dist/internal/mutation-queue.d.ts +23 -0
  85. package/dist/internal/mutation-queue.d.ts.map +1 -0
  86. package/dist/internal/mutation-queue.js +66 -0
  87. package/dist/internal/mutation-queue.js.map +1 -0
  88. package/dist/internal/prompt-answers.d.ts +6 -0
  89. package/dist/internal/prompt-answers.d.ts.map +1 -0
  90. package/dist/internal/prompt-answers.js +10 -0
  91. package/dist/internal/prompt-answers.js.map +1 -0
  92. package/dist/internal/prompt-flow.d.ts +8 -0
  93. package/dist/internal/prompt-flow.d.ts.map +1 -0
  94. package/dist/internal/prompt-flow.js +26 -0
  95. package/dist/internal/prompt-flow.js.map +1 -0
  96. package/dist/internal/queue.d.ts +17 -0
  97. package/dist/internal/queue.d.ts.map +1 -0
  98. package/dist/internal/queue.js +172 -0
  99. package/dist/internal/queue.js.map +1 -0
  100. package/dist/internal/requests.d.ts +22 -0
  101. package/dist/internal/requests.d.ts.map +1 -0
  102. package/dist/internal/requests.js +146 -0
  103. package/dist/internal/requests.js.map +1 -0
  104. package/dist/internal/rules-cache.d.ts +16 -0
  105. package/dist/internal/rules-cache.d.ts.map +1 -0
  106. package/dist/internal/rules-cache.js +86 -0
  107. package/dist/internal/rules-cache.js.map +1 -0
  108. package/dist/internal/storage.d.ts +36 -0
  109. package/dist/internal/storage.d.ts.map +1 -0
  110. package/dist/internal/storage.js +213 -0
  111. package/dist/internal/storage.js.map +1 -0
  112. package/dist/internal/survey-completion.d.ts +10 -0
  113. package/dist/internal/survey-completion.d.ts.map +1 -0
  114. package/dist/internal/survey-completion.js +11 -0
  115. package/dist/internal/survey-completion.js.map +1 -0
  116. package/dist/internal/survey-matcher.d.ts +21 -0
  117. package/dist/internal/survey-matcher.d.ts.map +1 -0
  118. package/dist/internal/survey-matcher.js +139 -0
  119. package/dist/internal/survey-matcher.js.map +1 -0
  120. package/dist/internal/survey-rules-cache.d.ts +17 -0
  121. package/dist/internal/survey-rules-cache.d.ts.map +1 -0
  122. package/dist/internal/survey-rules-cache.js +91 -0
  123. package/dist/internal/survey-rules-cache.js.map +1 -0
  124. package/dist/internal/survey-store.d.ts +19 -0
  125. package/dist/internal/survey-store.d.ts.map +1 -0
  126. package/dist/internal/survey-store.js +59 -0
  127. package/dist/internal/survey-store.js.map +1 -0
  128. package/dist/internal/transport.d.ts +210 -0
  129. package/dist/internal/transport.d.ts.map +1 -0
  130. package/dist/internal/transport.js +498 -0
  131. package/dist/internal/transport.js.map +1 -0
  132. package/dist/internal/trigger-matcher.d.ts +21 -0
  133. package/dist/internal/trigger-matcher.d.ts.map +1 -0
  134. package/dist/internal/trigger-matcher.js +114 -0
  135. package/dist/internal/trigger-matcher.js.map +1 -0
  136. package/dist/internal/types.d.ts +89 -0
  137. package/dist/internal/types.d.ts.map +1 -0
  138. package/dist/internal/types.js +2 -0
  139. package/dist/internal/types.js.map +1 -0
  140. package/dist/internal/user-state.d.ts +11 -0
  141. package/dist/internal/user-state.d.ts.map +1 -0
  142. package/dist/internal/user-state.js +118 -0
  143. package/dist/internal/user-state.js.map +1 -0
  144. package/dist/native/events.d.ts +27 -0
  145. package/dist/native/events.d.ts.map +1 -0
  146. package/dist/native/events.js +12 -0
  147. package/dist/native/events.js.map +1 -0
  148. package/dist/native/push-bridge.d.ts +20 -0
  149. package/dist/native/push-bridge.d.ts.map +1 -0
  150. package/dist/native/push-bridge.js +92 -0
  151. package/dist/native/push-bridge.js.map +1 -0
  152. package/dist/preview/PromptShell.d.ts +9 -0
  153. package/dist/preview/PromptShell.d.ts.map +1 -0
  154. package/dist/preview/PromptShell.js +128 -0
  155. package/dist/preview/PromptShell.js.map +1 -0
  156. package/dist/preview/SurveyShell.d.ts +15 -0
  157. package/dist/preview/SurveyShell.d.ts.map +1 -0
  158. package/dist/preview/SurveyShell.js +197 -0
  159. package/dist/preview/SurveyShell.js.map +1 -0
  160. package/dist/preview/index.d.ts +4 -0
  161. package/dist/preview/index.d.ts.map +1 -0
  162. package/dist/preview/index.js +16 -0
  163. package/dist/preview/index.js.map +1 -0
  164. package/dist/ui/InAppMessageView.d.ts +12 -0
  165. package/dist/ui/InAppMessageView.d.ts.map +1 -0
  166. package/dist/ui/InAppMessageView.js +260 -0
  167. package/dist/ui/InAppMessageView.js.map +1 -0
  168. package/dist/ui/PromptSheet.d.ts +12 -0
  169. package/dist/ui/PromptSheet.d.ts.map +1 -0
  170. package/dist/ui/PromptSheet.js +285 -0
  171. package/dist/ui/PromptSheet.js.map +1 -0
  172. package/dist/ui/QuestionImageHeader.d.ts +12 -0
  173. package/dist/ui/QuestionImageHeader.d.ts.map +1 -0
  174. package/dist/ui/QuestionImageHeader.js +22 -0
  175. package/dist/ui/QuestionImageHeader.js.map +1 -0
  176. package/dist/ui/SurveyView.d.ts +25 -0
  177. package/dist/ui/SurveyView.d.ts.map +1 -0
  178. package/dist/ui/SurveyView.js +478 -0
  179. package/dist/ui/SurveyView.js.map +1 -0
  180. package/dist/ui/animations.d.ts +4 -0
  181. package/dist/ui/animations.d.ts.map +1 -0
  182. package/dist/ui/animations.js +24 -0
  183. package/dist/ui/animations.js.map +1 -0
  184. package/dist/ui/questions/MultipleChoiceQuestion.d.ts +12 -0
  185. package/dist/ui/questions/MultipleChoiceQuestion.d.ts.map +1 -0
  186. package/dist/ui/questions/MultipleChoiceQuestion.js +41 -0
  187. package/dist/ui/questions/MultipleChoiceQuestion.js.map +1 -0
  188. package/dist/ui/questions/NpsQuestion.d.ts +14 -0
  189. package/dist/ui/questions/NpsQuestion.d.ts.map +1 -0
  190. package/dist/ui/questions/NpsQuestion.js +73 -0
  191. package/dist/ui/questions/NpsQuestion.js.map +1 -0
  192. package/dist/ui/questions/RatingQuestion.d.ts +21 -0
  193. package/dist/ui/questions/RatingQuestion.d.ts.map +1 -0
  194. package/dist/ui/questions/RatingQuestion.js +91 -0
  195. package/dist/ui/questions/RatingQuestion.js.map +1 -0
  196. package/dist/ui/questions/ShortTextQuestion.d.ts +12 -0
  197. package/dist/ui/questions/ShortTextQuestion.d.ts.map +1 -0
  198. package/dist/ui/questions/ShortTextQuestion.js +25 -0
  199. package/dist/ui/questions/ShortTextQuestion.js.map +1 -0
  200. package/dist/ui/requests/BoardView.d.ts +9 -0
  201. package/dist/ui/requests/BoardView.d.ts.map +1 -0
  202. package/dist/ui/requests/BoardView.js +270 -0
  203. package/dist/ui/requests/BoardView.js.map +1 -0
  204. package/dist/ui/requests/CommentsSection.d.ts +7 -0
  205. package/dist/ui/requests/CommentsSection.d.ts.map +1 -0
  206. package/dist/ui/requests/CommentsSection.js +254 -0
  207. package/dist/ui/requests/CommentsSection.js.map +1 -0
  208. package/dist/ui/requests/DetailView.d.ts +8 -0
  209. package/dist/ui/requests/DetailView.d.ts.map +1 -0
  210. package/dist/ui/requests/DetailView.js +206 -0
  211. package/dist/ui/requests/DetailView.js.map +1 -0
  212. package/dist/ui/requests/RequestsHost.d.ts +12 -0
  213. package/dist/ui/requests/RequestsHost.d.ts.map +1 -0
  214. package/dist/ui/requests/RequestsHost.js +58 -0
  215. package/dist/ui/requests/RequestsHost.js.map +1 -0
  216. package/dist/ui/requests/StatusPill.d.ts +6 -0
  217. package/dist/ui/requests/StatusPill.d.ts.map +1 -0
  218. package/dist/ui/requests/StatusPill.js +25 -0
  219. package/dist/ui/requests/StatusPill.js.map +1 -0
  220. package/dist/ui/requests/SubmitView.d.ts +8 -0
  221. package/dist/ui/requests/SubmitView.d.ts.map +1 -0
  222. package/dist/ui/requests/SubmitView.js +94 -0
  223. package/dist/ui/requests/SubmitView.js.map +1 -0
  224. package/dist/ui/requests/shared.d.ts +21 -0
  225. package/dist/ui/requests/shared.d.ts.map +1 -0
  226. package/dist/ui/requests/shared.js +74 -0
  227. package/dist/ui/requests/shared.js.map +1 -0
  228. package/dist/ui/survey-questions.d.ts +20 -0
  229. package/dist/ui/survey-questions.d.ts.map +1 -0
  230. package/dist/ui/survey-questions.js +211 -0
  231. package/dist/ui/survey-questions.js.map +1 -0
  232. package/dist/ui/theme.d.ts +15 -0
  233. package/dist/ui/theme.d.ts.map +1 -0
  234. package/dist/ui/theme.js +31 -0
  235. package/dist/ui/theme.js.map +1 -0
  236. package/ios/Extension/UserGistNotificationService.swift +292 -0
  237. package/ios/UserGistPush.h +16 -0
  238. package/ios/UserGistPush.mm +158 -0
  239. package/ios/UserGistPushImpl.swift +399 -0
  240. package/ios/UserGistPushSwizzle.m +168 -0
  241. package/package.json +89 -0
  242. package/react-native.config.js +16 -0
@@ -0,0 +1,1246 @@
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.
10
+ import { REQUEST_COMMENTED_EVENT_NAME, REQUEST_COMMENT_EDITED_EVENT_NAME, REQUEST_COMMENT_DELETED_EVENT_NAME, } from '@usergist/sdk-core/mobile';
11
+ import { asEventProps, clearAllState, createEngine, emitAppOpenWhenConsentReady, emitAppVersionChanged, ensureHydrated, enqueueAndEvaluate, flushNow, flushMutations, pollInstructions, refreshTargetingRules, recordServerBackedEventLocally, submitResponse, } from './internal/engine.js';
12
+ import { setDebugEnabled, reportError, debugLog, logTrace, getTraces, setDiagnosticHandler, } from './internal/debug.js';
13
+ import { DEFAULT_THEME, mergeTheme } from './ui/theme.js';
14
+ import { createSurveyStore } from './internal/survey-store.js';
15
+ import { UserGistPushNative, onTokenReceived, onNotificationReceived, onNotificationOpened, onTokenError, } from './native/push-bridge.js';
16
+ import { Push } from './Push.js';
17
+ import { AppState, Platform } from 'react-native';
18
+ import { generateEventId, validateIdentifyTransition } from './internal/identity.js';
19
+ import { configureStorageAdapter, } from './internal/storage.js';
20
+ import { surveyCompletionOutcome } from './internal/survey-completion.js';
21
+ import { USERGIST_SDK_VERSION } from './internal/context.js';
22
+ let engine = null;
23
+ let runtimeStarted = false;
24
+ let runtimeStartPromise = null;
25
+ let resetPromise = null;
26
+ let surveyStore = null;
27
+ let surveyHandlers = {};
28
+ let inAppHandlers = {};
29
+ let requestsHandlers = {};
30
+ let requestsCache = null;
31
+ function ensureRequestsCache() {
32
+ if (!requestsCache) {
33
+ // Lazy-imported to keep cold-start fast for apps that don't use requests.
34
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
35
+ const mod = require('./internal/requests.js');
36
+ requestsCache = mod.createRequestsCache();
37
+ }
38
+ return requestsCache;
39
+ }
40
+ function requireEngine() {
41
+ if (!engine) {
42
+ throw new Error('UserGist.init must be called before using the SDK');
43
+ }
44
+ return engine;
45
+ }
46
+ function ensureEngine(config) {
47
+ if (engine)
48
+ return engine;
49
+ engine = createEngine(config);
50
+ surveyStore = createSurveyStore(config.writeKey);
51
+ return engine;
52
+ }
53
+ async function startRuntime(e) {
54
+ if (runtimeStarted)
55
+ return;
56
+ if (runtimeStartPromise)
57
+ return runtimeStartPromise;
58
+ runtimeStarted = true;
59
+ e.lifecycle.start();
60
+ runtimeStartPromise = (async () => {
61
+ await refreshTargetingRules(e);
62
+ await emitAppVersionChanged(e);
63
+ emitAppOpenWhenConsentReady(e);
64
+ void pollInstructions(e);
65
+ })().finally(() => {
66
+ runtimeStartPromise = null;
67
+ });
68
+ return runtimeStartPromise;
69
+ }
70
+ function validateCommentBody(body) {
71
+ if (typeof body !== 'string' ||
72
+ body.trim().length === 0 ||
73
+ body.length > 1000) {
74
+ throw new Error('comment body required, max 1000 chars');
75
+ }
76
+ }
77
+ // ---------- Native push listener wiring (Phase 7) ----------
78
+ let enginePushListenersAttached = false;
79
+ let lastEnablePushOptions = null;
80
+ let lastForegroundPushRefreshAt = 0;
81
+ const FOREGROUND_PUSH_REFRESH_INTERVAL_MS = 5 * 60_000;
82
+ function defaultEnvironment() {
83
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
84
+ const dev = globalThis.__DEV__;
85
+ return dev ? 'sandbox' : 'production';
86
+ }
87
+ function attachEnginePushListeners() {
88
+ if (enginePushListenersAttached)
89
+ return;
90
+ enginePushListenersAttached = true;
91
+ // Re-check the push state on every app foreground transition.
92
+ //
93
+ // Why: iOS / Android both let the user flip notification permission in
94
+ // Settings without re-launching the app. Token rotation also happens
95
+ // out-of-band. By re-running enablePush() each time the app becomes
96
+ // active, we catch both. The native side caches its decision so this
97
+ // does NOT re-prompt the user — it just walks the (permission → token →
98
+ // server-register) chain idempotently. No-op if enablePush has never
99
+ // been called.
100
+ AppState.addEventListener('change', (state) => {
101
+ if (state !== 'active')
102
+ return;
103
+ if (!lastEnablePushOptions)
104
+ return;
105
+ // This lightweight beacon lets the reachability worker skip unnecessary
106
+ // silent pushes. Token/permission refresh remains throttled separately.
107
+ void UserGist.pushAppOpen();
108
+ const now = Date.now();
109
+ if (now - lastForegroundPushRefreshAt < FOREGROUND_PUSH_REFRESH_INTERVAL_MS)
110
+ return;
111
+ lastForegroundPushRefreshAt = now;
112
+ void UserGist.enablePush(lastEnablePushOptions).catch(() => undefined);
113
+ });
114
+ onTokenReceived((p) => {
115
+ void (async () => {
116
+ try {
117
+ const platform = (p.platform === 'ios' ? 'ios' : 'android');
118
+ await UserGist.registerPushToken(p.token, platform, {
119
+ environment: lastEnablePushOptions?.environment ?? defaultEnvironment(),
120
+ });
121
+ }
122
+ catch (err) {
123
+ reportError('tokenReceived registration failed', err);
124
+ }
125
+ })();
126
+ });
127
+ onTokenError((p) => {
128
+ debugLog('push token error', p.error);
129
+ });
130
+ onNotificationReceived((p) => {
131
+ forwardToPushHandler(p, 'received');
132
+ });
133
+ onNotificationOpened((p) => {
134
+ forwardToPushHandler(p, 'opened');
135
+ });
136
+ }
137
+ function forwardToPushHandler(p, kind) {
138
+ const data = p.data ?? {};
139
+ if (p.deliveryId) {
140
+ ;
141
+ data.usergist_delivery_id = p.deliveryId;
142
+ }
143
+ if (Platform.OS === 'ios') {
144
+ const userInfo = {
145
+ aps: {
146
+ alert: { title: p.title, body: p.body },
147
+ },
148
+ usergist: {
149
+ deliveryId: p.deliveryId,
150
+ },
151
+ };
152
+ Object.assign(userInfo, data);
153
+ if (kind === 'received') {
154
+ Push.handleReceived({ userInfo, notification: { title: p.title, body: p.body } });
155
+ }
156
+ else {
157
+ Push.handleOpened({ userInfo, actionIdentifier: p.actionIdentifier });
158
+ }
159
+ }
160
+ else {
161
+ const dataStr = {};
162
+ for (const [k, v] of Object.entries(data)) {
163
+ dataStr[k] = String(v);
164
+ }
165
+ if (kind === 'received') {
166
+ Push.handleReceived({ data: dataStr, notification: { title: p.title, body: p.body } });
167
+ }
168
+ else {
169
+ Push.handleOpened({ data: dataStr, actionIdentifier: p.actionIdentifier });
170
+ }
171
+ }
172
+ }
173
+ function trackInternal(name, props, purpose = 'analytics') {
174
+ if (!engine)
175
+ return;
176
+ enqueueAndEvaluate(engine, name, props, purpose);
177
+ }
178
+ export const UserGist = {
179
+ /**
180
+ * Replace AsyncStorage with a host-provided encrypted adapter. Call before
181
+ * `init()`; changing persistence while the SDK is running is rejected.
182
+ */
183
+ setStorageAdapter(adapter) {
184
+ if (engine) {
185
+ reportError('setStorageAdapter must be called before init');
186
+ return;
187
+ }
188
+ configureStorageAdapter(adapter);
189
+ },
190
+ setDiagnosticHandler(handler) {
191
+ setDiagnosticHandler(handler);
192
+ },
193
+ init(config) {
194
+ void UserGist.initAsync(config);
195
+ },
196
+ async initAsync(config, initialIdentity) {
197
+ try {
198
+ const e = ensureEngine(config);
199
+ await ensureHydrated(e);
200
+ if (initialIdentity) {
201
+ const result = await UserGist.identifyAsync(initialIdentity.userId, initialIdentity.properties, initialIdentity.subjectToken);
202
+ if (result !== 'synced')
203
+ return result;
204
+ }
205
+ await startRuntime(e);
206
+ return 'synced';
207
+ }
208
+ catch (error) {
209
+ reportError('initAsync failed', error);
210
+ return 'rejected';
211
+ }
212
+ },
213
+ identify(userId, properties, subjectToken) {
214
+ void UserGist.identifyAsync(userId, properties, subjectToken);
215
+ },
216
+ async identifyAsync(userId, properties, subjectToken) {
217
+ try {
218
+ if (typeof userId !== 'string' || userId.length === 0) {
219
+ reportError('identify requires a userId string');
220
+ return 'rejected';
221
+ }
222
+ const e = requireEngine();
223
+ await ensureHydrated(e);
224
+ const identity = e.identity.get();
225
+ const pendingIdentity = e.mutations.peek();
226
+ const transition = validateIdentifyTransition(identity.externalId, pendingIdentity?.kind === 'identify' ? pendingIdentity.payload.externalId : null, userId);
227
+ if (transition === 'reset_required') {
228
+ reportError('identify rejected: call reset() before switching users');
229
+ return 'rejected';
230
+ }
231
+ const cleanProps = e.consent.allowsAnalytics() ? asEventProps(properties) : undefined;
232
+ if (transition === 'already_identified' &&
233
+ (!cleanProps || Object.keys(cleanProps).length === 0)) {
234
+ return 'synced';
235
+ }
236
+ if (!subjectToken || !subjectToken.startsWith('st_')) {
237
+ reportError('identify requires a server-minted subject token');
238
+ return 'rejected';
239
+ }
240
+ const mutationId = await e.mutations.enqueue('identify', 'essential', {
241
+ subjectToken,
242
+ anonymousId: identity.anonymousId,
243
+ externalId: userId,
244
+ ...(cleanProps ? { properties: cleanProps } : {}),
245
+ }, `identify:${userId}`);
246
+ const result = await flushMutations(e);
247
+ if (result.permanentlyRejectedIds.has(mutationId)) {
248
+ reportError('identify was rejected by the server');
249
+ return 'rejected';
250
+ }
251
+ if (e.mutations.has(mutationId)) {
252
+ debugLog('identify is saved locally and pending retry');
253
+ return 'queued';
254
+ }
255
+ void UserGist.rebindPushToken(userId);
256
+ return 'synced';
257
+ }
258
+ catch (err) {
259
+ reportError('identify failed', err);
260
+ return 'rejected';
261
+ }
262
+ },
263
+ track(eventName, properties) {
264
+ try {
265
+ if (typeof eventName !== 'string' || eventName.length === 0) {
266
+ reportError('track requires a non-empty event name');
267
+ return;
268
+ }
269
+ const e = requireEngine();
270
+ const props = asEventProps(properties);
271
+ enqueueAndEvaluate(e, eventName, props);
272
+ }
273
+ catch (err) {
274
+ reportError('track failed', err);
275
+ }
276
+ },
277
+ async setConsent(purposes) {
278
+ try {
279
+ const e = requireEngine();
280
+ await ensureHydrated(e);
281
+ const next = await e.consent.set(purposes);
282
+ const id = e.identity.get();
283
+ try {
284
+ await e.transport.consent({
285
+ anonymousId: id.anonymousId,
286
+ externalId: id.externalId,
287
+ purposes: {
288
+ analytics: next.analytics,
289
+ feedback: next.feedback,
290
+ push: next.push,
291
+ survey: next.survey,
292
+ },
293
+ version: next.version,
294
+ effectiveAt: next.updatedAt ?? new Date().toISOString(),
295
+ });
296
+ }
297
+ catch (err) {
298
+ reportError('transport.consent failed', err);
299
+ return false;
300
+ }
301
+ if (next.feedback || next.survey) {
302
+ await refreshTargetingRules(e, true);
303
+ }
304
+ if (next.analytics || next.feedback) {
305
+ await flushNow(e);
306
+ }
307
+ return true;
308
+ }
309
+ catch (err) {
310
+ reportError('setConsent failed', err);
311
+ return false;
312
+ }
313
+ },
314
+ reset() {
315
+ try {
316
+ const e = requireEngine();
317
+ if (e.resetting)
318
+ return resetPromise ?? Promise.resolve();
319
+ e.resetting = true;
320
+ e.resetGeneration += 1;
321
+ e.events.emit('resetSurfaces', undefined);
322
+ const operation = (async () => {
323
+ await ensureHydrated(e);
324
+ if (e.lastPushToken)
325
+ await UserGist.invalidatePushToken(e.lastPushToken);
326
+ await UserGistPushNative.disablePush().catch(() => undefined);
327
+ await clearAllState(e);
328
+ })();
329
+ const finalized = operation
330
+ .catch((err) => reportError('reset failed', err))
331
+ .finally(() => {
332
+ e.resetting = false;
333
+ if (resetPromise === finalized)
334
+ resetPromise = null;
335
+ });
336
+ resetPromise = finalized;
337
+ return finalized;
338
+ }
339
+ catch (err) {
340
+ reportError('reset failed', err);
341
+ return Promise.resolve();
342
+ }
343
+ },
344
+ setThemeOverrides(theme) {
345
+ try {
346
+ const e = requireEngine();
347
+ e.themeOverride = theme ? mergeTheme(DEFAULT_THEME, theme) : null;
348
+ }
349
+ catch (err) {
350
+ reportError('setThemeOverrides failed', err);
351
+ }
352
+ },
353
+ flush() {
354
+ try {
355
+ const e = requireEngine();
356
+ void flushNow(e);
357
+ }
358
+ catch (err) {
359
+ reportError('flush failed', err);
360
+ }
361
+ },
362
+ setDebug(enabled) {
363
+ try {
364
+ setDebugEnabled(enabled);
365
+ }
366
+ catch (err) {
367
+ reportError('setDebug failed', err);
368
+ }
369
+ },
370
+ getAnonymousId() {
371
+ try {
372
+ const id = engine?.identity.get().anonymousId;
373
+ return id && id.length > 0 ? id : null;
374
+ }
375
+ catch {
376
+ return null;
377
+ }
378
+ },
379
+ getExternalId() {
380
+ try {
381
+ return engine?.identity.get().externalId ?? null;
382
+ }
383
+ catch {
384
+ return null;
385
+ }
386
+ },
387
+ onPromptShown(cb) {
388
+ try {
389
+ return requireEngine().events.on('promptShown', cb);
390
+ }
391
+ catch (err) {
392
+ reportError('onPromptShown failed', err);
393
+ return () => { };
394
+ }
395
+ },
396
+ onResponse(cb) {
397
+ try {
398
+ return requireEngine().events.on('response', cb);
399
+ }
400
+ catch (err) {
401
+ reportError('onResponse failed', err);
402
+ return () => { };
403
+ }
404
+ },
405
+ /**
406
+ * Subscribe to push lifecycle events: `$push_received`, `$push_displayed`,
407
+ * `$push_opened`, `$push_dismissed`, `$push_action_clicked`.
408
+ *
409
+ * Each callback receives `(eventName, props)` where `props` carries
410
+ * `{ campaign_id, variant_id, delivery_id, language, action_button? }`.
411
+ *
412
+ * Use this to forward push events to a host-app analytics tool
413
+ * (Amplitude, Mixpanel, Segment, etc.). The SDK is analytics-tool
414
+ * agnostic — you plumb to whatever stack you use.
415
+ *
416
+ * Example:
417
+ * import { track as amplitudeTrack } from '@amplitude/analytics-react-native'
418
+ * UserGist.onPushEvent((name, props) => amplitudeTrack(name, props))
419
+ *
420
+ * Returns an unsubscribe fn.
421
+ */
422
+ onPushEvent(cb) {
423
+ try {
424
+ return requireEngine().events.on('pushEvent', (p) => cb(p.name, p.props));
425
+ }
426
+ catch (err) {
427
+ reportError('onPushEvent failed', err);
428
+ return () => { };
429
+ }
430
+ },
431
+ /** SDK-internal: invoked by Push.ts for every $push_* event. */
432
+ _notifyPushEvent(name, props) {
433
+ try {
434
+ engine?.events.emit('pushEvent', { name, props });
435
+ }
436
+ catch {
437
+ // Listener errors must never cross the SDK boundary.
438
+ }
439
+ },
440
+ 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
+ }
486
+ },
487
+ 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
+ }
502
+ },
503
+ /**
504
+ * Re-bind the latest registered device token to a newly-identified user.
505
+ * Called from inside `identify()` so that subsequent campaign sends to
506
+ * the now-identified user reach this device. No-op when no push token
507
+ * is registered on this device.
508
+ */
509
+ async rebindPushToken(externalId) {
510
+ try {
511
+ const e = requireEngine();
512
+ await ensureHydrated(e);
513
+ const id = e.identity.get();
514
+ const token = e.lastPushToken;
515
+ if (!token)
516
+ return;
517
+ await e.transport.pushRebind({
518
+ anonymousId: id.anonymousId,
519
+ externalId,
520
+ token,
521
+ });
522
+ }
523
+ catch (err) {
524
+ reportError('rebindPushToken failed', err);
525
+ }
526
+ },
527
+ /** SDK-internal: emit a delivery beacon. */
528
+ async pushBeacon(kind, deliveryId) {
529
+ try {
530
+ if (!deliveryId)
531
+ return;
532
+ const e = requireEngine();
533
+ await ensureHydrated(e);
534
+ const dispatch = {
535
+ delivered: e.transport.pushDelivered,
536
+ displayed: e.transport.pushDisplayed,
537
+ dismissed: e.transport.pushDismissed,
538
+ };
539
+ await dispatch[kind]({ deliveryId });
540
+ }
541
+ catch (err) {
542
+ reportError(`pushBeacon ${kind} failed`, err);
543
+ }
544
+ },
545
+ /** Ack a silent reachability ping. */
546
+ async pushAckSilent(pingId) {
547
+ try {
548
+ const e = requireEngine();
549
+ await ensureHydrated(e);
550
+ const id = e.identity.get();
551
+ await e.transport.pushSilentAck({ pingId, anonymousId: id.anonymousId });
552
+ }
553
+ catch (err) {
554
+ reportError('pushAckSilent failed', err);
555
+ }
556
+ },
557
+ /** Forward an applicationDidBecomeActive / onResume signal to the server. */
558
+ async pushAppOpen() {
559
+ try {
560
+ const e = requireEngine();
561
+ await ensureHydrated(e);
562
+ if (!e.lastPushToken)
563
+ return;
564
+ const id = e.identity.get();
565
+ await e.transport.pushAppOpen({ anonymousId: id.anonymousId });
566
+ }
567
+ catch (err) {
568
+ reportError('pushAppOpen failed', err);
569
+ }
570
+ },
571
+ /** Fetch the per-app channel registry. */
572
+ async pushFetchChannels() {
573
+ try {
574
+ const e = requireEngine();
575
+ await ensureHydrated(e);
576
+ const resp = await e.transport.pushChannelsList();
577
+ return (resp.channels ?? []);
578
+ }
579
+ catch (err) {
580
+ reportError('pushFetchChannels failed', err);
581
+ return [];
582
+ }
583
+ },
584
+ async pushSetChannelSubscription(channelId, subscribed) {
585
+ try {
586
+ const e = requireEngine();
587
+ await ensureHydrated(e);
588
+ const id = e.identity.get();
589
+ await e.transport.pushChannelSubscription({
590
+ anonymousId: id.anonymousId,
591
+ channelId,
592
+ subscribed,
593
+ });
594
+ }
595
+ catch (err) {
596
+ reportError('pushSetChannelSubscription failed', err);
597
+ }
598
+ },
599
+ // ---------- Self-contained push (Phase 7) ----------
600
+ //
601
+ // `enablePush()` is the single high-level entry point — it acquires the
602
+ // OS-level push permission, fetches the platform token (APNs / FCM)
603
+ // through the bundled native module, and registers the token with the
604
+ // UserGist API. Token-refresh + foreground/background delivery + tap
605
+ // callbacks are wired automatically once any of these are called.
606
+ //
607
+ // Consumers do NOT need @react-native-firebase/messaging,
608
+ // react-native-onesignal, WonderPush, or any other push SDK.
609
+ async enablePush(opts) {
610
+ try {
611
+ if (!enginePushListenersAttached)
612
+ attachEnginePushListeners();
613
+ // Snapshot the caller's options so the AppState foreground listener
614
+ // can re-run enablePush idempotently with the same environment.
615
+ lastEnablePushOptions = {
616
+ environment: opts?.environment,
617
+ skipPermissionRequestIfDenied: opts?.skipPermissionRequestIfDenied,
618
+ installDelegateProxy: opts?.installDelegateProxy,
619
+ };
620
+ const result = await UserGistPushNative.enablePush(opts ?? {});
621
+ // Server-side registration runs on the tokenReceived event; if the
622
+ // native module already had a cached token it surfaces here.
623
+ if (result.granted && result.token) {
624
+ const platform = (result.platform === 'ios' ? 'ios' : 'android');
625
+ await UserGist.registerPushToken(result.token, platform, {
626
+ environment: opts?.environment ?? (defaultEnvironment()),
627
+ });
628
+ }
629
+ return result;
630
+ }
631
+ catch (err) {
632
+ reportError('enablePush failed', err);
633
+ return {
634
+ granted: false,
635
+ status: 'denied',
636
+ platform: (typeof navigator === 'object' && navigator.product === 'ReactNative' ? 'ios' : 'android'),
637
+ };
638
+ }
639
+ },
640
+ async disablePush() {
641
+ 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
+ }
652
+ }
653
+ catch (err) {
654
+ reportError('disablePush failed', err);
655
+ }
656
+ },
657
+ async getPushPermissionStatus() {
658
+ try {
659
+ return await UserGistPushNative.getPermissionStatus();
660
+ }
661
+ catch (err) {
662
+ reportError('getPushPermissionStatus failed', err);
663
+ return 'not_determined';
664
+ }
665
+ },
666
+ async setPushBadgeCount(count) {
667
+ try {
668
+ await UserGistPushNative.setBadgeCount(count);
669
+ }
670
+ catch (err) {
671
+ reportError('setPushBadgeCount failed', err);
672
+ }
673
+ },
674
+ async getInitialPushNotification() {
675
+ try {
676
+ return await UserGistPushNative.getInitialNotification();
677
+ }
678
+ catch (err) {
679
+ reportError('getInitialPushNotification failed', err);
680
+ return null;
681
+ }
682
+ },
683
+ // ---------- Surveys ----------
684
+ async getAvailableSurveys() {
685
+ try {
686
+ const e = requireEngine();
687
+ await ensureHydrated(e);
688
+ if (!e.consent.get()?.survey)
689
+ return [];
690
+ const id = e.identity.get();
691
+ const res = await e.transport.surveysAvailable({
692
+ anonymousId: id.anonymousId,
693
+ externalId: id.externalId ?? null,
694
+ });
695
+ return res.surveys ?? [];
696
+ }
697
+ catch (err) {
698
+ reportError('getAvailableSurveys failed', err);
699
+ return [];
700
+ }
701
+ },
702
+ async openSurvey(surveyId, context) {
703
+ try {
704
+ const e = requireEngine();
705
+ await ensureHydrated(e);
706
+ if (!e.consent.get()?.survey) {
707
+ debugLog('openSurvey: survey consent not granted; ignoring');
708
+ return;
709
+ }
710
+ e.events.emit('showSurvey', {
711
+ surveyId,
712
+ source: context?.source ?? 'on_demand',
713
+ ...(context?.language ? { language: context.language } : {}),
714
+ });
715
+ }
716
+ catch (err) {
717
+ reportError('openSurvey failed', err);
718
+ }
719
+ },
720
+ setSurveyHandlers(handlers) {
721
+ surveyHandlers = { ...handlers };
722
+ },
723
+ setInAppHandlers(handlers) {
724
+ inAppHandlers = { ...handlers };
725
+ },
726
+ async handleSurveyDeepLink(url) {
727
+ try {
728
+ const e = requireEngine();
729
+ await ensureHydrated(e);
730
+ // Accept URLs of the shape `*://*/s/<token>` or `*survey=<token>`.
731
+ const match = url.match(/\/s\/([A-Za-z0-9._-]+)/) ?? url.match(/[?&]survey=([A-Za-z0-9._-]+)/);
732
+ if (!match || !match[1])
733
+ return false;
734
+ const token = match[1];
735
+ const id = e.identity.get();
736
+ const resolved = await e.transport.surveyResolveLink({
737
+ token,
738
+ anonymousId: id.anonymousId,
739
+ externalId: id.externalId ?? null,
740
+ });
741
+ if (resolved.consentRequired) {
742
+ debugLog('survey link resolved but consent required — awaiting consent');
743
+ return false;
744
+ }
745
+ e.events.emit('showSurvey', { surveyId: resolved.surveyId, source: 'link' });
746
+ return true;
747
+ }
748
+ catch (err) {
749
+ reportError('handleSurveyDeepLink failed', err);
750
+ return false;
751
+ }
752
+ },
753
+ // ---------- Non-public / internal ----------
754
+ // Shape is unstable; for tests and debugging only.
755
+ __internal_state() {
756
+ return {
757
+ config: engine?.config ?? null,
758
+ identity: engine?.identity.get() ?? null,
759
+ consent: engine?.consent.get() ?? null,
760
+ queueSize: engine?.queue.size() ?? 0,
761
+ triggerCount: engine?.rules.all().length ?? 0,
762
+ frequencyCaps: engine?.caps.snapshot() ?? null,
763
+ traces: getTraces(),
764
+ };
765
+ },
766
+ // Called by <UserGistProvider>.
767
+ __internal_submitResponse(response, triggerEventName) {
768
+ const e = engine;
769
+ if (!e)
770
+ return Promise.resolve();
771
+ return submitResponse(e, response, triggerEventName, trackInternal);
772
+ },
773
+ __internal_events() {
774
+ return requireEngine().events;
775
+ },
776
+ __internal_theme() {
777
+ return engine?.themeOverride ?? null;
778
+ },
779
+ __internal_logTrace: logTrace,
780
+ __internal_surveyStore() {
781
+ return surveyStore;
782
+ },
783
+ __internal_inAppHandlers() {
784
+ return inAppHandlers;
785
+ },
786
+ __internal_surveyHandlers() {
787
+ return surveyHandlers;
788
+ },
789
+ __internal_reportSurveyShown(surveyId) {
790
+ try {
791
+ const e = requireEngine();
792
+ e.surveyMatcher.recordShown(surveyId);
793
+ }
794
+ catch (err) {
795
+ reportError('reportSurveyShown failed', err);
796
+ }
797
+ },
798
+ __internal_armedSurveyById(surveyId) {
799
+ // Local-fire fast-path. If the survey-rules-cache already has the
800
+ // full survey content (because the matcher just fired), the
801
+ // Provider can render without a /v1/sdk/surveys/:id roundtrip.
802
+ try {
803
+ const e = engine;
804
+ if (!e)
805
+ return null;
806
+ const armed = e.surveyRules.getById(surveyId);
807
+ return armed?.survey ?? null;
808
+ }
809
+ catch {
810
+ return null;
811
+ }
812
+ },
813
+ async __internal_fetchSurvey(surveyId, language) {
814
+ try {
815
+ const e = requireEngine();
816
+ await ensureHydrated(e);
817
+ const id = e.identity.get();
818
+ return await e.transport.surveyGet({
819
+ surveyId,
820
+ anonymousId: id.anonymousId,
821
+ externalId: id.externalId ?? null,
822
+ ...(language ? { language } : {}),
823
+ });
824
+ }
825
+ catch (err) {
826
+ reportError('surveyGet failed', err);
827
+ return null;
828
+ }
829
+ },
830
+ async __internal_createAttempt(surveyId, source, language) {
831
+ try {
832
+ const e = requireEngine();
833
+ await ensureHydrated(e);
834
+ // Give a previously deferred completion a chance to reach the server
835
+ // before asking it to create or resume another attempt.
836
+ await flushMutations(e);
837
+ const id = e.identity.get();
838
+ const res = await e.transport.surveyCreateAttempt(surveyId, {
839
+ anonymousId: id.anonymousId,
840
+ externalId: id.externalId ?? null,
841
+ source,
842
+ ...(language ? { language } : {}),
843
+ sdkVersion: `rn-${USERGIST_SDK_VERSION}`,
844
+ });
845
+ await surveyStore?.upsert({
846
+ surveyId,
847
+ attemptId: res.attemptId,
848
+ startedAt: Date.now(),
849
+ currentQuestionId: res.currentQuestionId,
850
+ snapshot: (res.progressSnapshot ?? {}),
851
+ language: language ?? null,
852
+ });
853
+ return {
854
+ attemptId: res.attemptId,
855
+ startQuestionId: res.startQuestionId,
856
+ currentQuestionId: res.currentQuestionId,
857
+ snapshot: (res.progressSnapshot ?? {}),
858
+ resumed: res.resumed,
859
+ };
860
+ }
861
+ catch (err) {
862
+ reportError('createAttempt failed', err);
863
+ return null;
864
+ }
865
+ },
866
+ async __internal_saveProgress(attemptId, currentQuestionId, snapshot) {
867
+ // Persist the resume point before attempting the network. Progress is
868
+ // best-effort transport; losing connectivity must not lose local answers.
869
+ await surveyStore?.updateProgress(attemptId, currentQuestionId, snapshot);
870
+ try {
871
+ const e = requireEngine();
872
+ await e.transport.surveyUpdateProgress(attemptId, {
873
+ currentQuestionId,
874
+ progressSnapshot: snapshot,
875
+ });
876
+ }
877
+ catch (err) {
878
+ // Completion can win a race with an in-flight progress PATCH, making a
879
+ // server-side 404 harmless. Keep this out of React Native's LogBox.
880
+ debugLog('survey progress delivery deferred', {
881
+ attemptId,
882
+ reason: err instanceof Error ? err.message : String(err),
883
+ });
884
+ }
885
+ },
886
+ async __internal_submitAnswers(attemptId, answers) {
887
+ try {
888
+ const e = requireEngine();
889
+ await e.transport.surveySubmitAnswers(attemptId, { answers });
890
+ }
891
+ catch (err) {
892
+ reportError('submitAnswers failed', err);
893
+ }
894
+ },
895
+ async __internal_completeAttempt(attemptId, finalAnswers = []) {
896
+ try {
897
+ const e = requireEngine();
898
+ await ensureHydrated(e);
899
+ if (e.resetting)
900
+ throw new Error('Survey submission cancelled by reset');
901
+ const resetGeneration = e.resetGeneration;
902
+ const mutationId = await e.mutations.enqueue('survey-complete', 'survey', {
903
+ attemptId,
904
+ body: { finalAnswers },
905
+ }, `survey-complete:${attemptId}`);
906
+ const result = await flushMutations(e);
907
+ if (e.resetting || e.resetGeneration !== resetGeneration) {
908
+ throw new Error('Survey submission cancelled by reset');
909
+ }
910
+ const outcome = surveyCompletionOutcome(mutationId, e.mutations, result);
911
+ if (outcome === 'rejected') {
912
+ throw new Error('Survey submission was rejected by the server');
913
+ }
914
+ if (outcome === 'deferred') {
915
+ debugLog('survey completion saved locally; delivery deferred', { attemptId });
916
+ }
917
+ // Once the completion is durably queued, the attempt must not reopen as
918
+ // resumable work. The mutation queue owns delivery from this point.
919
+ await surveyStore?.remove(attemptId);
920
+ }
921
+ catch (err) {
922
+ reportError('completeAttempt failed', err);
923
+ throw err;
924
+ }
925
+ },
926
+ async __internal_abandonAttempt(attemptId) {
927
+ try {
928
+ const e = requireEngine();
929
+ await ensureHydrated(e);
930
+ const mutationId = await e.mutations.enqueue('survey-abandon', 'survey', { attemptId }, `survey-abandon:${attemptId}`);
931
+ // The modal is already closed. Remove the local resume card now while
932
+ // the durable mutation queue keeps retrying the server transition.
933
+ await surveyStore?.remove(attemptId);
934
+ const result = await flushMutations(e);
935
+ if (result.permanentlyRejectedIds.has(mutationId)) {
936
+ reportError('abandonAttempt was rejected by the server');
937
+ }
938
+ }
939
+ catch (err) {
940
+ reportError('abandonAttempt failed', err);
941
+ }
942
+ },
943
+ // ============================================================
944
+ // Feature requests (5th pillar)
945
+ // ============================================================
946
+ // Consent decision: feature-request engagement maps onto the existing
947
+ // `feedback` consent purpose. No new consent migration required.
948
+ async getRequests(options = {}) {
949
+ try {
950
+ const e = requireEngine();
951
+ await ensureHydrated(e);
952
+ const id = e.identity.get();
953
+ const result = await e.transport.requestsList({
954
+ anonymousId: id.anonymousId,
955
+ externalId: id.externalId ?? null,
956
+ sort: options.sort,
957
+ statuses: options.statuses,
958
+ mine: options.mine,
959
+ q: options.q,
960
+ cursor: options.cursor ?? null,
961
+ limit: options.limit,
962
+ });
963
+ ensureRequestsCache().upsertList(result.items.map((s) => ({
964
+ id: s.id,
965
+ appId: '',
966
+ title: s.title,
967
+ description: s.description,
968
+ status: s.status,
969
+ devResponse: null,
970
+ upvoteCount: s.upvoteCount,
971
+ followerCount: s.followerCount,
972
+ createdAt: s.createdAt,
973
+ updatedAt: s.createdAt,
974
+ statusChangedAt: s.statusChangedAt,
975
+ lastRespondedAt: null,
976
+ viewerHasUpvoted: s.viewerHasUpvoted,
977
+ viewerIsFollowing: s.viewerIsFollowing,
978
+ viewerIsSubmitter: false,
979
+ })));
980
+ return result;
981
+ }
982
+ catch (err) {
983
+ reportError('getRequests failed', err);
984
+ return { items: [], nextCursor: null };
985
+ }
986
+ },
987
+ submitRequest(title, description, callback) {
988
+ try {
989
+ const e = requireEngine();
990
+ if (typeof title !== 'string' || title.length === 0 || title.length > 120) {
991
+ callback?.(new Error('title required, max 120 chars'));
992
+ return;
993
+ }
994
+ if (typeof description !== 'string' ||
995
+ description.length === 0 ||
996
+ description.length > 1500) {
997
+ callback?.(new Error('description required, max 1500 chars'));
998
+ return;
999
+ }
1000
+ void (async () => {
1001
+ try {
1002
+ await ensureHydrated(e);
1003
+ if (!e.consent.allowsFeedback()) {
1004
+ callback?.(new Error('feedback consent required'));
1005
+ return;
1006
+ }
1007
+ const id = e.identity.get();
1008
+ const req = await e.transport.requestSubmit({
1009
+ idempotencyKey: generateEventId(),
1010
+ anonymousId: id.anonymousId,
1011
+ externalId: id.externalId ?? null,
1012
+ title,
1013
+ description,
1014
+ });
1015
+ ensureRequestsCache().upsert(req);
1016
+ recordServerBackedEventLocally(e, '$request_submitted');
1017
+ requestsHandlers.onSubmit?.(req);
1018
+ callback?.(null, req);
1019
+ }
1020
+ catch (err) {
1021
+ callback?.(err instanceof Error ? err : new Error(String(err)));
1022
+ }
1023
+ })();
1024
+ }
1025
+ catch (err) {
1026
+ reportError('submitRequest failed', err);
1027
+ callback?.(err instanceof Error ? err : new Error(String(err)));
1028
+ }
1029
+ },
1030
+ async voteOnRequest(requestId, vote) {
1031
+ try {
1032
+ const e = requireEngine();
1033
+ await ensureHydrated(e);
1034
+ if (!e.consent.allowsFeedback())
1035
+ return;
1036
+ const cache = ensureRequestsCache();
1037
+ const rollback = cache.applyOptimisticVote(requestId, vote);
1038
+ try {
1039
+ const id = e.identity.get();
1040
+ const result = await e.transport.requestVote({
1041
+ requestId,
1042
+ anonymousId: id.anonymousId,
1043
+ externalId: id.externalId ?? null,
1044
+ vote,
1045
+ });
1046
+ cache.commitVote(requestId, result);
1047
+ recordServerBackedEventLocally(e, vote ? '$request_upvoted' : '$request_unupvoted');
1048
+ requestsHandlers.onVote?.(result);
1049
+ }
1050
+ catch (err) {
1051
+ rollback();
1052
+ throw err;
1053
+ }
1054
+ }
1055
+ catch (err) {
1056
+ reportError('voteOnRequest failed', err);
1057
+ }
1058
+ },
1059
+ async followRequest(requestId, follow) {
1060
+ try {
1061
+ const e = requireEngine();
1062
+ await ensureHydrated(e);
1063
+ if (!e.consent.allowsFeedback())
1064
+ return;
1065
+ const cache = ensureRequestsCache();
1066
+ const rollback = cache.applyOptimisticFollow(requestId, follow);
1067
+ try {
1068
+ const id = e.identity.get();
1069
+ const result = await e.transport.requestFollow({
1070
+ requestId,
1071
+ anonymousId: id.anonymousId,
1072
+ externalId: id.externalId ?? null,
1073
+ follow,
1074
+ });
1075
+ cache.commitFollow(requestId, result);
1076
+ recordServerBackedEventLocally(e, follow ? '$request_followed' : '$request_unfollowed');
1077
+ requestsHandlers.onFollow?.(result);
1078
+ }
1079
+ catch (err) {
1080
+ rollback();
1081
+ throw err;
1082
+ }
1083
+ }
1084
+ catch (err) {
1085
+ reportError('followRequest failed', err);
1086
+ }
1087
+ },
1088
+ /**
1089
+ * Fetch the per-app branding the dashboard configured for this pillar
1090
+ * (entry label, accent color, logo URL, intro copy). Host apps use
1091
+ * this to theme the requests UI so it matches the rest of the product
1092
+ * without hard-coding colors. Cached for 60s at the edge.
1093
+ */
1094
+ async getRequestBranding() {
1095
+ try {
1096
+ const e = requireEngine();
1097
+ await ensureHydrated(e);
1098
+ if (!e.consent.allowsFeedback())
1099
+ return null;
1100
+ return await e.transport.requestBranding();
1101
+ }
1102
+ catch (err) {
1103
+ reportError('getRequestBranding failed', err);
1104
+ return null;
1105
+ }
1106
+ },
1107
+ /**
1108
+ * Fetch a single request by id with full detail (description, dev
1109
+ * response, viewer flags). The list endpoint returns a `RequestSummary`
1110
+ * that intentionally omits `devResponse` to keep payloads small —
1111
+ * call this when rendering a detail view that needs the response.
1112
+ */
1113
+ async getRequest(requestId) {
1114
+ try {
1115
+ const e = requireEngine();
1116
+ await ensureHydrated(e);
1117
+ if (!e.consent.allowsFeedback())
1118
+ return null;
1119
+ const id = e.identity.get();
1120
+ const req = await e.transport.requestGet({
1121
+ requestId,
1122
+ anonymousId: id.anonymousId,
1123
+ externalId: id.externalId ?? null,
1124
+ });
1125
+ ensureRequestsCache().upsert(req);
1126
+ return req;
1127
+ }
1128
+ catch (err) {
1129
+ reportError('getRequest failed', err);
1130
+ return null;
1131
+ }
1132
+ },
1133
+ openRequestsBoard() {
1134
+ try {
1135
+ const e = requireEngine();
1136
+ if (!e.consent.allowsFeedback())
1137
+ return;
1138
+ e.events.emit('showRequestsBoard', undefined);
1139
+ }
1140
+ catch (err) {
1141
+ reportError('openRequestsBoard failed', err);
1142
+ }
1143
+ },
1144
+ openRequestDetail(requestId) {
1145
+ try {
1146
+ const e = requireEngine();
1147
+ if (!e.consent.allowsFeedback())
1148
+ return;
1149
+ e.events.emit('showRequestDetail', { requestId });
1150
+ }
1151
+ catch (err) {
1152
+ reportError('openRequestDetail failed', err);
1153
+ }
1154
+ },
1155
+ setRequestsHandlers(handlers) {
1156
+ requestsHandlers = { ...handlers };
1157
+ },
1158
+ // ---------- comments ----------
1159
+ /** List comments for a request, ordered oldest-first. */
1160
+ async getComments(requestId) {
1161
+ try {
1162
+ const e = requireEngine();
1163
+ await ensureHydrated(e);
1164
+ if (!e.consent.allowsFeedback())
1165
+ return [];
1166
+ const id = e.identity.get();
1167
+ const result = await e.transport.requestCommentsList({
1168
+ requestId,
1169
+ anonymousId: id.anonymousId,
1170
+ externalId: id.externalId ?? null,
1171
+ });
1172
+ return result.items;
1173
+ }
1174
+ catch (err) {
1175
+ reportError('getComments failed', err);
1176
+ return [];
1177
+ }
1178
+ },
1179
+ /** Post a comment on a request. Returns the new row, or null on failure. */
1180
+ async postComment(requestId, body) {
1181
+ try {
1182
+ const e = requireEngine();
1183
+ validateCommentBody(body);
1184
+ await ensureHydrated(e);
1185
+ if (!e.consent.allowsFeedback())
1186
+ return null;
1187
+ const id = e.identity.get();
1188
+ const comment = await e.transport.requestCommentPost({
1189
+ idempotencyKey: generateEventId(),
1190
+ requestId,
1191
+ anonymousId: id.anonymousId,
1192
+ externalId: id.externalId ?? null,
1193
+ body,
1194
+ });
1195
+ recordServerBackedEventLocally(e, REQUEST_COMMENTED_EVENT_NAME);
1196
+ return comment;
1197
+ }
1198
+ catch (err) {
1199
+ reportError('postComment failed', err);
1200
+ return null;
1201
+ }
1202
+ },
1203
+ /** Edit one of the viewer's own comments. Server returns 404 if not theirs. */
1204
+ async editComment(requestId, commentId, body) {
1205
+ try {
1206
+ const e = requireEngine();
1207
+ validateCommentBody(body);
1208
+ await ensureHydrated(e);
1209
+ if (!e.consent.allowsFeedback())
1210
+ return null;
1211
+ const id = e.identity.get();
1212
+ const comment = await e.transport.requestCommentEdit({
1213
+ requestId,
1214
+ commentId,
1215
+ anonymousId: id.anonymousId,
1216
+ body,
1217
+ });
1218
+ recordServerBackedEventLocally(e, REQUEST_COMMENT_EDITED_EVENT_NAME);
1219
+ return comment;
1220
+ }
1221
+ catch (err) {
1222
+ reportError('editComment failed', err);
1223
+ return null;
1224
+ }
1225
+ },
1226
+ /** Delete one of the viewer's own comments. */
1227
+ async deleteComment(requestId, commentId) {
1228
+ try {
1229
+ const e = requireEngine();
1230
+ await ensureHydrated(e);
1231
+ if (!e.consent.allowsFeedback())
1232
+ return;
1233
+ const id = e.identity.get();
1234
+ await e.transport.requestCommentDelete({
1235
+ requestId,
1236
+ commentId,
1237
+ anonymousId: id.anonymousId,
1238
+ });
1239
+ recordServerBackedEventLocally(e, REQUEST_COMMENT_DELETED_EVENT_NAME);
1240
+ }
1241
+ catch (err) {
1242
+ reportError('deleteComment failed', err);
1243
+ }
1244
+ },
1245
+ };
1246
+ //# sourceMappingURL=UserGist.js.map