@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
package/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@usergist/feedback-react-native` are documented here.
4
+ Releases use [Semantic Versioning](https://semver.org/).
5
+
6
+ ## 0.1.0
7
+
8
+ - Initial production React Native SDK.
9
+ - Anonymous and identified-user sessions with consent-aware ingestion.
10
+ - Offline event and mutation queues with bounded retries.
11
+ - Feedback, survey, in-app message, feature-request, and push surfaces.
12
+ - iOS and Android native push bridges plus notification-service extension.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 userGist
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,231 @@
1
+ # @usergist/feedback-react-native
2
+
3
+ Production userGist SDK for React Native and the behavioral reference for the
4
+ iOS, Android, and Flutter packages. Use the dashboard's SDK setup flow to
5
+ verify authentication and ingestion; push releases additionally require APNs
6
+ or FCM configuration and physical-device validation.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @usergist/feedback-react-native \
12
+ @react-native-async-storage/async-storage \
13
+ react-native-safe-area-context
14
+ cd ios && pod install
15
+ ```
16
+
17
+ AsyncStorage and `react-native-safe-area-context` are peer dependencies.
18
+ Without AsyncStorage the SDK runs with an in-memory store, so queued events do
19
+ not survive a relaunch.
20
+
21
+ ## Quick start
22
+
23
+ ```tsx
24
+ import React from 'react'
25
+ import { AppRegistry } from 'react-native'
26
+ import { UserGist, UserGistProvider } from '@usergist/feedback-react-native'
27
+
28
+ // Obtain this from your authenticated backend. It exchanges an rtk_ token at
29
+ // POST /v1/apps/:appId/sdk/subject-tokens; never ship rtk_ tokens in the app.
30
+ const identityResult = await UserGist.initAsync(
31
+ {
32
+ writeKey: 'rk_live_xxx',
33
+ apiUrl: 'https://api.usergist.com',
34
+ environment: 'production',
35
+ debug: __DEV__,
36
+ },
37
+ {
38
+ userId: 'user_42',
39
+ properties: { plan: 'pro' },
40
+ subjectToken: identifiedSubjectToken,
41
+ },
42
+ )
43
+
44
+ const consentSynchronized = await UserGist.setConsent({
45
+ analytics: true,
46
+ feedback: true,
47
+ })
48
+
49
+ function App() {
50
+ return (
51
+ <UserGistProvider>
52
+ {/* your app */}
53
+ </UserGistProvider>
54
+ )
55
+ }
56
+
57
+ AppRegistry.registerComponent('app', () => App)
58
+ ```
59
+
60
+ ## API
61
+
62
+ | Method | Description |
63
+ |---|---|
64
+ | `UserGist.init(config)` | Returns synchronously, then hydrates and establishes the anonymous session in the background. |
65
+ | `await UserGist.initAsync(config, initialIdentity?)` | Hydrates and optionally binds a server-proven identity before lifecycle events begin; returns `synced`, `queued`, or `rejected`. |
66
+ | `UserGist.identify(userId, props?, subjectToken)` | Links the anonymous installation using a customer-backend-minted subject token. |
67
+ | `await UserGist.identifyAsync(userId, props?, subjectToken)` | Backward-compatible async identity API returning `synced`, `queued`, or `rejected`. |
68
+ | `UserGist.track(name, props?)` | Enqueues a stable event id and immediately evaluates only server-authorized client-side campaigns; all other decisions remain server-authoritative. |
69
+ | `await UserGist.setConsent({ analytics?, feedback?, push?, survey? })` | Persists and synchronizes the transition, refreshes targeting rules, then resolves with `true`; returns `false` when synchronization fails. |
70
+ | `await UserGist.reset()` | Cancels in-flight, clears queue, rotates anonymous id, wipes caches, and resolves after the new anonymous session is ready. |
71
+ | `UserGist.setThemeOverrides(theme)` | Global theme applied under per-prompt theme. |
72
+ | `UserGist.flush()` | Best-effort flush. |
73
+ | `UserGist.setDebug(boolean)` | Toggle the debug trace logger at runtime. |
74
+ | `UserGist.setDiagnosticHandler(handler)` | Receive bounded diagnostics without raw user payloads. |
75
+ | `UserGist.getAnonymousId()` | Returns the persisted anonymous id (21-char, URL-safe). |
76
+ | `UserGist.getExternalId()` | Returns the current stable external account ID, or `null`. |
77
+ | `UserGist.onPromptShown(cb)` / `UserGist.onResponse(cb)` | Observe prompt lifecycle. |
78
+
79
+ Call and await `reset()` before identifying a different account on the same
80
+ installation. Switching directly from one non-null external ID to another is
81
+ rejected so two people cannot be merged accidentally.
82
+
83
+ ### Encrypted persistence
84
+
85
+ By default, the SDK uses AsyncStorage for bounded event/campaign state and
86
+ identity. Signed subject credentials and the durable mutation queue use the
87
+ bundled Keychain/EncryptedSharedPreferences native store and never fall back to
88
+ plaintext; if that native store is unavailable they remain process-memory only.
89
+ Apps that want every persisted value encrypted can provide an encrypted
90
+ key/value adapter **before** init:
91
+
92
+ ```ts
93
+ UserGist.setStorageAdapter({
94
+ getItem: (key) => encryptedStore.getItem(key),
95
+ setItem: (key, value) => encryptedStore.setItem(key, value),
96
+ removeItem: (key) => encryptedStore.removeItem(key),
97
+ })
98
+ UserGist.init(config)
99
+ ```
100
+
101
+ The adapter uses the same asynchronous string interface as AsyncStorage, so it
102
+ can wrap the host application's Keychain/Keystore-backed storage.
103
+
104
+ ## Architecture
105
+
106
+ The SDK is a singleton that orchestrates:
107
+ `Storage · Subject session · Consent · Durable queues · Transport · Instruction inbox · Armed campaigns · Persistent user state · Modal UI · Lifecycle · Debug`.
108
+
109
+ AsyncStorage is bounded but not encrypted. Use `setStorageAdapter` when all SDK
110
+ state must be encrypted at rest, and never put credentials or secrets in event
111
+ properties. Default email/phone/SSN/tax-id property keys are removed before
112
+ persistence and again by the API.
113
+
114
+ Public convenience methods contain their own failures and report bounded diagnostics through `setDiagnosticHandler`. Internal UI persistence hooks may reject so the bundled survey UI can show a pending/retry state instead of falsely reporting success.
115
+
116
+ ## Push integration modes
117
+
118
+ `UserGist.enablePush()` uses the bundled automatic native integration by default. Apps that already own notification delegates or use another push SDK should use host-forwarded mode:
119
+
120
+ ```ts
121
+ await UserGist.enablePush({
122
+ environment: 'production',
123
+ installDelegateProxy: false,
124
+ })
125
+
126
+ // Forward the token and message callbacks from the host integration.
127
+ await Push.registerDeviceToken(token, Platform.OS === 'ios' ? 'ios' : 'android')
128
+ Push.handleReceived({ data })
129
+ Push.handleOpened({ data })
130
+ ```
131
+
132
+ On Android, also disable the bundled `FirebaseMessagingService` when the host owns the FCM service. Set the application manifest placeholder `userGistFirebaseServiceEnabled=false` (or override/remove the library service in the app manifest). Automatic and host-forwarded modes must not both display the same notification.
133
+
134
+ On iOS, `installDelegateProxy: false` prevents UserGist from intercepting `UIApplicationDelegate` and `UNUserNotificationCenter.delegate`. The host remains responsible for forwarding the APNs token and notification callbacks through `Push`.
135
+
136
+ ### JSON-driven in-app actions
137
+
138
+ Dashboard-authored in-app CTAs and push action buttons can use the `json`
139
+ action type. The SDK always records the generic button-tap event first, then
140
+ passes the structured object to the host app. Keep business logic in the app
141
+ so the same action can safely select a price, enable a feature, or route into a
142
+ native flow:
143
+
144
+ ```ts
145
+ function executeAction(action: Readonly<Record<string, unknown>>) {
146
+ if (action.type === 'enable_feature' && typeof action.feature === 'string') {
147
+ featureStore.enable(action.feature)
148
+ }
149
+ }
150
+
151
+ UserGist.setInAppHandlers({
152
+ onJsonAction: (action) => executeAction(action),
153
+ })
154
+
155
+ Push.setHandlers({
156
+ onJsonAction: (action) => executeAction(action),
157
+ })
158
+ ```
159
+
160
+ JSON actions are data, not executable code. Validate supported `type` values
161
+ and fields in the host before changing app state.
162
+
163
+ ## iOS Notification Service Extension (NSE) — required for true delivery tracking
164
+
165
+ The NSE runs in-process when each push arrives, BEFORE the system displays it.
166
+ Without it, on iOS we cannot distinguish "delivered" from "user opened the
167
+ notification" — `delivered_at` only fills when the user actually taps.
168
+
169
+ ### One-time Xcode setup
170
+
171
+ 1. **File → New → Target → Notification Service Extension**
172
+ - Name: `UserGistNotificationServiceExtension`
173
+ - Bundle id: `<your-app-bundle>.UserGistNotificationServiceExtension`
174
+ 2. **Replace the auto-generated `NotificationService.swift`** with:
175
+ ```swift
176
+ import UserNotifications
177
+ import UserGistFeedbackExtension
178
+
179
+ class NotificationService: UserGistNotificationService { }
180
+ ```
181
+ 3. **Configure the NSE target's Info.plist** (or write to the App Group's
182
+ `UserDefaults` from the main app — preferred, supports rotation):
183
+ - `UserGistWriteKey` — required; same write key the main SDK uses.
184
+ - `UserGistApiUrl` — optional; defaults to `https://api.usergist.com`.
185
+ - `UserGistAppGroup` — optional; App Group id shared with main app.
186
+ - `UserGistAnonymousId` — populated by main SDK at init via App Group.
187
+ 4. **Add `pod 'UserGistFeedbackExtension'`** to the NSE target stanza in your
188
+ `Podfile` and run `pod install`.
189
+
190
+ The `UserGistNotificationService` base class:
191
+ - Detects silent reachability pings (`usergist_silent: "1"`) and acks them
192
+ — the system shows nothing.
193
+ - Beacons `POST /v1/sdk/push/delivered` with the `usergist.deliveryId` so
194
+ we record true delivered_at, distinct from when the user opens.
195
+ - Falls back to an App Group ledger if the network beacon fails; the main
196
+ app drains it on next foreground.
197
+ - Downloads any rich-media `imageUrl` and attaches it as a
198
+ `UNNotificationAttachment`.
199
+
200
+ ## Android NotificationChannels
201
+
202
+ Android 8+ requires every notification to belong to a registered channel —
203
+ notifications without a channel are silently dropped by the OS. The bundled
204
+ Android service creates a `usergist_default` fallback channel automatically.
205
+ When a push names a dashboard-configured `usergist_channel_id`, the service
206
+ uses it if the host app has already created that channel and otherwise falls
207
+ back safely. Host-forwarded integrations can call `await Push.fetchChannels()`
208
+ at startup and create/update those definitions with their notification library.
209
+
210
+ ## Forward `applicationDidBecomeActive` / `onResume`
211
+
212
+ Automatic mode sends this beacon from the SDK's foreground listener. In
213
+ host-forwarded mode, call `Push.appDidBecomeActive()` from your foreground hook so the
214
+ server's reachability worker knows this device is alive — skips the next
215
+ silent-ping cycle for this user (saves provider quota + battery).
216
+
217
+ ## Compatibility and privacy decisions
218
+
219
+ - Transport bodies use plain JSON so the SDK does not add a native compression dependency.
220
+ - Device model is not collected by default. Send an approved, non-sensitive
221
+ property if your product genuinely needs model-level analysis.
222
+ - Anonymous IDs use a dependency-free, cryptographically random 21-character
223
+ URL-safe generator compatible with nanoid identifiers.
224
+
225
+ ## Testing hooks
226
+
227
+ `UserGist.__internal_state()` exposes `{ config, identity, consent, queueSize, triggerCount, frequencyCaps, traces }`. Unstable; do not rely on in production code.
228
+
229
+ ## License
230
+
231
+ MIT
@@ -0,0 +1,31 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
4
+
5
+ Pod::Spec.new do |s|
6
+ s.name = 'UserGistFeedback'
7
+ s.version = package['version']
8
+ s.summary = package['description']
9
+ s.description = package['description']
10
+ s.homepage = 'https://github.com/Future-Picnic/usergist-js'
11
+ s.license = { :type => 'MIT', :file => 'LICENSE' }
12
+ s.authors = { 'userGist' => 'engineering@usergist.com' }
13
+ s.platforms = { :ios => '13.0' }
14
+ s.source = { :git => 'https://github.com/Future-Picnic/usergist-js.git', :tag => "v#{s.version}" }
15
+
16
+ s.requires_arc = true
17
+ s.swift_version = '5.7'
18
+
19
+ # Default subspec — what RN autolinking picks up for the host app target.
20
+ s.default_subspec = 'Core'
21
+
22
+ # ---------- Core subspec (for the main app target) ----------
23
+ # The RN bridge module: RCTBridgeModule, RCTEventEmitter, Swift impl.
24
+ # Pulls in React-Core because it imports <React/...> headers.
25
+ s.subspec 'Core' do |core|
26
+ core.source_files = 'ios/*.{h,m,mm,swift}'
27
+ core.frameworks = 'UIKit', 'UserNotifications', 'Security'
28
+ core.dependency 'React-Core'
29
+ end
30
+
31
+ end
@@ -0,0 +1,23 @@
1
+ require 'json'
2
+
3
+ package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
4
+
5
+ # The Notification Service Extension is deliberately a separate pod. CocoaPods
6
+ # gives every subspec the root pod's Swift module name, which causes Xcode to
7
+ # infer circular dependencies when the app bridge and extension are embedded in
8
+ # the same workspace. This pod contains no React Native runtime dependency.
9
+ Pod::Spec.new do |s|
10
+ s.name = 'UserGistFeedbackExtension'
11
+ s.version = package['version']
12
+ s.summary = 'UserGist notification service extension support'
13
+ s.description = 'Lightweight iOS notification service extension support for UserGist React Native applications.'
14
+ s.homepage = 'https://github.com/Future-Picnic/usergist-js'
15
+ s.license = { :type => 'MIT', :file => 'LICENSE' }
16
+ s.authors = { 'userGist' => 'engineering@usergist.com' }
17
+ s.platforms = { :ios => '13.0' }
18
+ s.source = { :git => 'https://github.com/Future-Picnic/usergist-js.git', :tag => "v#{s.version}" }
19
+ s.requires_arc = true
20
+ s.swift_version = '5.7'
21
+ s.source_files = 'ios/Extension/*.swift'
22
+ s.frameworks = 'UserNotifications'
23
+ end
@@ -0,0 +1,92 @@
1
+ // Android build module for @usergist/feedback-react-native.
2
+ //
3
+ // What this module owns:
4
+ // - The native Kotlin React Native module (UserGistPushModule)
5
+ // - A FirebaseMessagingService subclass that auto-registers via the
6
+ // library's AndroidManifest (manifest-merger merges it into the host).
7
+ // - A transitive dep on com.google.firebase:firebase-messaging so the
8
+ // host app does NOT need @react-native-firebase/messaging — they
9
+ // only need their own google-services.json + the gms plugin.
10
+ //
11
+ // Convention: matches what most React Native libraries do (react-native-permissions, etc.).
12
+
13
+ buildscript {
14
+ ext.getExtOrDefault = { name ->
15
+ return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties["UserGistPush_" + name]
16
+ }
17
+
18
+ repositories {
19
+ google()
20
+ mavenCentral()
21
+ }
22
+
23
+ dependencies {
24
+ classpath "com.android.tools.build:gradle:7.2.1"
25
+ classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.22"
26
+ }
27
+ }
28
+
29
+ apply plugin: "com.android.library"
30
+ apply plugin: "kotlin-android"
31
+
32
+ def isNewArchitectureEnabled() {
33
+ return project.hasProperty("newArchEnabled") && project.newArchEnabled == "true"
34
+ }
35
+
36
+ if (isNewArchitectureEnabled()) {
37
+ apply plugin: "com.facebook.react"
38
+ }
39
+
40
+ android {
41
+ namespace "studio.usergist.feedback"
42
+ compileSdkVersion getExtOrDefault('compileSdkVersion')?.toInteger() ?: 34
43
+ buildToolsVersion getExtOrDefault('buildToolsVersion') ?: '34.0.0'
44
+
45
+ defaultConfig {
46
+ minSdkVersion getExtOrDefault('minSdkVersion')?.toInteger() ?: 23
47
+ targetSdkVersion getExtOrDefault('targetSdkVersion')?.toInteger() ?: 34
48
+ consumerProguardFiles 'consumer-rules.pro'
49
+ buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", isNewArchitectureEnabled().toString()
50
+ manifestPlaceholders = [userGistFirebaseServiceEnabled: "true"]
51
+ }
52
+
53
+ buildFeatures {
54
+ buildConfig true
55
+ }
56
+
57
+ compileOptions {
58
+ sourceCompatibility JavaVersion.VERSION_17
59
+ targetCompatibility JavaVersion.VERSION_17
60
+ }
61
+
62
+ kotlinOptions {
63
+ jvmTarget = "17"
64
+ }
65
+
66
+ sourceSets {
67
+ main {
68
+ manifest.srcFile "src/main/AndroidManifest.xml"
69
+ java.srcDirs = ['src/main/java']
70
+ }
71
+ }
72
+ }
73
+
74
+ repositories {
75
+ google()
76
+ mavenCentral()
77
+ }
78
+
79
+ dependencies {
80
+ implementation "com.facebook.react:react-android"
81
+
82
+ // FCM token + RemoteMessage delivery. Bundled transitively so consumer
83
+ // apps don't add @react-native-firebase/messaging themselves; they only
84
+ // need their own google-services.json + the com.google.gms.google-services
85
+ // plugin in their app/build.gradle (Google's hard requirement for FCM).
86
+ // Use a floor version that's broadly compatible with current Firebase BoM
87
+ // ranges; consumer's BoM (if any) will pick the actual version.
88
+ implementation "com.google.firebase:firebase-messaging:23.4.1"
89
+
90
+ implementation "androidx.core:core-ktx:1.12.0"
91
+ implementation "androidx.security:security-crypto:1.1.0-alpha06"
92
+ }
@@ -0,0 +1,5 @@
1
+ # Preserve the manifest-instantiated FCM service and React Native module in
2
+ # minified host release builds.
3
+ -keep class studio.usergist.feedback.UserGistFirebaseMessagingService { *; }
4
+ -keep class studio.usergist.feedback.UserGistPushModule { *; }
5
+ -keep class studio.usergist.feedback.UserGistPushPackage { *; }
@@ -0,0 +1,31 @@
1
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android"
2
+ xmlns:tools="http://schemas.android.com/tools">
3
+
4
+ <!-- Required for Firebase Cloud Messaging delivery. The host app's
5
+ google-services.json + com.google.gms.google-services plugin
6
+ provide the actual configuration. -->
7
+
8
+ <!-- Android 13+ runtime permission for showing notifications. -->
9
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
10
+
11
+ <application>
12
+ <!-- Library-supplied FirebaseMessagingService. Hosts that already own
13
+ FCM delivery must set userGistFirebaseServiceEnabled=false and
14
+ forward tokens/messages through the public Push API. -->
15
+ <service
16
+ android:name=".UserGistFirebaseMessagingService"
17
+ android:enabled="${userGistFirebaseServiceEnabled}"
18
+ android:exported="false">
19
+ <intent-filter>
20
+ <action android:name="com.google.firebase.MESSAGING_EVENT" />
21
+ </intent-filter>
22
+ </service>
23
+
24
+ <!-- Default notification channel applied when the host hasn't
25
+ defined one. Consumers can override by declaring their own
26
+ default-channel meta-data (auto-merged with theirs winning). -->
27
+ <meta-data
28
+ android:name="com.google.firebase.messaging.default_notification_channel_id"
29
+ android:value="usergist_default" />
30
+ </application>
31
+ </manifest>
@@ -0,0 +1,153 @@
1
+ package studio.usergist.feedback
2
+
3
+ import android.app.NotificationChannel
4
+ import android.app.NotificationManager
5
+ import android.app.PendingIntent
6
+ import android.content.Context
7
+ import android.content.Intent
8
+ import android.os.Build
9
+ import androidx.core.app.NotificationCompat
10
+ import androidx.core.app.NotificationManagerCompat
11
+ import com.facebook.react.bridge.Arguments
12
+ import com.facebook.react.bridge.WritableMap
13
+ import com.google.firebase.messaging.FirebaseMessagingService
14
+ import com.google.firebase.messaging.RemoteMessage
15
+ import org.json.JSONArray
16
+
17
+ /**
18
+ * Library-supplied FirebaseMessagingService. Auto-registered via the
19
+ * library AndroidManifest's manifest-merger entry — consumer apps don't
20
+ * write any Firebase code.
21
+ *
22
+ * - `onNewToken` : forward to JS so the SDK re-registers with the API.
23
+ * - `onMessageReceived` : (a) emit a `notificationReceived` JS event so
24
+ * the host can react, (b) post a system notification
25
+ * when the host app is backgrounded so the user sees
26
+ * the message even when no Activity is alive.
27
+ *
28
+ * If the host app already declares its own FirebaseMessagingService, it must
29
+ * disable this service with the `userGistFirebaseServiceEnabled` manifest
30
+ * placeholder and forward tokens/messages through the public Push API.
31
+ */
32
+ class UserGistFirebaseMessagingService : FirebaseMessagingService() {
33
+
34
+ override fun onNewToken(token: String) {
35
+ super.onNewToken(token)
36
+ UserGistPushEventBus.emitToken(token)
37
+ }
38
+
39
+ override fun onMessageReceived(remoteMessage: RemoteMessage) {
40
+ super.onMessageReceived(remoteMessage)
41
+
42
+ val payload = normalize(remoteMessage)
43
+ UserGistPushEventBus.emitNotificationReceived(payload)
44
+
45
+ // Surface a system notification when the user-visible content is set.
46
+ // Background / killed-state delivery on Android requires explicit
47
+ // NotificationManager.notify() — RemoteMessage doesn't auto-display.
48
+ val notification = remoteMessage.notification
49
+ val title = notification?.title ?: remoteMessage.data["title"] ?: remoteMessage.data["usergist_title"]
50
+ val body = notification?.body ?: remoteMessage.data["body"] ?: remoteMessage.data["usergist_body"]
51
+ if (title != null || body != null) {
52
+ postSystemNotification(
53
+ title = title,
54
+ body = body,
55
+ data = remoteMessage.data
56
+ )
57
+ }
58
+ }
59
+
60
+ private fun postSystemNotification(title: String?, body: String?, data: Map<String, String>) {
61
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
62
+ checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) != android.content.pm.PackageManager.PERMISSION_GRANTED
63
+ ) return
64
+ val channelId = resolveChannel(this, data["usergist_channel_id"] ?: data["channel_id"])
65
+ val launchIntent = packageManager?.getLaunchIntentForPackage(packageName)?.apply {
66
+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
67
+ // Forward delivery_id + usergist_delivery_id so when the user taps and
68
+ // the app launches we can fire `$push_opened` with the right id.
69
+ data.forEach { (k, v) ->
70
+ val key = if (k.startsWith("usergist_")) k else "usergist_$k"
71
+ putExtra(key, v)
72
+ }
73
+ }
74
+ val pendingFlags = PendingIntent.FLAG_UPDATE_CURRENT or
75
+ (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
76
+ val contentIntent = launchIntent?.let {
77
+ PendingIntent.getActivity(this, 0, it, pendingFlags)
78
+ }
79
+
80
+ val notifId = (System.currentTimeMillis() % Int.MAX_VALUE).toInt()
81
+ val builder = NotificationCompat.Builder(this, channelId)
82
+ .setSmallIcon(applicationInfo.icon.takeIf { it != 0 } ?: android.R.drawable.ic_dialog_info)
83
+ .setContentTitle(title)
84
+ .setContentText(body)
85
+ .setAutoCancel(true)
86
+ .setContentIntent(contentIntent)
87
+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
88
+
89
+ val rawActions = data["usergist_actions"]
90
+ if (!rawActions.isNullOrBlank() && launchIntent != null) {
91
+ runCatching {
92
+ val actions = JSONArray(rawActions)
93
+ for (index in 0 until actions.length()) {
94
+ val action = actions.optJSONObject(index) ?: continue
95
+ val label = action.optString("label").takeIf { it.isNotBlank() } ?: continue
96
+ val actionIntent = Intent(launchIntent).apply {
97
+ putExtra("usergist_action_identifier", "usergist_action_$index")
98
+ }
99
+ val actionPendingIntent = PendingIntent.getActivity(
100
+ this,
101
+ notifId * 10 + index + 1,
102
+ actionIntent,
103
+ pendingFlags,
104
+ )
105
+ builder.addAction(0, label, actionPendingIntent)
106
+ }
107
+ }
108
+ }
109
+
110
+ val nm = NotificationManagerCompat.from(this)
111
+ nm.notify(notifId, builder.build())
112
+ }
113
+
114
+ private fun ensureChannel(ctx: Context): String {
115
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return DEFAULT_CHANNEL_ID
116
+ val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
117
+ if (nm.getNotificationChannel(DEFAULT_CHANNEL_ID) == null) {
118
+ val channel = NotificationChannel(
119
+ DEFAULT_CHANNEL_ID,
120
+ "Notifications",
121
+ NotificationManager.IMPORTANCE_DEFAULT
122
+ )
123
+ nm.createNotificationChannel(channel)
124
+ }
125
+ return DEFAULT_CHANNEL_ID
126
+ }
127
+
128
+ private fun resolveChannel(ctx: Context, requested: String?): String {
129
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || requested.isNullOrBlank()) {
130
+ return ensureChannel(ctx)
131
+ }
132
+ val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
133
+ return if (nm.getNotificationChannel(requested) != null) requested else ensureChannel(ctx)
134
+ }
135
+
136
+ private fun normalize(msg: RemoteMessage): WritableMap {
137
+ val out = Arguments.createMap()
138
+ msg.notification?.title?.let { out.putString("title", it) }
139
+ msg.notification?.body?.let { out.putString("body", it) }
140
+ val data = Arguments.createMap()
141
+ for ((k, v) in msg.data) {
142
+ data.putString(k, v)
143
+ }
144
+ out.putMap("data", data)
145
+ val deliveryId = msg.data["delivery_id"] ?: msg.data["usergist_delivery_id"]
146
+ if (deliveryId != null) out.putString("deliveryId", deliveryId)
147
+ return out
148
+ }
149
+
150
+ companion object {
151
+ const val DEFAULT_CHANNEL_ID = "usergist_default"
152
+ }
153
+ }