@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,292 @@
1
+ import Foundation
2
+ import UserNotifications
3
+
4
+ /// Drop-in base class for the consumer's iOS Notification Service Extension.
5
+ ///
6
+ /// **Why an NSE is needed:** for media-rich pushes (image attachments,
7
+ /// rendered locally), sub-titled grouping, or custom content modification,
8
+ /// iOS requires a separate Xcode target whose `NotificationService` runs
9
+ /// in-process when each push arrives, BEFORE the system displays it.
10
+ /// The host app's main bundle cannot do this work — only the extension can.
11
+ /// (See: Apple "Modifying Content in Newly Delivered Notifications".)
12
+ ///
13
+ /// **How to install:**
14
+ /// 1. Xcode → File → New → Target → Notification Service Extension.
15
+ /// 2. Set the Bundle Identifier to `<your-main-bundle>.UserGistNotificationServiceExtension`.
16
+ /// 3. **Replace** the auto-generated `NotificationService.swift` with:
17
+ ///
18
+ /// ```swift
19
+ /// import UserNotifications
20
+ /// import UserGistFeedbackExtension
21
+ ///
22
+ /// class NotificationService: UserGistNotificationService { }
23
+ /// ```
24
+ ///
25
+ /// 4. Add `UserGistFeedback` to the extension target's Linked Frameworks
26
+ /// (it's the same pod the main app already uses; CocoaPods picks it
27
+ /// up via the extension's target stanza in your `Podfile`).
28
+ ///
29
+ /// **Configuration (Info.plist of the NSE target, or shared App Group):**
30
+ /// • `UserGistWriteKey` — required; write key the main SDK uses.
31
+ /// • `UserGistApiUrl` — optional; defaults to https://api.usergist.com.
32
+ /// • `UserGistAppGroup` — optional; App Group id shared with main app.
33
+ /// • `UserGistAnonymousId` — optional; written by main SDK at init for
34
+ /// silent-ack scoping.
35
+ ///
36
+ /// **What this base class does on each received push:**
37
+ /// 1. Detects silent reachability pings (`usergist_silent: "1"`) and acks
38
+ /// directly to /v1/sdk/push/silent-ack — never shows anything.
39
+ /// 2. For real notifications, fires `POST /v1/sdk/push/delivered` to
40
+ /// record true delivered_at (separate from opened_at).
41
+ /// 3. As a network-fail fallback, writes the delivery_id to a shared
42
+ /// App Group ledger; the main app drains it on next foreground.
43
+ /// 4. Honours an optional `subtitle` field.
44
+ /// 5. Downloads any image attachment from `extra.imageUrl` (UserGist
45
+ /// convention) or `fcm_options.image` (FCM convention) and attaches
46
+ /// it to the notification.
47
+ open class UserGistNotificationService: UNNotificationServiceExtension {
48
+
49
+ private var contentHandler: ((UNNotificationContent) -> Void)?
50
+ private var bestAttempt: UNMutableNotificationContent?
51
+ private let completionLock = NSLock()
52
+ private var didFinish = false
53
+
54
+ open override func didReceive(_ request: UNNotificationRequest,
55
+ withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
56
+ self.contentHandler = contentHandler
57
+ self.bestAttempt = request.content.mutableCopy() as? UNMutableNotificationContent
58
+
59
+ let userInfo = request.content.userInfo
60
+
61
+ // ---------- Silent reachability ping ----------
62
+ if (userInfo["usergist_silent"] as? String) == "1" {
63
+ let pingId = (userInfo["usergist_ping_id"] as? String) ?? ""
64
+ UserGistBeaconClient.silentAck(pingId: pingId) { [weak self] in
65
+ // Empty content — no banner, no sound, no badge.
66
+ self?.finish(UNNotificationContent())
67
+ }
68
+ return
69
+ }
70
+
71
+ guard let bestAttempt = self.bestAttempt else {
72
+ finish(request.content)
73
+ return
74
+ }
75
+
76
+ // ---------- Delivered beacon (direct, with App Group fallback) ----------
77
+ if let deliveryId = extractDeliveryId(from: userInfo) {
78
+ UserGistBeaconClient.delivered(deliveryId: deliveryId)
79
+ UserGistDeliveryLedger.recordDelivered(deliveryId: deliveryId)
80
+ }
81
+
82
+ if let subtitle = userInfo["subtitle"] as? String {
83
+ bestAttempt.subtitle = subtitle
84
+ }
85
+
86
+ // ---------- Rich-media attachment ----------
87
+ let imageUrlString = extractImageUrl(from: userInfo)
88
+
89
+ guard let raw = imageUrlString, let imageUrl = URL(string: raw) else {
90
+ finish(bestAttempt)
91
+ return
92
+ }
93
+
94
+ downloadAttachment(from: imageUrl) { attachment in
95
+ if let attachment = attachment {
96
+ bestAttempt.attachments = [attachment]
97
+ }
98
+ self.finish(bestAttempt)
99
+ }
100
+ }
101
+
102
+ open override func serviceExtensionTimeWillExpire() {
103
+ // Best effort: deliver whatever we have if Apple cuts us off (~30s).
104
+ if let bestAttempt = self.bestAttempt {
105
+ finish(bestAttempt)
106
+ }
107
+ }
108
+
109
+ private func finish(_ content: UNNotificationContent) {
110
+ completionLock.lock()
111
+ guard !didFinish else {
112
+ completionLock.unlock()
113
+ return
114
+ }
115
+ didFinish = true
116
+ let handler = contentHandler
117
+ contentHandler = nil
118
+ completionLock.unlock()
119
+ handler?(content)
120
+ }
121
+
122
+ // MARK: - Helpers
123
+
124
+ private func extractDeliveryId(from userInfo: [AnyHashable: Any]) -> String? {
125
+ if let usergist = userInfo["usergist"] as? [String: Any],
126
+ let id = usergist["deliveryId"] as? String {
127
+ return id
128
+ }
129
+ return (userInfo["delivery_id"] as? String)
130
+ ?? (userInfo["usergist_delivery_id"] as? String)
131
+ }
132
+
133
+ private func extractImageUrl(from userInfo: [AnyHashable: Any]) -> String? {
134
+ if let usergist = userInfo["usergist"] as? [String: Any],
135
+ let extra = usergist["extra"] as? [String: Any],
136
+ let url = extra["imageUrl"] as? String {
137
+ return url
138
+ }
139
+ if let direct = userInfo["image_url"] as? String { return direct }
140
+ if let fcm = userInfo["fcm_options"] as? [String: Any],
141
+ let image = fcm["image"] as? String {
142
+ return image
143
+ }
144
+ return nil
145
+ }
146
+
147
+ private func downloadAttachment(from url: URL, completion: @escaping (UNNotificationAttachment?) -> Void) {
148
+ let task = URLSession.shared.downloadTask(with: url) { tempLocation, response, _ in
149
+ guard let tempLocation = tempLocation else {
150
+ completion(nil)
151
+ return
152
+ }
153
+ let suggested = response?.suggestedFilename ?? url.lastPathComponent
154
+ let dest = URL(fileURLWithPath: NSTemporaryDirectory())
155
+ .appendingPathComponent(UUID().uuidString)
156
+ .appendingPathComponent(suggested)
157
+ do {
158
+ try FileManager.default.createDirectory(at: dest.deletingLastPathComponent(),
159
+ withIntermediateDirectories: true)
160
+ try FileManager.default.moveItem(at: tempLocation, to: dest)
161
+ let attachment = try UNNotificationAttachment(identifier: "usergist-image", url: dest, options: nil)
162
+ completion(attachment)
163
+ } catch {
164
+ completion(nil)
165
+ }
166
+ }
167
+ task.resume()
168
+ }
169
+ }
170
+
171
+ /// Network beacon client used by the NSE. NSEs *can* make network calls
172
+ /// reliably — Apple just gives us a tight 30s budget. We use 5s timeouts
173
+ /// + URLSession's background-friendly default config.
174
+ enum UserGistBeaconClient {
175
+
176
+ static func delivered(deliveryId: String) {
177
+ guard !deliveryId.isEmpty else { return }
178
+ guard let writeKey = UserGistNSEConfig.writeKey,
179
+ let apiUrl = UserGistNSEConfig.apiUrl else { return }
180
+
181
+ let url = apiUrl.appendingPathComponent("v1/sdk/push/delivered")
182
+ var req = URLRequest(url: url, timeoutInterval: 5)
183
+ req.httpMethod = "POST"
184
+ req.setValue("Bearer \(writeKey)", forHTTPHeaderField: "Authorization")
185
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
186
+ let body: [String: Any] = [
187
+ "deliveryId": deliveryId,
188
+ "occurredAt": ISO8601DateFormatter().string(from: Date()),
189
+ ]
190
+ req.httpBody = try? JSONSerialization.data(withJSONObject: body)
191
+ URLSession.shared.dataTask(with: req).resume()
192
+ }
193
+
194
+ static func silentAck(pingId: String, completion: @escaping () -> Void) {
195
+ guard !pingId.isEmpty else { return completion() }
196
+ guard let writeKey = UserGistNSEConfig.writeKey,
197
+ let apiUrl = UserGistNSEConfig.apiUrl,
198
+ let anonymousId = UserGistNSEConfig.anonymousId else { return completion() }
199
+
200
+ let url = apiUrl.appendingPathComponent("v1/sdk/push/silent-ack")
201
+ var req = URLRequest(url: url, timeoutInterval: 5)
202
+ req.httpMethod = "POST"
203
+ req.setValue("Bearer \(writeKey)", forHTTPHeaderField: "Authorization")
204
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
205
+ let body: [String: Any] = [
206
+ "pingId": pingId,
207
+ "anonymousId": anonymousId,
208
+ "receivedAt": ISO8601DateFormatter().string(from: Date()),
209
+ ]
210
+ req.httpBody = try? JSONSerialization.data(withJSONObject: body)
211
+ let lock = NSLock()
212
+ var completed = false
213
+ let finish = {
214
+ lock.lock()
215
+ guard !completed else {
216
+ lock.unlock()
217
+ return
218
+ }
219
+ completed = true
220
+ lock.unlock()
221
+ completion()
222
+ }
223
+ let task = URLSession.shared.dataTask(with: req) { _, _, _ in
224
+ finish()
225
+ }
226
+ task.resume()
227
+ // Safety net so we still call contentHandler if the request hangs.
228
+ DispatchQueue.main.asyncAfter(deadline: .now() + 5) { finish() }
229
+ }
230
+ }
231
+
232
+ /// Configuration plumbing. Reads from the App Group's UserDefaults first
233
+ /// (so the main SDK can rotate values without requiring an extension
234
+ /// rebuild) then falls back to the extension target's Info.plist.
235
+ enum UserGistNSEConfig {
236
+
237
+ static var writeKey: String? { return readString(key: "UserGistWriteKey") }
238
+ static var anonymousId: String? { return readString(key: "UserGistAnonymousId") }
239
+ static var apiUrl: URL? {
240
+ let raw = readString(key: "UserGistApiUrl") ?? "https://api.usergist.com"
241
+ return URL(string: raw)
242
+ }
243
+
244
+ private static func readString(key: String) -> String? {
245
+ if let group = Bundle.main.object(forInfoDictionaryKey: "UserGistAppGroup") as? String,
246
+ let defaults = UserDefaults(suiteName: group),
247
+ let value = defaults.string(forKey: key),
248
+ !value.isEmpty {
249
+ return value
250
+ }
251
+ if let value = Bundle.main.object(forInfoDictionaryKey: key) as? String,
252
+ !value.isEmpty {
253
+ return value
254
+ }
255
+ return nil
256
+ }
257
+ }
258
+
259
+ /// Tiny App Group ledger used by the NSE as a fallback when the direct
260
+ /// beacon couldn't fire (network down at receive time). The main app
261
+ /// drains it on next launch and re-fires the beacons.
262
+ public enum UserGistDeliveryLedger {
263
+
264
+ /// Override at runtime if the consumer's App Group identifier differs.
265
+ /// Default: read from `UserGistAppGroup` in the main bundle's Info.plist.
266
+ public static var appGroupIdentifier: String? = nil
267
+
268
+ private static var defaults: UserDefaults? {
269
+ let id = appGroupIdentifier
270
+ ?? Bundle.main.object(forInfoDictionaryKey: "UserGistAppGroup") as? String
271
+ guard let id = id else { return nil }
272
+ return UserDefaults(suiteName: id)
273
+ }
274
+
275
+ static func recordDelivered(deliveryId: String) {
276
+ guard let defaults = defaults else { return }
277
+ var pending = defaults.array(forKey: "usergist_pending_delivered") as? [String] ?? []
278
+ if !pending.contains(deliveryId) {
279
+ pending.append(deliveryId)
280
+ defaults.set(pending, forKey: "usergist_pending_delivered")
281
+ }
282
+ }
283
+
284
+ /// Called from the main app to drain + clear the ledger. Returns the IDs
285
+ /// the NSE recorded since the last drain.
286
+ public static func drainPendingDelivered() -> [String] {
287
+ guard let defaults = defaults else { return [] }
288
+ let ids = defaults.array(forKey: "usergist_pending_delivered") as? [String] ?? []
289
+ defaults.removeObject(forKey: "usergist_pending_delivered")
290
+ return ids
291
+ }
292
+ }
@@ -0,0 +1,16 @@
1
+ #import <React/RCTBridgeModule.h>
2
+ #import <React/RCTEventEmitter.h>
3
+ #import <Foundation/Foundation.h>
4
+
5
+ // Public Objective-C bridge. The actual implementation lives in
6
+ // UserGistPushImpl.swift; this class is a thin shim that React Native's
7
+ // runtime can instantiate (RN's auto-discovery requires an Obj-C class
8
+ // or a TurboModule registration).
9
+ @interface UserGistPush : RCTEventEmitter <RCTBridgeModule>
10
+ @end
11
+
12
+ // Exposed by UserGistPushSwizzle.m. Returns whoever last tried to install
13
+ // themselves as the UNUserNotificationCenter delegate (FirebaseAnalytics,
14
+ // OneSignal, etc.). The Swift delegate uses this to chain didReceive /
15
+ // willPresent so other SDKs' analytics still fire while we keep ownership.
16
+ NSObject * _Nullable _UserGistGetLastForeignDelegate(void);
@@ -0,0 +1,158 @@
1
+ #import "UserGistPush.h"
2
+ #import <React/RCTLog.h>
3
+ #import <Security/Security.h>
4
+
5
+ #if __has_include("UserGistFeedback-Swift.h")
6
+ #import "UserGistFeedback-Swift.h"
7
+ #else
8
+ #import <UserGistFeedback/UserGistFeedback-Swift.h>
9
+ #endif
10
+
11
+ // Bridges the React Native runtime to UserGistPushImpl.swift. We expose
12
+ // the module under the JS name "UserGistPush" and forward every method
13
+ // call (and outgoing event) to the singleton Swift impl.
14
+ //
15
+ // Events the Swift side emits and JS subscribes to:
16
+ // - "UserGistPush:tokenReceived" — APNs token captured
17
+ // - "UserGistPush:tokenError" — APNs registration failed
18
+ // - "UserGistPush:notificationReceived" — foreground delivery
19
+ // - "UserGistPush:notificationOpened" — user tapped a notification
20
+ @interface UserGistPush ()
21
+ @end
22
+
23
+ @implementation UserGistPush
24
+
25
+ RCT_EXPORT_MODULE(UserGistPush)
26
+
27
+ + (BOOL)requiresMainQueueSetup {
28
+ // We register UNUserNotificationCenter delegates on the main thread and
29
+ // bind to UIApplication; doing the setup on the main queue avoids early
30
+ // dispatch races during app launch.
31
+ return YES;
32
+ }
33
+
34
+ - (instancetype)init {
35
+ if ((self = [super init])) {
36
+ [UserGistPushImpl.shared bindEmitter:self];
37
+ }
38
+ return self;
39
+ }
40
+
41
+ - (NSArray<NSString *> *)supportedEvents {
42
+ return @[
43
+ @"UserGistPush:tokenReceived",
44
+ @"UserGistPush:tokenError",
45
+ @"UserGistPush:notificationReceived",
46
+ @"UserGistPush:notificationOpened",
47
+ ];
48
+ }
49
+
50
+ - (void)startObserving {
51
+ [UserGistPushImpl.shared setHasJsListeners:YES];
52
+ }
53
+
54
+ - (void)stopObserving {
55
+ [UserGistPushImpl.shared setHasJsListeners:NO];
56
+ }
57
+
58
+ // MARK: - Exported methods
59
+
60
+ RCT_EXPORT_METHOD(enablePush:(NSDictionary *)options
61
+ resolver:(RCTPromiseResolveBlock)resolve
62
+ rejecter:(RCTPromiseRejectBlock)reject) {
63
+ [UserGistPushImpl.shared enablePushWithOptions:options resolver:resolve rejecter:reject];
64
+ }
65
+
66
+ RCT_EXPORT_METHOD(disablePush:(RCTPromiseResolveBlock)resolve
67
+ rejecter:(RCTPromiseRejectBlock)reject) {
68
+ [UserGistPushImpl.shared disablePushWithResolver:resolve rejecter:reject];
69
+ }
70
+
71
+ RCT_EXPORT_METHOD(getPushPermissionStatus:(RCTPromiseResolveBlock)resolve
72
+ rejecter:(RCTPromiseRejectBlock)reject) {
73
+ [UserGistPushImpl.shared getPermissionStatusWithResolver:resolve rejecter:reject];
74
+ }
75
+
76
+ RCT_EXPORT_METHOD(setBadgeCount:(double)count
77
+ resolver:(RCTPromiseResolveBlock)resolve
78
+ rejecter:(RCTPromiseRejectBlock)reject) {
79
+ [UserGistPushImpl.shared setBadgeCount:count resolver:resolve rejecter:reject];
80
+ }
81
+
82
+ RCT_EXPORT_METHOD(getInitialNotification:(RCTPromiseResolveBlock)resolve
83
+ rejecter:(RCTPromiseRejectBlock)reject) {
84
+ [UserGistPushImpl.shared getInitialNotificationWithResolver:resolve rejecter:reject];
85
+ }
86
+
87
+ static NSString * const UserGistSecureService = @"studio.usergist.feedback.react-native";
88
+
89
+ static NSMutableDictionary *UserGistSecureQuery(NSString *key) {
90
+ return [@{
91
+ (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword,
92
+ (__bridge id)kSecAttrService: UserGistSecureService,
93
+ (__bridge id)kSecAttrAccount: key,
94
+ } mutableCopy];
95
+ }
96
+
97
+ RCT_EXPORT_METHOD(secureGetItem:(NSString *)key
98
+ resolver:(RCTPromiseResolveBlock)resolve
99
+ rejecter:(RCTPromiseRejectBlock)reject) {
100
+ NSMutableDictionary *query = UserGistSecureQuery(key);
101
+ query[(__bridge id)kSecReturnData] = @YES;
102
+ query[(__bridge id)kSecMatchLimit] = (__bridge id)kSecMatchLimitOne;
103
+ CFTypeRef result = NULL;
104
+ OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
105
+ if (status == errSecItemNotFound) { resolve(nil); return; }
106
+ if (status != errSecSuccess) {
107
+ reject(@"secure_read_failed", [NSString stringWithFormat:@"Keychain read failed: %d", (int)status], nil);
108
+ return;
109
+ }
110
+ NSData *data = CFBridgingRelease(result);
111
+ resolve([[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]);
112
+ }
113
+
114
+ RCT_EXPORT_METHOD(secureSetItem:(NSString *)key
115
+ value:(NSString *)value
116
+ resolver:(RCTPromiseResolveBlock)resolve
117
+ rejecter:(RCTPromiseRejectBlock)reject) {
118
+ NSData *data = [value dataUsingEncoding:NSUTF8StringEncoding];
119
+ NSMutableDictionary *query = UserGistSecureQuery(key);
120
+ OSStatus status = SecItemUpdate((__bridge CFDictionaryRef)query,
121
+ (__bridge CFDictionaryRef)@{(__bridge id)kSecValueData: data});
122
+ if (status == errSecItemNotFound) {
123
+ query[(__bridge id)kSecValueData] = data;
124
+ query[(__bridge id)kSecAttrAccessible] = (__bridge id)kSecAttrAccessibleAfterFirstUnlock;
125
+ status = SecItemAdd((__bridge CFDictionaryRef)query, NULL);
126
+ }
127
+ if (status != errSecSuccess) {
128
+ reject(@"secure_write_failed", [NSString stringWithFormat:@"Keychain write failed: %d", (int)status], nil);
129
+ return;
130
+ }
131
+ resolve(nil);
132
+ }
133
+
134
+ RCT_EXPORT_METHOD(secureRemoveItem:(NSString *)key
135
+ resolver:(RCTPromiseResolveBlock)resolve
136
+ rejecter:(RCTPromiseRejectBlock)reject) {
137
+ OSStatus status = SecItemDelete((__bridge CFDictionaryRef)UserGistSecureQuery(key));
138
+ if (status != errSecSuccess && status != errSecItemNotFound) {
139
+ reject(@"secure_remove_failed", [NSString stringWithFormat:@"Keychain delete failed: %d", (int)status], nil);
140
+ return;
141
+ }
142
+ resolve(nil);
143
+ }
144
+
145
+ RCT_EXPORT_METHOD(secureMultiRemove:(NSArray<NSString *> *)keys
146
+ resolver:(RCTPromiseResolveBlock)resolve
147
+ rejecter:(RCTPromiseRejectBlock)reject) {
148
+ for (NSString *key in keys) {
149
+ OSStatus status = SecItemDelete((__bridge CFDictionaryRef)UserGistSecureQuery(key));
150
+ if (status != errSecSuccess && status != errSecItemNotFound) {
151
+ reject(@"secure_remove_failed", [NSString stringWithFormat:@"Keychain delete failed: %d", (int)status], nil);
152
+ return;
153
+ }
154
+ }
155
+ resolve(nil);
156
+ }
157
+
158
+ @end