@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,399 @@
1
+ import Foundation
2
+ import UIKit
3
+ import UserNotifications
4
+
5
+ /// Self-contained iOS push handler for `@usergist/feedback-react-native`.
6
+ ///
7
+ /// Owns:
8
+ /// 1. UNUserNotificationCenter authorization + APNs registration.
9
+ /// 2. Capturing the APNs device token via swizzling so consumers don't
10
+ /// have to edit `AppDelegate`.
11
+ /// 3. Foreground notification banners + tap delivery via swizzled
12
+ /// `UNUserNotificationCenterDelegate`.
13
+ /// 4. Forwarding every signal (`tokenReceived`, `notificationReceived`,
14
+ /// `notificationOpened`, `tokenError`) to the JS bridge.
15
+ ///
16
+ /// `UserGistPushImpl` is a singleton. The Obj-C `UserGistPush` class binds an
17
+ /// `RCTEventEmitter` reference at module-init time so the Swift side can
18
+ /// emit events without owning the RN bridge type.
19
+ @objc(UserGistPushImpl)
20
+ public final class UserGistPushImpl: NSObject {
21
+
22
+ // MARK: - Singleton
23
+
24
+ @objc public static let shared = UserGistPushImpl()
25
+
26
+ private weak var emitter: AnyObject?
27
+ private let stateLock = NSLock()
28
+ private var hasJsListeners: Bool = false
29
+
30
+ /// Buffer of events that arrived before JS was ready to listen. Drained
31
+ /// the moment `startObserving` fires.
32
+ private var pendingEvents: [(name: String, body: [String: Any])] = []
33
+
34
+ /// The APNs token captured during the most recent registration cycle.
35
+ /// Cached so the JS layer can ask for it without forcing a re-prompt.
36
+ private var lastApnsTokenHex: String?
37
+
38
+ /// The notification (if any) the user tapped while the app was killed
39
+ /// — surfaced via `getInitialNotification` so the host can navigate.
40
+ private var initialNotification: [String: Any]?
41
+
42
+ private override init() {
43
+ super.init()
44
+ }
45
+
46
+ /// Exposed to Obj-C so UserGistPushSwizzle.m's setDelegate: interceptor
47
+ /// can fetch the shared UN delegate and reinstall it whenever some
48
+ /// other SDK (FirebaseAnalytics, OneSignal, etc.) tries to take over.
49
+ @objc public static func usergistUNDelegate() -> AnyObject {
50
+ return UserGistUNDelegateSwizzler.sharedDelegateAccessor
51
+ }
52
+
53
+ // MARK: - Bridge wiring
54
+
55
+ /// Called by `UserGistPush.mm -init`. Swift never imports React-Core, so we
56
+ /// take an opaque reference and reflect back through `@objc selector`s.
57
+ @objc public func bindEmitter(_ emitter: AnyObject) {
58
+ stateLock.lock()
59
+ self.emitter = emitter
60
+ stateLock.unlock()
61
+ }
62
+
63
+ @objc public func setHasJsListeners(_ value: Bool) {
64
+ stateLock.lock()
65
+ self.hasJsListeners = value
66
+ stateLock.unlock()
67
+ if value {
68
+ drainPendingEvents()
69
+ }
70
+ }
71
+
72
+ // MARK: - Exported (called from UserGistPush.mm)
73
+
74
+ @objc public func enablePush(options: [String: Any],
75
+ resolver: @escaping (Any?) -> Void,
76
+ rejecter: @escaping (String, String, Error?) -> Void) {
77
+ NSLog("[UserGistPush] enablePush called")
78
+ let installDelegateProxy = options["installDelegateProxy"] as? Bool ?? true
79
+ if installDelegateProxy {
80
+ Self.installUNDelegateSwizzleIfNeeded()
81
+ if let swizzler: AnyClass = NSClassFromString("UserGistPushSwizzle") {
82
+ let selector = NSSelectorFromString("installIfNeeded")
83
+ let target = swizzler as AnyObject
84
+ if target.responds(to: selector) {
85
+ _ = target.perform(selector)
86
+ }
87
+ }
88
+ }
89
+ UNUserNotificationCenter.current().requestAuthorization(
90
+ options: [.alert, .badge, .sound, .providesAppNotificationSettings]
91
+ ) { [weak self] granted, error in
92
+ NSLog("[UserGistPush] requestAuthorization callback granted=\(granted) error=\(String(describing: error))")
93
+ if let error = error {
94
+ rejecter("auth_error", error.localizedDescription, error)
95
+ return
96
+ }
97
+ let status: String = granted ? "authorized" : "denied"
98
+ DispatchQueue.main.async {
99
+ if granted {
100
+ NSLog("[UserGistPush] calling UIApplication.registerForRemoteNotifications")
101
+ UIApplication.shared.registerForRemoteNotifications()
102
+ }
103
+ // Token arrives asynchronously via the swizzled
104
+ // `application:didRegisterForRemoteNotificationsWithDeviceToken:`
105
+ // callback. Resolve immediately with whatever we have cached so the
106
+ // caller has a result even if the token is still in flight; the
107
+ // `tokenReceived` event will fire shortly with the value.
108
+ resolver([
109
+ "granted": granted,
110
+ "status": status,
111
+ "token": self?.readLastApnsToken() as Any,
112
+ "platform": "ios"
113
+ ])
114
+ }
115
+ }
116
+ }
117
+
118
+ @objc public func disablePush(resolver: @escaping (Any?) -> Void,
119
+ rejecter: @escaping (String, String, Error?) -> Void) {
120
+ DispatchQueue.main.async {
121
+ UIApplication.shared.unregisterForRemoteNotifications()
122
+ self.stateLock.lock()
123
+ self.lastApnsTokenHex = nil
124
+ self.stateLock.unlock()
125
+ resolver(nil)
126
+ }
127
+ }
128
+
129
+ @objc public func getPermissionStatus(resolver: @escaping (Any?) -> Void,
130
+ rejecter: @escaping (String, String, Error?) -> Void) {
131
+ UNUserNotificationCenter.current().getNotificationSettings { settings in
132
+ let s: String
133
+ switch settings.authorizationStatus {
134
+ case .authorized: s = "authorized"
135
+ case .denied: s = "denied"
136
+ case .notDetermined: s = "not_determined"
137
+ case .provisional: s = "provisional"
138
+ case .ephemeral: s = "authorized"
139
+ @unknown default: s = "not_determined"
140
+ }
141
+ resolver(s)
142
+ }
143
+ }
144
+
145
+ @objc public func setBadgeCount(_ count: Double,
146
+ resolver: @escaping (Any?) -> Void,
147
+ rejecter: @escaping (String, String, Error?) -> Void) {
148
+ DispatchQueue.main.async {
149
+ let center = UNUserNotificationCenter.current()
150
+ let n = Int(count)
151
+ if #available(iOS 16.0, *) {
152
+ center.setBadgeCount(n) { error in
153
+ if let error = error {
154
+ rejecter("badge_failed", error.localizedDescription, error)
155
+ } else {
156
+ resolver(nil)
157
+ }
158
+ }
159
+ } else {
160
+ UIApplication.shared.applicationIconBadgeNumber = n
161
+ resolver(nil)
162
+ }
163
+ }
164
+ }
165
+
166
+ @objc public func getInitialNotification(resolver: @escaping (Any?) -> Void,
167
+ rejecter: @escaping (String, String, Error?) -> Void) {
168
+ stateLock.lock()
169
+ let notification = initialNotification
170
+ initialNotification = nil
171
+ stateLock.unlock()
172
+ resolver(notification as Any?)
173
+ }
174
+
175
+ // MARK: - Internal — emission helpers (called from swizzles)
176
+
177
+ @objc public func recordToken(deviceToken: Data) {
178
+ let hex = deviceToken.map { String(format: "%02x", $0) }.joined()
179
+ NSLog("[UserGistPush] APNs token received (\(hex.count) hex chars) — emitting tokenReceived")
180
+ stateLock.lock()
181
+ lastApnsTokenHex = hex
182
+ stateLock.unlock()
183
+ emit(name: "UserGistPush:tokenReceived", body: ["token": hex, "platform": "ios"])
184
+ }
185
+
186
+ @objc public func recordTokenError(error: Error) {
187
+ NSLog("[UserGistPush] APNs registration FAILED: \(error.localizedDescription)")
188
+ emit(name: "UserGistPush:tokenError",
189
+ body: ["error": error.localizedDescription])
190
+ }
191
+
192
+ func recordNotificationReceived(userInfo: [AnyHashable: Any]) {
193
+ let normalized = normalizeUserInfo(userInfo)
194
+ emit(name: "UserGistPush:notificationReceived", body: normalized)
195
+ }
196
+
197
+ func recordNotificationOpened(userInfo: [AnyHashable: Any], actionIdentifier: String?) {
198
+ var body = normalizeUserInfo(userInfo)
199
+ if let actionIdentifier = actionIdentifier, actionIdentifier != "com.apple.UNNotificationDefaultActionIdentifier" {
200
+ body["actionIdentifier"] = actionIdentifier
201
+ }
202
+ stateLock.lock()
203
+ if initialNotification == nil {
204
+ // First open captured — surface via getInitialNotification.
205
+ initialNotification = body
206
+ }
207
+ stateLock.unlock()
208
+ emit(name: "UserGistPush:notificationOpened", body: body)
209
+ }
210
+
211
+ // MARK: - Internal — emit + normalize
212
+
213
+ private func emit(name: String, body: [String: Any]) {
214
+ stateLock.lock()
215
+ guard hasJsListeners else {
216
+ NSLog("[UserGistPush] emit BUFFER \(name) — JS not listening yet (pending=\(pendingEvents.count + 1))")
217
+ if pendingEvents.count >= 100 { pendingEvents.removeFirst() }
218
+ pendingEvents.append((name, body))
219
+ stateLock.unlock()
220
+ return
221
+ }
222
+ stateLock.unlock()
223
+ NSLog("[UserGistPush] emit SEND \(name)")
224
+ sendToEmitter(name: name, body: body)
225
+ }
226
+
227
+ private func drainPendingEvents() {
228
+ stateLock.lock()
229
+ let buffered = pendingEvents
230
+ pendingEvents = []
231
+ stateLock.unlock()
232
+ for ev in buffered {
233
+ sendToEmitter(name: ev.name, body: ev.body)
234
+ }
235
+ }
236
+
237
+ private func sendToEmitter(name: String, body: [String: Any]) {
238
+ stateLock.lock()
239
+ let target = emitter
240
+ stateLock.unlock()
241
+ guard let emitter = target else { return }
242
+ // RCTEventEmitter exposes `sendEventWithName:body:`. Reflect to call
243
+ // it without importing React-Core in Swift (keeps the Swift module
244
+ // free of React headers, which simplifies new-arch interop).
245
+ let sel = NSSelectorFromString("sendEventWithName:body:")
246
+ guard emitter.responds(to: sel) else { return }
247
+ _ = emitter.perform(sel, with: name, with: body as NSDictionary)
248
+ }
249
+
250
+ private func readLastApnsToken() -> String? {
251
+ stateLock.lock()
252
+ defer { stateLock.unlock() }
253
+ return lastApnsTokenHex
254
+ }
255
+
256
+ private func normalizeUserInfo(_ userInfo: [AnyHashable: Any]) -> [String: Any] {
257
+ var out: [String: Any] = [:]
258
+ var aps: [String: Any]?
259
+ var data: [String: Any] = [:]
260
+ for (k, v) in userInfo {
261
+ guard let key = k as? String else { continue }
262
+ if key == "aps", let dict = v as? [String: Any] {
263
+ aps = dict
264
+ } else {
265
+ data[key] = v
266
+ }
267
+ }
268
+ if let alert = aps?["alert"] as? [String: Any] {
269
+ if let title = alert["title"] as? String { out["title"] = title }
270
+ if let body = alert["body"] as? String { out["body"] = body }
271
+ } else if let alertString = aps?["alert"] as? String {
272
+ out["body"] = alertString
273
+ }
274
+ out["data"] = data
275
+ // delivery_id can land in three places depending on payload shape:
276
+ // 1. flat data["usergist_delivery_id"] — FCM data-only path
277
+ // 2. flat data["delivery_id"] — legacy / generic path
278
+ // 3. nested data["usergist"]["deliveryId"] — current APNs payload
279
+ // Without fallback (3), iOS opens land with deliveryId=nil and the
280
+ // server-side patcher early-returns → opened_at never updates.
281
+ let nestedUserGist = data["usergist"] as? [String: Any]
282
+ if let deliveryId =
283
+ (data["delivery_id"] as? String)
284
+ ?? (data["usergist_delivery_id"] as? String)
285
+ ?? (nestedUserGist?["deliveryId"] as? String)
286
+ {
287
+ out["deliveryId"] = deliveryId
288
+ }
289
+ if let actionId = nestedUserGist?["actionIdentifier"] as? String {
290
+ // Carry a server-set action id through if present (none today, future-proof).
291
+ out["actionIdentifier"] = actionId
292
+ }
293
+ return out
294
+ }
295
+
296
+ // MARK: - UN delegate swizzle install
297
+
298
+ private static var unDelegateInstalled = false
299
+
300
+ private static func installUNDelegateSwizzleIfNeeded() {
301
+ guard !unDelegateInstalled else { return }
302
+ unDelegateInstalled = true
303
+ // Install synchronously after enablePush opts into automatic integration.
304
+ UserGistUNDelegateSwizzler.install()
305
+ // Re-install whenever the app foregrounds. Belt-and-braces against
306
+ // any SDK that re-takes the delegate while the app is backgrounded.
307
+ NotificationCenter.default.addObserver(
308
+ forName: UIApplication.didBecomeActiveNotification,
309
+ object: nil,
310
+ queue: .main,
311
+ ) { _ in
312
+ UserGistUNDelegateSwizzler.reinstallIfHijacked()
313
+ }
314
+ }
315
+ }
316
+
317
+ // MARK: - AppDelegate swizzler (APNs token capture)
318
+
319
+ // AppDelegate APNs swizzle has moved to UserGistPushSwizzle.m (pure Obj-C,
320
+ // installed via +load + UIApplicationDidFinishLaunchingNotification so it
321
+ // lands before any iOS push registration cycle). The Obj-C bridge calls
322
+ // `[UserGistPushImpl.shared recordTokenWithDeviceToken:]` /
323
+ // `recordTokenErrorWithError:` from the swizzled IMP.
324
+
325
+ // MARK: - UNUserNotificationCenterDelegate swizzler (foreground + open)
326
+
327
+ @objc private final class UserGistUNDelegateSwizzler: NSObject, UNUserNotificationCenterDelegate {
328
+
329
+ fileprivate static let sharedDelegate = UserGistUNDelegateSwizzler()
330
+ /// Public access to the shared instance for the Obj-C swizzle bridge.
331
+ @objc fileprivate static var sharedDelegateAccessor: AnyObject { return sharedDelegate }
332
+ private weak var existingDelegate: UNUserNotificationCenterDelegate?
333
+
334
+ fileprivate static func install() {
335
+ let center = UNUserNotificationCenter.current()
336
+ if let existing = center.delegate, existing !== sharedDelegate {
337
+ sharedDelegate.existingDelegate = existing
338
+ }
339
+ center.delegate = sharedDelegate
340
+ NSLog("[UserGistPush] UN delegate installed (chained=\(sharedDelegate.existingDelegate != nil))")
341
+ }
342
+
343
+ /// Re-install if some other SDK has stolen the delegate since we last set it.
344
+ /// Called on every foreground transition.
345
+ fileprivate static func reinstallIfHijacked() {
346
+ let center = UNUserNotificationCenter.current()
347
+ guard center.delegate !== sharedDelegate else { return }
348
+ NSLog("[UserGistPush] delegate hijacked by \(String(describing: type(of: center.delegate))) — reinstalling")
349
+ if let existing = center.delegate, existing !== sharedDelegate {
350
+ sharedDelegate.existingDelegate = existing
351
+ }
352
+ center.delegate = sharedDelegate
353
+ }
354
+
355
+ /// Resolve the foreign delegate to chain to: prefer the most recent one
356
+ /// captured by the Obj-C setDelegate: interceptor (catches Firebase /
357
+ /// OneSignal / etc. that install AFTER our swizzle ran), fall back to
358
+ /// whatever was on the center when our swizzle first installed.
359
+ private func chainTarget() -> UNUserNotificationCenterDelegate? {
360
+ if let foreign = _UserGistGetLastForeignDelegate() as? UNUserNotificationCenterDelegate,
361
+ foreign !== self {
362
+ return foreign
363
+ }
364
+ return existingDelegate
365
+ }
366
+
367
+ // Foreground delivery
368
+ func userNotificationCenter(_ center: UNUserNotificationCenter,
369
+ willPresent notification: UNNotification,
370
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
371
+ UserGistPushImpl.shared.recordNotificationReceived(userInfo: notification.request.content.userInfo)
372
+ if let existing = chainTarget(),
373
+ existing.responds(to: #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:willPresent:withCompletionHandler:))) {
374
+ existing.userNotificationCenter?(center, willPresent: notification, withCompletionHandler: completionHandler)
375
+ return
376
+ }
377
+ if #available(iOS 14.0, *) {
378
+ completionHandler([.banner, .list, .sound, .badge])
379
+ } else {
380
+ completionHandler([.alert, .sound, .badge])
381
+ }
382
+ }
383
+
384
+ // Tap / action
385
+ func userNotificationCenter(_ center: UNUserNotificationCenter,
386
+ didReceive response: UNNotificationResponse,
387
+ withCompletionHandler completionHandler: @escaping () -> Void) {
388
+ UserGistPushImpl.shared.recordNotificationOpened(
389
+ userInfo: response.notification.request.content.userInfo,
390
+ actionIdentifier: response.actionIdentifier
391
+ )
392
+ if let existing = chainTarget(),
393
+ existing.responds(to: #selector(UNUserNotificationCenterDelegate.userNotificationCenter(_:didReceive:withCompletionHandler:))) {
394
+ existing.userNotificationCenter?(center, didReceive: response, withCompletionHandler: completionHandler)
395
+ return
396
+ }
397
+ completionHandler()
398
+ }
399
+ }
@@ -0,0 +1,168 @@
1
+ // Pure Obj-C swizzle for capturing the APNs device token from the host
2
+ // app's UIApplicationDelegate without requiring any AppDelegate edits.
3
+ //
4
+ // Why Obj-C: Swift methods even with `@objc dynamic` go through Swift's
5
+ // vtable thunking. When method_getImplementation returns the IMP, calling
6
+ // it on a `self` of a different class can hit Swift-specific guards that
7
+ // silently no-op the call. A bare Obj-C IMP installed via
8
+ // `imp_implementationWithBlock` has the standard objc_msgSend ABI and runs
9
+ // on any NSObject subclass without surprises.
10
+ //
11
+ // Installation is explicit and happens only after the host calls enablePush.
12
+ // At that point UIApplication already has a delegate, and interception is in
13
+ // place before registerForRemoteNotifications starts the APNs token cycle.
14
+
15
+ #import <Foundation/Foundation.h>
16
+ #import <UIKit/UIKit.h>
17
+ #import <UserNotifications/UserNotifications.h>
18
+ #import <objc/runtime.h>
19
+ #import <objc/message.h>
20
+
21
+ #if __has_include("UserGistFeedback-Swift.h")
22
+ #import "UserGistFeedback-Swift.h"
23
+ #else
24
+ #import <UserGistFeedback/UserGistFeedback-Swift.h>
25
+ #endif
26
+
27
+ @interface UserGistPushSwizzle : NSObject
28
+ @end
29
+
30
+ @implementation UserGistPushSwizzle
31
+
32
+ + (void)load { }
33
+
34
+ + (void)installIfNeeded {
35
+ static dispatch_once_t onceToken;
36
+ dispatch_once(&onceToken, ^{
37
+ [self installUNDelegateSetterSwizzle];
38
+
39
+ // Force-instantiate the Swift singleton so its `init` runs and our
40
+ // shared UN delegate is available the very first time the setter
41
+ // swizzle fires. Otherwise the first foreign `setDelegate:` call
42
+ // (typically Firebase) would still hit the original setter because
43
+ // usergistUNDelegate() returned nil.
44
+ Class implCls = NSClassFromString(@"UserGistFeedback.UserGistPushImpl");
45
+ if (!implCls) implCls = NSClassFromString(@"UserGistPushImpl");
46
+ if (implCls) {
47
+ SEL sharedSel = NSSelectorFromString(@"shared");
48
+ if ([implCls respondsToSelector:sharedSel]) {
49
+ (void)((id (*)(id, SEL))objc_msgSend)(implCls, sharedSel);
50
+ NSLog(@"[UserGistPush] +load: UserGistPushImpl.shared force-initialized");
51
+ }
52
+ }
53
+
54
+ [self _usergistInstallSwizzles:nil];
55
+ });
56
+ }
57
+
58
+ + (void)_usergistInstallSwizzles:(NSNotification *)note {
59
+ Class delegateClass = [[[UIApplication sharedApplication] delegate] class];
60
+ if (!delegateClass) {
61
+ NSLog(@"[UserGistPush] opt-in swizzle: no AppDelegate — skipping");
62
+ return;
63
+ }
64
+ NSLog(@"[UserGistPush] opt-in swizzle: target class = %@", NSStringFromClass(delegateClass));
65
+
66
+ [self installDidRegister:delegateClass];
67
+ [self installDidFail:delegateClass];
68
+
69
+ NSLog(@"[UserGistPush] opt-in swizzle: complete");
70
+ }
71
+
72
+ // Holds whoever the host (or another SDK like FirebaseAnalytics) most
73
+ // recently tried to install as the UNUserNotificationCenter delegate.
74
+ // Read by UserGistUNDelegateSwizzler so it can chain didReceive / willPresent
75
+ // to the displaced delegate.
76
+ static __weak id<NSObject> _usergist_lastForeignDelegate = nil;
77
+
78
+ // Swizzle UNUserNotificationCenter.setDelegate: so any code that tries to
79
+ // install its own delegate (FirebaseAnalytics, OneSignal, Notifee, the
80
+ // host app itself) is intercepted. Their delegate is captured as the
81
+ // "foreign" chain target; our shared delegate stays installed.
82
+ + (void)installUNDelegateSetterSwizzle {
83
+ Class cls = [UNUserNotificationCenter class];
84
+ SEL setterSel = @selector(setDelegate:);
85
+ Method existing = class_getInstanceMethod(cls, setterSel);
86
+ if (!existing) {
87
+ NSLog(@"[UserGistPush] setDelegate: not found on UNUserNotificationCenter — skipping");
88
+ return;
89
+ }
90
+ IMP origImp = method_getImplementation(existing);
91
+ IMP newImp = imp_implementationWithBlock(
92
+ ^(UNUserNotificationCenter *_self, id<NSObject> incoming) {
93
+ // Pull the Swift-side shared delegate via the public Swift class.
94
+ // UserGistFeedback-Swift.h exposes UserGistPushImpl; we route through
95
+ // it to install + record the foreign chain target.
96
+ Class implCls = NSClassFromString(@"UserGistFeedback.UserGistPushImpl");
97
+ if (!implCls) implCls = NSClassFromString(@"UserGistPushImpl");
98
+ id sharedDelegate = nil;
99
+ if (implCls) {
100
+ SEL usergistSharedSel = NSSelectorFromString(@"usergistUNDelegate");
101
+ if ([implCls respondsToSelector:usergistSharedSel]) {
102
+ sharedDelegate = ((id (*)(id, SEL))objc_msgSend)(implCls, usergistSharedSel);
103
+ }
104
+ }
105
+ if (incoming && sharedDelegate && incoming != sharedDelegate) {
106
+ NSLog(@"[UserGistPush] setDelegate: foreign=%@, keeping ours; chain target captured",
107
+ NSStringFromClass([incoming class]));
108
+ _usergist_lastForeignDelegate = incoming;
109
+ ((void (*)(id, SEL, id))origImp)(_self, setterSel, sharedDelegate);
110
+ return;
111
+ }
112
+ ((void (*)(id, SEL, id))origImp)(_self, setterSel, incoming);
113
+ });
114
+ method_setImplementation(existing, newImp);
115
+ NSLog(@"[UserGistPush] UN setDelegate swizzle installed");
116
+ }
117
+
118
+ // Exposed to Swift via the bridging header — the Swift swizzler reads this
119
+ // when chaining didReceive / willPresent.
120
+ NSObject *_UserGistGetLastForeignDelegate(void) {
121
+ return (NSObject *)_usergist_lastForeignDelegate;
122
+ }
123
+
124
+ + (void)installDidRegister:(Class)cls {
125
+ SEL sel = @selector(application:didRegisterForRemoteNotificationsWithDeviceToken:);
126
+ IMP newImp = imp_implementationWithBlock(^(id _self, UIApplication *app, NSData *deviceToken) {
127
+ NSLog(@"[UserGistPush] swizzled didRegister fired (token len=%lu)",
128
+ (unsigned long)deviceToken.length);
129
+ [[UserGistPushImpl shared] recordTokenWithDeviceToken:deviceToken];
130
+ // Chain — if the original delegate had its own impl we replaced, the
131
+ // exchanged IMP lives under sel `_usergist_orig_didRegister:`.
132
+ SEL chainSel = NSSelectorFromString(@"_usergist_orig_application:didRegisterForRemoteNotificationsWithDeviceToken:");
133
+ if ([_self respondsToSelector:chainSel]) {
134
+ ((void (*)(id, SEL, UIApplication *, NSData *))objc_msgSend)(_self, chainSel, app, deviceToken);
135
+ }
136
+ });
137
+ Method existing = class_getInstanceMethod(cls, sel);
138
+ if (existing) {
139
+ // Save the original under a different selector so we can chain.
140
+ SEL chainSel = NSSelectorFromString(@"_usergist_orig_application:didRegisterForRemoteNotificationsWithDeviceToken:");
141
+ class_addMethod(cls, chainSel, method_getImplementation(existing), method_getTypeEncoding(existing));
142
+ method_setImplementation(existing, newImp);
143
+ } else {
144
+ class_addMethod(cls, sel, newImp, "v@:@@");
145
+ }
146
+ }
147
+
148
+ + (void)installDidFail:(Class)cls {
149
+ SEL sel = @selector(application:didFailToRegisterForRemoteNotificationsWithError:);
150
+ IMP newImp = imp_implementationWithBlock(^(id _self, UIApplication *app, NSError *err) {
151
+ NSLog(@"[UserGistPush] swizzled didFail fired: %@", err.localizedDescription);
152
+ [[UserGistPushImpl shared] recordTokenErrorWithError:err];
153
+ SEL chainSel = NSSelectorFromString(@"_usergist_orig_application:didFailToRegisterForRemoteNotificationsWithError:");
154
+ if ([_self respondsToSelector:chainSel]) {
155
+ ((void (*)(id, SEL, UIApplication *, NSError *))objc_msgSend)(_self, chainSel, app, err);
156
+ }
157
+ });
158
+ Method existing = class_getInstanceMethod(cls, sel);
159
+ if (existing) {
160
+ SEL chainSel = NSSelectorFromString(@"_usergist_orig_application:didFailToRegisterForRemoteNotificationsWithError:");
161
+ class_addMethod(cls, chainSel, method_getImplementation(existing), method_getTypeEncoding(existing));
162
+ method_setImplementation(existing, newImp);
163
+ } else {
164
+ class_addMethod(cls, sel, newImp, "v@:@@");
165
+ }
166
+ }
167
+
168
+ @end
package/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "name": "@usergist/feedback-react-native",
3
+ "version": "0.1.0",
4
+ "description": "userGist feedback + push SDK for React Native (self-contained, no Firebase/OneSignal/WonderPush required)",
5
+ "license": "MIT",
6
+ "homepage": "https://usergist.com/docs/sdks/react-native",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Future-Picnic/usergist-js.git",
10
+ "directory": "packages/sdk-react-native"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/Future-Picnic/usergist-js/issues"
14
+ },
15
+ "keywords": [
16
+ "react-native",
17
+ "feedback",
18
+ "surveys",
19
+ "push-notifications",
20
+ "in-app-messaging",
21
+ "feature-requests"
22
+ ],
23
+ "main": "./dist/index.js",
24
+ "types": "./dist/index.d.ts",
25
+ "module": "./dist/index.js",
26
+ "type": "module",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.ts",
30
+ "import": "./dist/index.js",
31
+ "default": "./dist/index.js"
32
+ },
33
+ "./preview": {
34
+ "types": "./dist/preview/index.d.ts",
35
+ "import": "./dist/preview/index.js",
36
+ "default": "./dist/preview/index.js"
37
+ },
38
+ "./package.json": "./package.json"
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "!dist/Ritmus*",
43
+ "!dist/NativeRitmusPush*",
44
+ "ios",
45
+ "android",
46
+ "!android/build",
47
+ "UserGistFeedback.podspec",
48
+ "UserGistFeedbackExtension.podspec",
49
+ "react-native.config.js",
50
+ "README.md",
51
+ "CHANGELOG.md",
52
+ "LICENSE"
53
+ ],
54
+ "peerDependencies": {
55
+ "@react-native-async-storage/async-storage": ">=1.19",
56
+ "react": ">=18",
57
+ "react-native": ">=0.72",
58
+ "react-native-safe-area-context": ">=4"
59
+ },
60
+ "dependencies": {
61
+ "@usergist/sdk-core": "^0.1.0"
62
+ },
63
+ "publishConfig": {
64
+ "access": "public"
65
+ },
66
+ "devDependencies": {
67
+ "@types/react": "^18.3.3",
68
+ "react": "18.2.0",
69
+ "react-native": "0.74.0",
70
+ "react-native-safe-area-context": "^4",
71
+ "vitest": "^2.1.0",
72
+ "typescript": "^5.5.4"
73
+ },
74
+ "codegenConfig": {
75
+ "name": "RNUserGistPushSpec",
76
+ "type": "modules",
77
+ "jsSrcsDir": "src",
78
+ "android": {
79
+ "javaPackageName": "studio.usergist.feedback"
80
+ }
81
+ },
82
+ "scripts": {
83
+ "build": "tsc",
84
+ "dev": "tsc --watch",
85
+ "test": "vitest run",
86
+ "typecheck": "tsc --noEmit",
87
+ "pack:check": "pnpm publish --dry-run --no-git-checks"
88
+ }
89
+ }
@@ -0,0 +1,16 @@
1
+ // React Native autolinking entry. RN 0.60+ reads this to discover the
2
+ // native iOS Pod and Android Gradle module without consumer-side edits.
3
+ export default {
4
+ dependency: {
5
+ platforms: {
6
+ ios: {
7
+ // The podspec at repo root pulls ios/ sources via s.source_files.
8
+ },
9
+ android: {
10
+ sourceDir: 'android',
11
+ packageImportPath: 'import studio.usergist.feedback.UserGistPushPackage;',
12
+ packageInstance: 'new UserGistPushPackage()',
13
+ },
14
+ },
15
+ },
16
+ }