@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.
- package/CHANGELOG.md +12 -0
- package/LICENSE +21 -0
- package/README.md +231 -0
- package/UserGistFeedback.podspec +31 -0
- package/UserGistFeedbackExtension.podspec +23 -0
- package/android/build.gradle +92 -0
- package/android/consumer-rules.pro +5 -0
- package/android/src/main/AndroidManifest.xml +31 -0
- package/android/src/main/java/studio/usergist/feedback/UserGistFirebaseMessagingService.kt +153 -0
- package/android/src/main/java/studio/usergist/feedback/UserGistPushModule.kt +344 -0
- package/android/src/main/java/studio/usergist/feedback/UserGistPushPackage.kt +18 -0
- package/dist/NativeUserGistPush.d.ts +35 -0
- package/dist/NativeUserGistPush.d.ts.map +1 -0
- package/dist/NativeUserGistPush.js +13 -0
- package/dist/NativeUserGistPush.js.map +1 -0
- package/dist/Provider.d.ts +7 -0
- package/dist/Provider.d.ts.map +1 -0
- package/dist/Provider.js +300 -0
- package/dist/Provider.js.map +1 -0
- package/dist/Push.d.ts +103 -0
- package/dist/Push.d.ts.map +1 -0
- package/dist/Push.js +225 -0
- package/dist/Push.js.map +1 -0
- package/dist/UserGist.d.ts +210 -0
- package/dist/UserGist.d.ts.map +1 -0
- package/dist/UserGist.js +1246 -0
- package/dist/UserGist.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/consent.d.ts +17 -0
- package/dist/internal/consent.d.ts.map +1 -0
- package/dist/internal/consent.js +98 -0
- package/dist/internal/consent.js.map +1 -0
- package/dist/internal/context.d.ts +12 -0
- package/dist/internal/context.d.ts.map +1 -0
- package/dist/internal/context.js +70 -0
- package/dist/internal/context.js.map +1 -0
- package/dist/internal/debug.d.ts +14 -0
- package/dist/internal/debug.d.ts.map +1 -0
- package/dist/internal/debug.js +73 -0
- package/dist/internal/debug.js.map +1 -0
- package/dist/internal/engine.d.ts +93 -0
- package/dist/internal/engine.d.ts.map +1 -0
- package/dist/internal/engine.js +904 -0
- package/dist/internal/engine.js.map +1 -0
- package/dist/internal/events.d.ts +49 -0
- package/dist/internal/events.d.ts.map +1 -0
- package/dist/internal/events.js +71 -0
- package/dist/internal/events.js.map +1 -0
- package/dist/internal/frequency-cap.d.ts +15 -0
- package/dist/internal/frequency-cap.d.ts.map +1 -0
- package/dist/internal/frequency-cap.js +84 -0
- package/dist/internal/frequency-cap.js.map +1 -0
- package/dist/internal/identity.d.ts +14 -0
- package/dist/internal/identity.d.ts.map +1 -0
- package/dist/internal/identity.js +113 -0
- package/dist/internal/identity.js.map +1 -0
- package/dist/internal/inapp-matcher.d.ts +13 -0
- package/dist/internal/inapp-matcher.d.ts.map +1 -0
- package/dist/internal/inapp-matcher.js +65 -0
- package/dist/internal/inapp-matcher.js.map +1 -0
- package/dist/internal/inapp-rules-cache.d.ts +16 -0
- package/dist/internal/inapp-rules-cache.d.ts.map +1 -0
- package/dist/internal/inapp-rules-cache.js +88 -0
- package/dist/internal/inapp-rules-cache.js.map +1 -0
- package/dist/internal/instruction-dedupe.d.ts +16 -0
- package/dist/internal/instruction-dedupe.d.ts.map +1 -0
- package/dist/internal/instruction-dedupe.js +52 -0
- package/dist/internal/instruction-dedupe.js.map +1 -0
- package/dist/internal/lifecycle.d.ts +12 -0
- package/dist/internal/lifecycle.d.ts.map +1 -0
- package/dist/internal/lifecycle.js +97 -0
- package/dist/internal/lifecycle.js.map +1 -0
- package/dist/internal/modal-coordinator.d.ts +4 -0
- package/dist/internal/modal-coordinator.d.ts.map +1 -0
- package/dist/internal/modal-coordinator.js +43 -0
- package/dist/internal/modal-coordinator.js.map +1 -0
- package/dist/internal/modal-queue.d.ts +9 -0
- package/dist/internal/modal-queue.d.ts.map +1 -0
- package/dist/internal/modal-queue.js +32 -0
- package/dist/internal/modal-queue.js.map +1 -0
- package/dist/internal/mutation-queue.d.ts +23 -0
- package/dist/internal/mutation-queue.d.ts.map +1 -0
- package/dist/internal/mutation-queue.js +66 -0
- package/dist/internal/mutation-queue.js.map +1 -0
- package/dist/internal/prompt-answers.d.ts +6 -0
- package/dist/internal/prompt-answers.d.ts.map +1 -0
- package/dist/internal/prompt-answers.js +10 -0
- package/dist/internal/prompt-answers.js.map +1 -0
- package/dist/internal/prompt-flow.d.ts +8 -0
- package/dist/internal/prompt-flow.d.ts.map +1 -0
- package/dist/internal/prompt-flow.js +26 -0
- package/dist/internal/prompt-flow.js.map +1 -0
- package/dist/internal/queue.d.ts +17 -0
- package/dist/internal/queue.d.ts.map +1 -0
- package/dist/internal/queue.js +172 -0
- package/dist/internal/queue.js.map +1 -0
- package/dist/internal/requests.d.ts +22 -0
- package/dist/internal/requests.d.ts.map +1 -0
- package/dist/internal/requests.js +146 -0
- package/dist/internal/requests.js.map +1 -0
- package/dist/internal/rules-cache.d.ts +16 -0
- package/dist/internal/rules-cache.d.ts.map +1 -0
- package/dist/internal/rules-cache.js +86 -0
- package/dist/internal/rules-cache.js.map +1 -0
- package/dist/internal/storage.d.ts +36 -0
- package/dist/internal/storage.d.ts.map +1 -0
- package/dist/internal/storage.js +213 -0
- package/dist/internal/storage.js.map +1 -0
- package/dist/internal/survey-completion.d.ts +10 -0
- package/dist/internal/survey-completion.d.ts.map +1 -0
- package/dist/internal/survey-completion.js +11 -0
- package/dist/internal/survey-completion.js.map +1 -0
- package/dist/internal/survey-matcher.d.ts +21 -0
- package/dist/internal/survey-matcher.d.ts.map +1 -0
- package/dist/internal/survey-matcher.js +139 -0
- package/dist/internal/survey-matcher.js.map +1 -0
- package/dist/internal/survey-rules-cache.d.ts +17 -0
- package/dist/internal/survey-rules-cache.d.ts.map +1 -0
- package/dist/internal/survey-rules-cache.js +91 -0
- package/dist/internal/survey-rules-cache.js.map +1 -0
- package/dist/internal/survey-store.d.ts +19 -0
- package/dist/internal/survey-store.d.ts.map +1 -0
- package/dist/internal/survey-store.js +59 -0
- package/dist/internal/survey-store.js.map +1 -0
- package/dist/internal/transport.d.ts +210 -0
- package/dist/internal/transport.d.ts.map +1 -0
- package/dist/internal/transport.js +498 -0
- package/dist/internal/transport.js.map +1 -0
- package/dist/internal/trigger-matcher.d.ts +21 -0
- package/dist/internal/trigger-matcher.d.ts.map +1 -0
- package/dist/internal/trigger-matcher.js +114 -0
- package/dist/internal/trigger-matcher.js.map +1 -0
- package/dist/internal/types.d.ts +89 -0
- package/dist/internal/types.d.ts.map +1 -0
- package/dist/internal/types.js +2 -0
- package/dist/internal/types.js.map +1 -0
- package/dist/internal/user-state.d.ts +11 -0
- package/dist/internal/user-state.d.ts.map +1 -0
- package/dist/internal/user-state.js +118 -0
- package/dist/internal/user-state.js.map +1 -0
- package/dist/native/events.d.ts +27 -0
- package/dist/native/events.d.ts.map +1 -0
- package/dist/native/events.js +12 -0
- package/dist/native/events.js.map +1 -0
- package/dist/native/push-bridge.d.ts +20 -0
- package/dist/native/push-bridge.d.ts.map +1 -0
- package/dist/native/push-bridge.js +92 -0
- package/dist/native/push-bridge.js.map +1 -0
- package/dist/preview/PromptShell.d.ts +9 -0
- package/dist/preview/PromptShell.d.ts.map +1 -0
- package/dist/preview/PromptShell.js +128 -0
- package/dist/preview/PromptShell.js.map +1 -0
- package/dist/preview/SurveyShell.d.ts +15 -0
- package/dist/preview/SurveyShell.d.ts.map +1 -0
- package/dist/preview/SurveyShell.js +197 -0
- package/dist/preview/SurveyShell.js.map +1 -0
- package/dist/preview/index.d.ts +4 -0
- package/dist/preview/index.d.ts.map +1 -0
- package/dist/preview/index.js +16 -0
- package/dist/preview/index.js.map +1 -0
- package/dist/ui/InAppMessageView.d.ts +12 -0
- package/dist/ui/InAppMessageView.d.ts.map +1 -0
- package/dist/ui/InAppMessageView.js +260 -0
- package/dist/ui/InAppMessageView.js.map +1 -0
- package/dist/ui/PromptSheet.d.ts +12 -0
- package/dist/ui/PromptSheet.d.ts.map +1 -0
- package/dist/ui/PromptSheet.js +285 -0
- package/dist/ui/PromptSheet.js.map +1 -0
- package/dist/ui/QuestionImageHeader.d.ts +12 -0
- package/dist/ui/QuestionImageHeader.d.ts.map +1 -0
- package/dist/ui/QuestionImageHeader.js +22 -0
- package/dist/ui/QuestionImageHeader.js.map +1 -0
- package/dist/ui/SurveyView.d.ts +25 -0
- package/dist/ui/SurveyView.d.ts.map +1 -0
- package/dist/ui/SurveyView.js +478 -0
- package/dist/ui/SurveyView.js.map +1 -0
- package/dist/ui/animations.d.ts +4 -0
- package/dist/ui/animations.d.ts.map +1 -0
- package/dist/ui/animations.js +24 -0
- package/dist/ui/animations.js.map +1 -0
- package/dist/ui/questions/MultipleChoiceQuestion.d.ts +12 -0
- package/dist/ui/questions/MultipleChoiceQuestion.d.ts.map +1 -0
- package/dist/ui/questions/MultipleChoiceQuestion.js +41 -0
- package/dist/ui/questions/MultipleChoiceQuestion.js.map +1 -0
- package/dist/ui/questions/NpsQuestion.d.ts +14 -0
- package/dist/ui/questions/NpsQuestion.d.ts.map +1 -0
- package/dist/ui/questions/NpsQuestion.js +73 -0
- package/dist/ui/questions/NpsQuestion.js.map +1 -0
- package/dist/ui/questions/RatingQuestion.d.ts +21 -0
- package/dist/ui/questions/RatingQuestion.d.ts.map +1 -0
- package/dist/ui/questions/RatingQuestion.js +91 -0
- package/dist/ui/questions/RatingQuestion.js.map +1 -0
- package/dist/ui/questions/ShortTextQuestion.d.ts +12 -0
- package/dist/ui/questions/ShortTextQuestion.d.ts.map +1 -0
- package/dist/ui/questions/ShortTextQuestion.js +25 -0
- package/dist/ui/questions/ShortTextQuestion.js.map +1 -0
- package/dist/ui/requests/BoardView.d.ts +9 -0
- package/dist/ui/requests/BoardView.d.ts.map +1 -0
- package/dist/ui/requests/BoardView.js +270 -0
- package/dist/ui/requests/BoardView.js.map +1 -0
- package/dist/ui/requests/CommentsSection.d.ts +7 -0
- package/dist/ui/requests/CommentsSection.d.ts.map +1 -0
- package/dist/ui/requests/CommentsSection.js +254 -0
- package/dist/ui/requests/CommentsSection.js.map +1 -0
- package/dist/ui/requests/DetailView.d.ts +8 -0
- package/dist/ui/requests/DetailView.d.ts.map +1 -0
- package/dist/ui/requests/DetailView.js +206 -0
- package/dist/ui/requests/DetailView.js.map +1 -0
- package/dist/ui/requests/RequestsHost.d.ts +12 -0
- package/dist/ui/requests/RequestsHost.d.ts.map +1 -0
- package/dist/ui/requests/RequestsHost.js +58 -0
- package/dist/ui/requests/RequestsHost.js.map +1 -0
- package/dist/ui/requests/StatusPill.d.ts +6 -0
- package/dist/ui/requests/StatusPill.d.ts.map +1 -0
- package/dist/ui/requests/StatusPill.js +25 -0
- package/dist/ui/requests/StatusPill.js.map +1 -0
- package/dist/ui/requests/SubmitView.d.ts +8 -0
- package/dist/ui/requests/SubmitView.d.ts.map +1 -0
- package/dist/ui/requests/SubmitView.js +94 -0
- package/dist/ui/requests/SubmitView.js.map +1 -0
- package/dist/ui/requests/shared.d.ts +21 -0
- package/dist/ui/requests/shared.d.ts.map +1 -0
- package/dist/ui/requests/shared.js +74 -0
- package/dist/ui/requests/shared.js.map +1 -0
- package/dist/ui/survey-questions.d.ts +20 -0
- package/dist/ui/survey-questions.d.ts.map +1 -0
- package/dist/ui/survey-questions.js +211 -0
- package/dist/ui/survey-questions.js.map +1 -0
- package/dist/ui/theme.d.ts +15 -0
- package/dist/ui/theme.d.ts.map +1 -0
- package/dist/ui/theme.js +31 -0
- package/dist/ui/theme.js.map +1 -0
- package/ios/Extension/UserGistNotificationService.swift +292 -0
- package/ios/UserGistPush.h +16 -0
- package/ios/UserGistPush.mm +158 -0
- package/ios/UserGistPushImpl.swift +399 -0
- package/ios/UserGistPushSwizzle.m +168 -0
- package/package.json +89 -0
- 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
|