@joryio/react-native-sdk 0.0.0-stage → 1.3.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 +5 -0
- package/LICENSE +54 -0
- package/README.md +385 -2
- package/android/build.gradle +90 -0
- package/android/src/main/java/io/joryio/reactnative/JoryioModule.kt +1188 -0
- package/android/src/main/java/io/joryio/reactnative/JoryioPackage.kt +27 -0
- package/ios/JoryioModule.m +113 -0
- package/ios/JoryioModule.swift +979 -0
- package/joryio-react-native.podspec +36 -0
- package/lib/index.d.ts +1098 -0
- package/lib/index.js +1928 -0
- package/lib/index.js.map +1 -0
- package/package.json +62 -4
- package/react-native.config.js +25 -0
- package/src/index.ts +2355 -0
|
@@ -0,0 +1,979 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import UserNotifications
|
|
3
|
+
// RCTEventEmitter, RCTPromiseResolveBlock and friends live in React-Core. The
|
|
4
|
+
// podspec already depends on it, but a Swift file still has to import the
|
|
5
|
+
// module - without this the whole bridge fails to compile with "cannot find
|
|
6
|
+
// type 'RCTEventEmitter' in scope", which is what happened here: iOS had never
|
|
7
|
+
// been built for either demo app, so nothing had ever compiled this file.
|
|
8
|
+
import React
|
|
9
|
+
import Joryio
|
|
10
|
+
|
|
11
|
+
/// React Native bridge for the Joryio iOS SDK.
|
|
12
|
+
/// Each method delegates to the native `Joryio.shared` singleton using its
|
|
13
|
+
/// real public API (see sdk-ios `JoryioSDK.swift`).
|
|
14
|
+
@objc(JoryioModule)
|
|
15
|
+
class JoryioModule: RCTEventEmitter {
|
|
16
|
+
|
|
17
|
+
/// The prefix the native SDK requires of an iOS SDK key (it refuses any
|
|
18
|
+
/// other). Checked here so a wrong key rejects initialize() instead of
|
|
19
|
+
/// resolving into an SDK that sends nothing.
|
|
20
|
+
private static let sdkKeyPrefix = "jry_sdk_ios_"
|
|
21
|
+
|
|
22
|
+
private var hasListeners = false
|
|
23
|
+
/// True while in-app messages are handed to JS instead of drawn natively.
|
|
24
|
+
private var inAppHandedToJS = false
|
|
25
|
+
|
|
26
|
+
/// The module whose callbacks the native SDK currently holds (contract item
|
|
27
|
+
/// 18). Only that module may release them: on a reload the new module can
|
|
28
|
+
/// install its own before the old one is torn down, and a blind clear would
|
|
29
|
+
/// take the NEW one away. An ObjectIdentifier rather than a weak reference,
|
|
30
|
+
/// because a weak reference already reads nil inside `deinit`.
|
|
31
|
+
private enum NativeCallback { case inApp, auth }
|
|
32
|
+
private static var owners: [NativeCallback: ObjectIdentifier] = [:]
|
|
33
|
+
private static let ownerLock = NSLock()
|
|
34
|
+
|
|
35
|
+
/// Clears `callback`'s owner when this module holds it; true if it did.
|
|
36
|
+
private func releaseOwnership(_ callback: NativeCallback) -> Bool {
|
|
37
|
+
Self.ownerLock.lock()
|
|
38
|
+
defer { Self.ownerLock.unlock() }
|
|
39
|
+
guard Self.owners[callback] == ObjectIdentifier(self) else { return false }
|
|
40
|
+
Self.owners[callback] = nil
|
|
41
|
+
return true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private func takeOwnership(_ callback: NativeCallback) {
|
|
45
|
+
Self.ownerLock.lock()
|
|
46
|
+
Self.owners[callback] = ObjectIdentifier(self)
|
|
47
|
+
Self.ownerLock.unlock()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/// Hand the native callbacks back when this module goes away - a reload, a
|
|
51
|
+
/// dev-menu refresh, React Native tearing down. The callbacks capture `self`
|
|
52
|
+
/// weakly, so once the module is gone they silently RETURN: every in-app
|
|
53
|
+
/// message was then "handed to JS" and dropped, with no native rendering
|
|
54
|
+
/// either, until the next process start.
|
|
55
|
+
private func releaseNativeCallbacks() {
|
|
56
|
+
if releaseOwnership(.inApp) {
|
|
57
|
+
inAppHandedToJS = false
|
|
58
|
+
Joryio.shared.clearInAppMessageCallback()
|
|
59
|
+
}
|
|
60
|
+
if releaseOwnership(.auth) {
|
|
61
|
+
Joryio.shared.clearSdkAuthenticationErrorHandler()
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
override func invalidate() {
|
|
66
|
+
releaseNativeCallbacks()
|
|
67
|
+
super.invalidate()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
deinit {
|
|
71
|
+
releaseNativeCallbacks()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// `requiresMainQueueSetup` is declared in JoryioModule.m (the RCT_EXTERN
|
|
75
|
+
// category) to avoid a duplicate Objective-C selector definition.
|
|
76
|
+
|
|
77
|
+
override func supportedEvents() -> [String]! {
|
|
78
|
+
return ["JoryioInAppMessage", "JoryioSdkAuthError"]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
override func startObserving() {
|
|
82
|
+
hasListeners = true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
override func stopObserving() {
|
|
86
|
+
hasListeners = false
|
|
87
|
+
// Every JS listener is gone, so nobody can draw a handed-off message: give
|
|
88
|
+
// rendering back to the native SDK. The JS layer asks for this itself when
|
|
89
|
+
// its last onInAppMessage listener unsubscribes; this covers a listener
|
|
90
|
+
// removed some other way (removeAllListeners, a reload).
|
|
91
|
+
if inAppHandedToJS, releaseOwnership(.inApp) {
|
|
92
|
+
inAppHandedToJS = false
|
|
93
|
+
Joryio.shared.clearInAppMessageCallback()
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// MARK: - Initialization
|
|
98
|
+
|
|
99
|
+
// Every String / NSDictionary parameter is OPTIONAL (contract item 19): a JS
|
|
100
|
+
// null arrives as nil, and a nil in a non-optional parameter is undefined
|
|
101
|
+
// behaviour in Swift - at best a crash. The JS layer checks first; this is
|
|
102
|
+
// the second line of defence. A null CONFIG VALUE arrives as NSNull, which
|
|
103
|
+
// every `as?` below reads as "not set", so the native default applies.
|
|
104
|
+
@objc func initialize(
|
|
105
|
+
_ sdkKey: String?,
|
|
106
|
+
apiHost: String?,
|
|
107
|
+
config: NSDictionary?,
|
|
108
|
+
resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
109
|
+
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
110
|
+
) {
|
|
111
|
+
guard let sdkKey, !sdkKey.isEmpty, let apiHost, !apiHost.isEmpty else {
|
|
112
|
+
reject("INIT_ERROR", "initialize() needs a non-empty sdkKey and apiHost", nil)
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
// A key the native SDK would refuse is refused HERE, with the prefix the
|
|
116
|
+
// native SDK checks (the Android bridge does the same). This is the ONLY
|
|
117
|
+
// refusal: anything else resolves - see the comment at the native call.
|
|
118
|
+
guard sdkKey.hasPrefix(Self.sdkKeyPrefix) else {
|
|
119
|
+
reject(
|
|
120
|
+
"INIT_ERROR",
|
|
121
|
+
"initialize() was refused: iOS SDK keys start with '\(Self.sdkKeyPrefix)'. "
|
|
122
|
+
+ "Copy your SDK key from the Joryio dashboard.",
|
|
123
|
+
nil
|
|
124
|
+
)
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
let config: NSDictionary = config ?? [:]
|
|
128
|
+
// JoryioConfig is an immutable struct — build it via its initializer
|
|
129
|
+
// rather than mutating fields (which are `let`).
|
|
130
|
+
let enableDebug = config["enableDebug"] as? Bool ?? false
|
|
131
|
+
|
|
132
|
+
let logLevel: JoryioConfig.LogLevel = {
|
|
133
|
+
// Lower-cased: 'Warn' matched no case here while Android upper-cases.
|
|
134
|
+
if let raw = config["logLevel"] as? String,
|
|
135
|
+
let level = JoryioConfig.LogLevel(rawValue: raw.lowercased()) {
|
|
136
|
+
return level
|
|
137
|
+
}
|
|
138
|
+
return enableDebug ? .debug : .error
|
|
139
|
+
}()
|
|
140
|
+
|
|
141
|
+
let batchSize = config["batchSize"] as? Int ?? ConfigDefaults.batchSize
|
|
142
|
+
// JS sends milliseconds; the iOS SDK expects seconds.
|
|
143
|
+
let flushInterval = (config["flushInterval"] as? Double).map { $0 / 1000.0 }
|
|
144
|
+
?? ConfigDefaults.flushInterval
|
|
145
|
+
let sessionTimeout = (config["sessionTimeout"] as? Double).map { $0 / 1000.0 }
|
|
146
|
+
?? ConfigDefaults.sessionTimeout
|
|
147
|
+
let trackSessionStart = config["trackSessionStart"] as? Bool ?? true
|
|
148
|
+
// Opt-in auto-prompt; see JoryioConfig.requestPushPermissionAtLaunch.
|
|
149
|
+
let requestPushPermissionAtLaunch = config["requestPushPermissionAtLaunch"] as? Bool ?? false
|
|
150
|
+
let userId = config["userId"] as? String
|
|
151
|
+
|
|
152
|
+
// Four options the native SDKs honour that this bridge dropped on the
|
|
153
|
+
// floor (audit 2026-09-26, D26): a JS app setting any of them got the
|
|
154
|
+
// native default and no error. Same names as native; `requestTimeout`
|
|
155
|
+
// follows flushInterval - JS sends milliseconds, iOS wants seconds.
|
|
156
|
+
let anonymousId = config["anonymousId"] as? String
|
|
157
|
+
let sendImmediately = config["sendImmediately"] as? Bool ?? false
|
|
158
|
+
let maxQueueSize = config["maxQueueSize"] as? Int ?? ConfigDefaults.maxQueueSize
|
|
159
|
+
let requestTimeout = (config["requestTimeout"] as? Double).map { $0 / 1000.0 }
|
|
160
|
+
?? ConfigDefaults.requestTimeout
|
|
161
|
+
|
|
162
|
+
// Optional SDK authentication (opt-in). Mirrors the sdk-ios
|
|
163
|
+
// JoryioConfig fields; both default off/nil so behaviour is unchanged.
|
|
164
|
+
let enableSdkAuthentication = config["enableSdkAuthentication"] as? Bool ?? false
|
|
165
|
+
let sdkAuthenticationToken = config["sdkAuthenticationToken"] as? String
|
|
166
|
+
|
|
167
|
+
// In-app: HTML messages render in a web view that can execute script (the
|
|
168
|
+
// SDK's own bridge runs there), so this is opt-in and OFF by default. The
|
|
169
|
+
// server strips <script> and on* handlers, so author JS does not run in
|
|
170
|
+
// normal operation - the residual risk is a sanitiser bypass. The Android bridge already forwarded
|
|
171
|
+
// it; without this arm the same JS config silently did nothing on iOS and
|
|
172
|
+
// an app that opted in still had every HTML campaign skipped.
|
|
173
|
+
let allowHtmlJsInAppMessages = config["allowHtmlJsInAppMessages"] as? Bool ?? false
|
|
174
|
+
|
|
175
|
+
// Consent and opt-out at start-up, the same options every Joryio SDK takes.
|
|
176
|
+
// Missing here, so an RN app could only opt out AFTER initialize() - by
|
|
177
|
+
// which time the session start had been sent.
|
|
178
|
+
let optOut = config["optOut"] as? Bool ?? false
|
|
179
|
+
// UNSET unless JS set it (consent model A, contract item 24): an unset
|
|
180
|
+
// consent keeps a denial stored by an earlier launch, while an explicit
|
|
181
|
+
// `.granted` lifts it. This used to fill in `.granted` for every app that
|
|
182
|
+
// passed nothing, which made the default config an explicit grant.
|
|
183
|
+
var trackingConsent: JoryioConfig.TrackingConsent?
|
|
184
|
+
if let raw = config["trackingConsent"] as? String {
|
|
185
|
+
if let parsed = JoryioConfig.TrackingConsent(rawValue: raw) {
|
|
186
|
+
trackingConsent = parsed
|
|
187
|
+
} else {
|
|
188
|
+
NSLog("[Joryio] Unknown trackingConsent '\(raw)' - ignored")
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Two forms, so an unset consent is left OUT and the native SDK's own
|
|
193
|
+
// "unset" default applies rather than a value chosen here.
|
|
194
|
+
let joryioConfig: JoryioConfig
|
|
195
|
+
if let trackingConsent {
|
|
196
|
+
joryioConfig = JoryioConfig(
|
|
197
|
+
userId: userId,
|
|
198
|
+
anonymousId: anonymousId,
|
|
199
|
+
batchSize: batchSize,
|
|
200
|
+
flushInterval: flushInterval,
|
|
201
|
+
sendImmediately: sendImmediately,
|
|
202
|
+
maxQueueSize: maxQueueSize,
|
|
203
|
+
sessionTimeout: sessionTimeout,
|
|
204
|
+
trackSessionStart: trackSessionStart,
|
|
205
|
+
requestPushPermissionAtLaunch: requestPushPermissionAtLaunch,
|
|
206
|
+
inApp: InAppConfig(allowHtmlJsInAppMessages: allowHtmlJsInAppMessages),
|
|
207
|
+
requestTimeout: requestTimeout,
|
|
208
|
+
enableSdkAuthentication: enableSdkAuthentication,
|
|
209
|
+
sdkAuthenticationToken: sdkAuthenticationToken,
|
|
210
|
+
optOut: optOut,
|
|
211
|
+
trackingConsent: trackingConsent,
|
|
212
|
+
enableDebug: enableDebug,
|
|
213
|
+
logLevel: logLevel
|
|
214
|
+
)
|
|
215
|
+
} else {
|
|
216
|
+
joryioConfig = JoryioConfig(
|
|
217
|
+
userId: userId,
|
|
218
|
+
anonymousId: anonymousId,
|
|
219
|
+
batchSize: batchSize,
|
|
220
|
+
flushInterval: flushInterval,
|
|
221
|
+
sendImmediately: sendImmediately,
|
|
222
|
+
maxQueueSize: maxQueueSize,
|
|
223
|
+
sessionTimeout: sessionTimeout,
|
|
224
|
+
trackSessionStart: trackSessionStart,
|
|
225
|
+
requestPushPermissionAtLaunch: requestPushPermissionAtLaunch,
|
|
226
|
+
inApp: InAppConfig(allowHtmlJsInAppMessages: allowHtmlJsInAppMessages),
|
|
227
|
+
requestTimeout: requestTimeout,
|
|
228
|
+
enableSdkAuthentication: enableSdkAuthentication,
|
|
229
|
+
sdkAuthenticationToken: sdkAuthenticationToken,
|
|
230
|
+
optOut: optOut,
|
|
231
|
+
enableDebug: enableDebug,
|
|
232
|
+
logLevel: logLevel
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
DispatchQueue.main.async {
|
|
237
|
+
// Forward SDK-authentication rejections to JS via the event emitter so the
|
|
238
|
+
// RN layer can mint a fresh token and call setSdkAuthenticationToken.
|
|
239
|
+
// Harmless when SDK auth is disabled (the handler simply never fires).
|
|
240
|
+
//
|
|
241
|
+
// Installed BEFORE initialize(), through the native SDK's pre-init path:
|
|
242
|
+
// the native SDK may DEFER its initialization (a launch before the first
|
|
243
|
+
// unlock since boot) and run it later, when protected data becomes
|
|
244
|
+
// available. A handler held for init is applied then; one installed
|
|
245
|
+
// after a deferred initialize() would never have been registered.
|
|
246
|
+
self.takeOwnership(.auth)
|
|
247
|
+
Joryio.shared.setSdkAuthenticationErrorHandler { [weak self] authError in
|
|
248
|
+
self?.emitSdkAuthError(authError)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
Joryio.shared.initialize(sdkKey: sdkKey, apiHost: apiHost, config: joryioConfig)
|
|
252
|
+
|
|
253
|
+
// Resolved even when the native SDK has not started YET. Before the
|
|
254
|
+
// first unlock since boot it defers its initialization
|
|
255
|
+
// (`isInitializationPending`) and finishes it on unlock, holding every
|
|
256
|
+
// call made meanwhile - so this is not a failure (contract v4 47). This
|
|
257
|
+
// used to reject whenever `currentApiEndpoint` was nil, which a deferred
|
|
258
|
+
// init also is: JS then marked the SDK failed and dropped every call for
|
|
259
|
+
// the whole process, although native would have started a moment later.
|
|
260
|
+
// The real refusals (no key, no host, a key without the iOS prefix) are
|
|
261
|
+
// rejected above, before anything starts.
|
|
262
|
+
if Joryio.shared.isInitializationPending {
|
|
263
|
+
NSLog("[Joryio] initialize(): the device has not been unlocked since it started; the SDK starts at the first unlock and holds every call until then")
|
|
264
|
+
}
|
|
265
|
+
resolve(nil)
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// MARK: - Event Tracking
|
|
270
|
+
|
|
271
|
+
/// Logs and answers false for a missing required string argument.
|
|
272
|
+
private func present(_ caller: String, _ name: String, _ value: String?) -> Bool {
|
|
273
|
+
if let value, !value.isEmpty { return true }
|
|
274
|
+
NSLog("[Joryio] \(caller)() needs a non-empty \(name) - ignored")
|
|
275
|
+
return false
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
@objc func track(_ eventName: String?, properties: NSDictionary?) {
|
|
279
|
+
guard present("track", "eventName", eventName), let eventName else { return }
|
|
280
|
+
let props = properties as? [String: Any] ?? [:]
|
|
281
|
+
Joryio.shared.track(eventName, properties: props)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
@objc func trackScreen(_ screenName: String?, properties: NSDictionary?) {
|
|
285
|
+
guard present("trackScreen", "screenName", screenName), let screenName else { return }
|
|
286
|
+
let props = properties as? [String: Any] ?? [:]
|
|
287
|
+
Joryio.shared.trackScreen(screenName, properties: props)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// MARK: - Identity
|
|
291
|
+
|
|
292
|
+
@objc func identify(_ userId: String?) {
|
|
293
|
+
guard present("identify", "userId", userId), let userId else { return }
|
|
294
|
+
Joryio.shared.identify(userId)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/// A labelled alias (`crm_id` -> `C-1042`). Works before identify(): the
|
|
298
|
+
/// native SDK sends it with the anonymous id. The unlabelled alias(userId) is
|
|
299
|
+
/// gone from every SDK - identify() is how you name the current person.
|
|
300
|
+
@objc func addAlias(_ aliasLabel: String?, aliasName: String?) {
|
|
301
|
+
guard present("addAlias", "aliasLabel", aliasLabel), let aliasLabel,
|
|
302
|
+
present("addAlias", "aliasName", aliasName), let aliasName else { return }
|
|
303
|
+
Joryio.shared.addAlias(aliasLabel, name: aliasName)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
@objc func reset() {
|
|
307
|
+
Joryio.shared.reset()
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// MARK: - Tracking consent
|
|
311
|
+
//
|
|
312
|
+
// Tracking consent, NOT marketing subscription. optOut stops the SDK
|
|
313
|
+
// collecting and sending; it does not unsubscribe anyone from email.
|
|
314
|
+
//
|
|
315
|
+
// Bridged because it was missing: web, Android, iOS and Unity all had these
|
|
316
|
+
// and React Native had no consent surface at all. The native call already
|
|
317
|
+
// records `tracking_opted_out` on the profile, so bridging is the whole fix.
|
|
318
|
+
|
|
319
|
+
@objc func optOut() {
|
|
320
|
+
Joryio.shared.optOut()
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
@objc func optIn() {
|
|
324
|
+
Joryio.shared.optIn()
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/// Erase this device's data for the current person and unregister its push
|
|
328
|
+
/// token on the server; the opt-out is kept (same on every Joryio SDK).
|
|
329
|
+
@objc func wipeData() {
|
|
330
|
+
Joryio.shared.wipeData()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
@objc func isUserOptedOut(_ resolve: @escaping RCTPromiseResolveBlock,
|
|
334
|
+
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
|
335
|
+
resolve(Joryio.shared.isUserOptedOut())
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/// The current tracking consent ('granted' | 'denied' | 'pending'), separate
|
|
339
|
+
/// from the opt-out that isUserOptedOut reports. Safe before initialize().
|
|
340
|
+
@objc func getTrackingConsent(_ resolve: @escaping RCTPromiseResolveBlock,
|
|
341
|
+
rejecter reject: @escaping RCTPromiseRejectBlock) {
|
|
342
|
+
resolve(Joryio.shared.getTrackingConsent().rawValue)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/// 'granted' releases what was held while consent was pending; 'denied'
|
|
346
|
+
/// discards it; 'pending' holds for this launch. Made before initialize(),
|
|
347
|
+
/// the native SDK records it and applies it inside init.
|
|
348
|
+
@objc func updateConsent(_ consent: String?) {
|
|
349
|
+
guard let consent, let parsed = JoryioConfig.TrackingConsent(rawValue: consent) else {
|
|
350
|
+
NSLog("[Joryio] updateConsent: unknown value '\(consent ?? "null")' - ignored")
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
Joryio.shared.updateConsent(parsed)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// MARK: - Marketing subscription
|
|
357
|
+
//
|
|
358
|
+
// A DIFFERENT consent from optIn/optOut above: that is whether we may
|
|
359
|
+
// OBSERVE, this is whether we may MESSAGE.
|
|
360
|
+
|
|
361
|
+
@objc func setSubscription(_ channel: String?, status: String?) {
|
|
362
|
+
guard present("setSubscription", "channel", channel), let channel,
|
|
363
|
+
present("setSubscription", "status", status), let status else { return }
|
|
364
|
+
Joryio.shared.setSubscription(channel: channel, status: status)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
@objc func addToSubscriptionGroup(_ groupId: String?, channel: String?) {
|
|
368
|
+
guard present("addToSubscriptionGroup", "groupId", groupId), let groupId,
|
|
369
|
+
present("addToSubscriptionGroup", "channel", channel), let channel else { return }
|
|
370
|
+
Joryio.shared.addToSubscriptionGroup(groupId, channel: channel)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
@objc func removeFromSubscriptionGroup(_ groupId: String?, channel: String?) {
|
|
374
|
+
guard present("removeFromSubscriptionGroup", "groupId", groupId), let groupId,
|
|
375
|
+
present("removeFromSubscriptionGroup", "channel", channel), let channel else { return }
|
|
376
|
+
Joryio.shared.removeFromSubscriptionGroup(groupId, channel: channel)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// MARK: - Attributes
|
|
380
|
+
|
|
381
|
+
/// A null value UNSETS that attribute, the same as on Android. The JS layer
|
|
382
|
+
/// already splits nulls out; this is the guard for anything that reaches the
|
|
383
|
+
/// bridge directly. It used to forward NSNull as if it were a value.
|
|
384
|
+
@objc func setAttributes(_ attributes: NSDictionary?) {
|
|
385
|
+
guard let attrs = attributes as? [String: Any] else {
|
|
386
|
+
NSLog("[Joryio] setAttributes() needs a map of attributes - ignored")
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
var values: [String: Any] = [:]
|
|
390
|
+
var unset: [String] = []
|
|
391
|
+
for (key, value) in attrs {
|
|
392
|
+
if value is NSNull { unset.append(key) } else { values[key] = value }
|
|
393
|
+
}
|
|
394
|
+
if !values.isEmpty { Joryio.shared.setAttributes(values) }
|
|
395
|
+
unset.forEach { Joryio.shared.unsetAttribute($0) }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
@objc func setAttribute(_ key: String?, value: Any?) {
|
|
399
|
+
guard present("setAttribute", "key", key), let key else { return }
|
|
400
|
+
guard let value, !(value is NSNull) else {
|
|
401
|
+
Joryio.shared.unsetAttribute(key)
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
Joryio.shared.setAttribute(key, value: value)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/// `by` is untyped so a JS null reaches us at all - a `nonnull NSNumber`
|
|
408
|
+
/// made React Native refuse the call. Null means the default step, 1.
|
|
409
|
+
@objc func incrementAttribute(_ key: String?, by: Any?) {
|
|
410
|
+
guard present("incrementAttribute", "key", key), let key else { return }
|
|
411
|
+
let step: Double
|
|
412
|
+
switch by {
|
|
413
|
+
case nil, is NSNull: step = 1
|
|
414
|
+
case let n as NSNumber: step = n.doubleValue
|
|
415
|
+
default:
|
|
416
|
+
NSLog("[Joryio] incrementAttribute('\(key)') needs a number to add - ignored")
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
Joryio.shared.incrementAttribute(key, by: step)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
@objc func unsetAttribute(_ key: String?) {
|
|
423
|
+
guard present("unsetAttribute", "key", key), let key else { return }
|
|
424
|
+
Joryio.shared.unsetAttribute(key)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// MARK: - Push
|
|
428
|
+
|
|
429
|
+
@objc func reportPushDelivered(_ trackingId: String?) {
|
|
430
|
+
guard present("reportPushDelivered", "trackingId", trackingId), let trackingId else { return }
|
|
431
|
+
Joryio.shared.reportPushDelivered(trackingId)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/// Resolves with the native PushRegistrationResult. The single-arg form
|
|
435
|
+
/// dropped it, so from JS a failed registration was indistinguishable from a
|
|
436
|
+
/// successful one (Teamder, 2026-09-29).
|
|
437
|
+
@objc func registerPushToken(
|
|
438
|
+
_ token: String?,
|
|
439
|
+
resolver resolve: @escaping RCTPromiseResolveBlock,
|
|
440
|
+
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
441
|
+
) {
|
|
442
|
+
guard let token, !token.isEmpty else {
|
|
443
|
+
resolve([
|
|
444
|
+
"success": false,
|
|
445
|
+
"message": "A push token is required",
|
|
446
|
+
"isUnauthorized": false,
|
|
447
|
+
"isRetryable": false,
|
|
448
|
+
])
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
Joryio.shared.registerPushToken(token) { r in
|
|
452
|
+
resolve([
|
|
453
|
+
"success": r.success,
|
|
454
|
+
"message": r.message.map { $0 as Any } ?? NSNull(),
|
|
455
|
+
"isUnauthorized": r.isUnauthorized,
|
|
456
|
+
"isRetryable": r.isRetryable,
|
|
457
|
+
])
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
@objc func unregisterPush() {
|
|
462
|
+
Joryio.shared.unregisterPush()
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/// Show the system notification prompt, resolving the resulting status.
|
|
466
|
+
///
|
|
467
|
+
/// Resolves a status string rather than a bare boolean so JS can tell "denied"
|
|
468
|
+
/// from "we could not ask" - that difference decides whether showing a primer
|
|
469
|
+
/// again is worth anything. Vocabulary matches the Android bridge.
|
|
470
|
+
///
|
|
471
|
+
/// Opted out of tracking: the prompt is STILL shown, as on Android - whether
|
|
472
|
+
/// the app may notify is the user's choice in the OS, not a tracking decision.
|
|
473
|
+
/// It is asked for directly, so nothing registers or reports to Joryio. The
|
|
474
|
+
/// native call skips the prompt while opted out, which left a JS primer
|
|
475
|
+
/// reading `notDetermined` and asking again forever.
|
|
476
|
+
@objc func requestPushPermission(
|
|
477
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
478
|
+
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
479
|
+
) {
|
|
480
|
+
Task {
|
|
481
|
+
if Joryio.shared.isUserOptedOut() {
|
|
482
|
+
_ = try? await UNUserNotificationCenter.current()
|
|
483
|
+
.requestAuthorization(options: [.alert, .badge, .sound])
|
|
484
|
+
} else {
|
|
485
|
+
_ = await Joryio.shared.requestPushPermission()
|
|
486
|
+
}
|
|
487
|
+
resolve(await Joryio.shared.getPushPermissionStatus())
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
@objc func getPushPermissionStatus(
|
|
492
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
493
|
+
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
494
|
+
) {
|
|
495
|
+
Task { resolve(await Joryio.shared.getPushPermissionStatus()) }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
@objc func isPushEnabled(
|
|
499
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
500
|
+
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
501
|
+
) {
|
|
502
|
+
// isPushEnabled() is async on the iOS SDK — await it before resolving.
|
|
503
|
+
Task {
|
|
504
|
+
let enabled = await Joryio.shared.isPushEnabled()
|
|
505
|
+
resolve(enabled)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
@objc func trackPushClick(_ trackingId: String?) {
|
|
510
|
+
guard present("trackPushClick", "trackingId", trackingId), let trackingId else { return }
|
|
511
|
+
Joryio.shared.trackPushClick(trackingId: trackingId)
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// MARK: - E-commerce
|
|
515
|
+
|
|
516
|
+
/// ONE bridge method, not nineteen - see the Android module for the reasoning.
|
|
517
|
+
/// The canonical event names and purchase property keys live in the native
|
|
518
|
+
/// tracker; rebuilding them in JS would be a fifth copy of a vocabulary that
|
|
519
|
+
/// has no server-side alias layer, so a near-miss is stored verbatim and
|
|
520
|
+
/// matches no report.
|
|
521
|
+
///
|
|
522
|
+
/// The case list here MUST match the Android `when` and the JS surface. A spec
|
|
523
|
+
/// compares all three, because nothing else can: three languages, none
|
|
524
|
+
/// importing another.
|
|
525
|
+
@objc func ecommerce(_ method: String?, payload: NSDictionary?) {
|
|
526
|
+
guard present("ecommerce", "method", method), let method else { return }
|
|
527
|
+
let p = payload as? [String: Any] ?? [:]
|
|
528
|
+
|
|
529
|
+
// Every required argument is checked before anything is sent (contract
|
|
530
|
+
// item 19). A missing one used to become a default - an empty orderId, a
|
|
531
|
+
// zero total - so `purchase(null)` tracked an Order Completed worth
|
|
532
|
+
// nothing instead of being refused. The JS layer checks first.
|
|
533
|
+
func need(_ name: String, _ value: Any?) -> String? {
|
|
534
|
+
if let text = value as? String, !text.isEmpty { return text }
|
|
535
|
+
NSLog("[Joryio] ecommerce.\(method)() needs a non-empty \(name) - event NOT sent")
|
|
536
|
+
return nil
|
|
537
|
+
}
|
|
538
|
+
func amount(_ name: String, _ value: Any?) -> Double? {
|
|
539
|
+
if let n = value as? NSNumber, n.doubleValue.isFinite { return n.doubleValue }
|
|
540
|
+
NSLog("[Joryio] ecommerce.\(method)() needs a number \(name) - event NOT sent")
|
|
541
|
+
return nil
|
|
542
|
+
}
|
|
543
|
+
func product(_ key: String) -> EcommerceProduct? {
|
|
544
|
+
guard let m = p[key] as? [String: Any], let id = m["productId"] as? String, !id.isEmpty else {
|
|
545
|
+
NSLog("[Joryio] ecommerce.\(method)() needs a \(key) with a productId - event NOT sent")
|
|
546
|
+
return nil
|
|
547
|
+
}
|
|
548
|
+
return Self.product(m)
|
|
549
|
+
}
|
|
550
|
+
/// Contract item 42: an order without an items array is not sent either,
|
|
551
|
+
/// rather than recorded as an empty order.
|
|
552
|
+
func orderItems(_ value: Any?) -> Bool {
|
|
553
|
+
if value is [Any] { return true }
|
|
554
|
+
NSLog("[Joryio] ecommerce.\(method)() needs an order with an items array - event NOT sent")
|
|
555
|
+
return false
|
|
556
|
+
}
|
|
557
|
+
func items() -> [EcommerceCartItem]? {
|
|
558
|
+
guard let raw = p["items"] as? [Any] else {
|
|
559
|
+
NSLog("[Joryio] ecommerce.\(method)() needs an array of items - event NOT sent")
|
|
560
|
+
return nil
|
|
561
|
+
}
|
|
562
|
+
return Self.cartItems(raw)
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
let tracker = Joryio.shared.ecommerce()
|
|
566
|
+
|
|
567
|
+
switch method {
|
|
568
|
+
case "viewProduct":
|
|
569
|
+
guard let product = product("product") else { return }
|
|
570
|
+
tracker.viewProduct(product)
|
|
571
|
+
case "viewCategory":
|
|
572
|
+
guard let id = need("categoryId", p["categoryId"]),
|
|
573
|
+
let name = need("categoryName", p["categoryName"]) else { return }
|
|
574
|
+
tracker.viewCategory(categoryId: id, categoryName: name)
|
|
575
|
+
case "search":
|
|
576
|
+
guard let query = need("query", p["query"]) else { return }
|
|
577
|
+
tracker.search(query: query, resultCount: p["resultCount"] as? Int)
|
|
578
|
+
case "addToCart":
|
|
579
|
+
guard product("item") != nil else { return }
|
|
580
|
+
tracker.addToCart(item: Self.cartItem(p["item"]), cartValue: p["cartValue"] as? Double)
|
|
581
|
+
case "removeFromCart":
|
|
582
|
+
guard let product = product("product") else { return }
|
|
583
|
+
tracker.removeFromCart(product: product, quantity: p["quantity"] as? Int ?? 1)
|
|
584
|
+
case "cartViewed":
|
|
585
|
+
guard let items = items(), let value = amount("cartValue", p["cartValue"]) else { return }
|
|
586
|
+
tracker.cartViewed(items: items, cartValue: value)
|
|
587
|
+
case "updateCart":
|
|
588
|
+
guard let items = items(), let value = amount("cartValue", p["cartValue"]) else { return }
|
|
589
|
+
tracker.updateCart(items: items, cartValue: value)
|
|
590
|
+
case "startCheckout":
|
|
591
|
+
guard let items = items(), let value = amount("cartValue", p["cartValue"]) else { return }
|
|
592
|
+
tracker.startCheckout(items: items, cartValue: value)
|
|
593
|
+
case "addPaymentInfo":
|
|
594
|
+
guard let paymentMethod = need("paymentMethod", p["paymentMethod"]) else { return }
|
|
595
|
+
tracker.addPaymentInfo(paymentMethod: paymentMethod)
|
|
596
|
+
case "purchase":
|
|
597
|
+
guard let order = p["order"] as? [String: Any],
|
|
598
|
+
need("order.orderId", order["orderId"]) != nil,
|
|
599
|
+
orderItems(order["items"]),
|
|
600
|
+
amount("order.value", order["value"]) != nil else { return }
|
|
601
|
+
tracker.purchase(order: Self.order(order))
|
|
602
|
+
case "orderFulfilled":
|
|
603
|
+
guard let orderId = need("orderId", p["orderId"]) else { return }
|
|
604
|
+
tracker.orderFulfilled(
|
|
605
|
+
orderId: orderId,
|
|
606
|
+
trackingNumber: p["trackingNumber"] as? String,
|
|
607
|
+
carrier: p["carrier"] as? String)
|
|
608
|
+
case "orderDelivered":
|
|
609
|
+
guard let orderId = need("orderId", p["orderId"]) else { return }
|
|
610
|
+
tracker.orderDelivered(orderId: orderId)
|
|
611
|
+
case "orderCancelled":
|
|
612
|
+
guard let orderId = need("orderId", p["orderId"]) else { return }
|
|
613
|
+
tracker.orderCancelled(orderId: orderId, reason: p["reason"] as? String)
|
|
614
|
+
case "orderRefunded":
|
|
615
|
+
guard let orderId = need("orderId", p["orderId"]) else { return }
|
|
616
|
+
tracker.orderRefunded(
|
|
617
|
+
orderId: orderId,
|
|
618
|
+
refundAmount: p["refundAmount"] as? Double,
|
|
619
|
+
reason: p["reason"] as? String)
|
|
620
|
+
case "addToWishlist":
|
|
621
|
+
guard let product = product("product") else { return }
|
|
622
|
+
tracker.addToWishlist(product: product)
|
|
623
|
+
case "removeFromWishlist":
|
|
624
|
+
guard let product = product("product") else { return }
|
|
625
|
+
tracker.removeFromWishlist(product: product)
|
|
626
|
+
case "shareProduct":
|
|
627
|
+
guard let product = product("product"),
|
|
628
|
+
let shareMethod = need("shareMethod", p["shareMethod"]) else { return }
|
|
629
|
+
tracker.shareProduct(product: product, shareMethod: shareMethod)
|
|
630
|
+
case "applyCoupon":
|
|
631
|
+
guard let couponCode = need("couponCode", p["couponCode"]) else { return }
|
|
632
|
+
tracker.applyCoupon(
|
|
633
|
+
couponCode: couponCode,
|
|
634
|
+
discountAmount: p["discountAmount"] as? Double,
|
|
635
|
+
discountType: p["discountType"] as? String)
|
|
636
|
+
case "removeCoupon":
|
|
637
|
+
guard let couponCode = need("couponCode", p["couponCode"]) else { return }
|
|
638
|
+
tracker.removeCoupon(couponCode: couponCode)
|
|
639
|
+
case "submitReview":
|
|
640
|
+
// Read as a Double on BOTH bridges, then converted the same way: `as?
|
|
641
|
+
// Int` read 4.5 as 0 here (it fails on a fraction) while Android gave 4.
|
|
642
|
+
// The native trackers take an Int rating, so a fraction is truncated
|
|
643
|
+
// identically on both until they take a Double like the web SDK.
|
|
644
|
+
guard let productId = need("productId", p["productId"]),
|
|
645
|
+
let rating = amount("rating", p["rating"]) else { return }
|
|
646
|
+
// Int(Double) TRAPS past Int.max - a crash of the host app from
|
|
647
|
+
// `submitReview('p', Number(userInput))`. Refused instead, at the same
|
|
648
|
+
// bound as Android (a 32-bit Int); the JS layer refuses it first.
|
|
649
|
+
guard abs(rating) <= Double(Int32.max) else {
|
|
650
|
+
NSLog("[Joryio] ecommerce.submitReview() needs a rating an Int can hold - event NOT sent")
|
|
651
|
+
return
|
|
652
|
+
}
|
|
653
|
+
tracker.submitReview(
|
|
654
|
+
productId: productId,
|
|
655
|
+
rating: Int(rating),
|
|
656
|
+
reviewText: p["reviewText"] as? String)
|
|
657
|
+
default:
|
|
658
|
+
// Loudly, not silently: a typo'd method name would otherwise be a no-op
|
|
659
|
+
// that looks exactly like the SDK working.
|
|
660
|
+
NSLog("[Joryio] Unknown ecommerce method '\(method)' - event NOT sent")
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private static func product(_ raw: Any?) -> EcommerceProduct {
|
|
665
|
+
let m = raw as? [String: Any] ?? [:]
|
|
666
|
+
return EcommerceProduct(
|
|
667
|
+
productId: m["productId"] as? String ?? "",
|
|
668
|
+
name: m["name"] as? String ?? "",
|
|
669
|
+
price: m["price"] as? Double ?? 0,
|
|
670
|
+
quantity: m["quantity"] as? Int,
|
|
671
|
+
category: m["category"] as? String,
|
|
672
|
+
brand: m["brand"] as? String,
|
|
673
|
+
variant: m["variant"] as? String,
|
|
674
|
+
variantId: m["variantId"] as? String,
|
|
675
|
+
sku: m["sku"] as? String,
|
|
676
|
+
imageUrl: m["imageUrl"] as? String,
|
|
677
|
+
url: m["url"] as? String)
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
private static func cartItem(_ raw: Any?) -> EcommerceCartItem {
|
|
681
|
+
let m = raw as? [String: Any] ?? [:]
|
|
682
|
+
return EcommerceCartItem(
|
|
683
|
+
productId: m["productId"] as? String ?? "",
|
|
684
|
+
name: m["name"] as? String ?? "",
|
|
685
|
+
price: m["price"] as? Double ?? 0,
|
|
686
|
+
// Defaults to 1, not 0: a cart line with zero quantity has an item total
|
|
687
|
+
// of zero, which is a silently wrong revenue number rather than an
|
|
688
|
+
// obviously wrong one. Matches Android.
|
|
689
|
+
quantity: m["quantity"] as? Int ?? 1,
|
|
690
|
+
category: m["category"] as? String,
|
|
691
|
+
brand: m["brand"] as? String,
|
|
692
|
+
variant: m["variant"] as? String,
|
|
693
|
+
variantId: m["variantId"] as? String,
|
|
694
|
+
sku: m["sku"] as? String,
|
|
695
|
+
imageUrl: m["imageUrl"] as? String,
|
|
696
|
+
url: m["url"] as? String)
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
private static func cartItems(_ raw: Any?) -> [EcommerceCartItem] {
|
|
700
|
+
guard let arr = raw as? [Any] else { return [] }
|
|
701
|
+
// A null (NSNull) or non-object entry is SKIPPED, as the JS layer skips
|
|
702
|
+
// it. Mapped to a line it had no productId, and the native tracker
|
|
703
|
+
// refused the whole order because of it.
|
|
704
|
+
return arr.compactMap { $0 as? [String: Any] }.map { cartItem($0) }
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private static func order(_ raw: Any?) -> EcommerceOrder {
|
|
708
|
+
let m = raw as? [String: Any] ?? [:]
|
|
709
|
+
return EcommerceOrder(
|
|
710
|
+
orderId: m["orderId"] as? String ?? "",
|
|
711
|
+
items: cartItems(m["items"]),
|
|
712
|
+
value: m["value"] as? Double ?? 0,
|
|
713
|
+
currency: m["currency"] as? String,
|
|
714
|
+
shipping: m["shipping"] as? Double,
|
|
715
|
+
tax: m["tax"] as? Double,
|
|
716
|
+
discount: m["discount"] as? Double,
|
|
717
|
+
coupon: m["coupon"] as? String)
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// MARK: - In-App Messaging
|
|
721
|
+
|
|
722
|
+
/// Hand in-app messages to JS, exactly as the Android module does.
|
|
723
|
+
///
|
|
724
|
+
/// This was a no-op, on a premise that had expired: the comment said the iOS
|
|
725
|
+
/// SDK "renders natively, so there is no callback to forward", which was true
|
|
726
|
+
/// when written. iOS later gained setInAppMessageCallback and the bridge was
|
|
727
|
+
/// never updated - so onInAppMessage fired on Android and never on iOS, and
|
|
728
|
+
/// one React Native app showed its own branded UI on Android and a
|
|
729
|
+
/// native-styled dialog on iOS. The docs promised the first for both.
|
|
730
|
+
///
|
|
731
|
+
/// THE PAYLOAD MUST MATCH ANDROID FIELD FOR FIELD, because the same
|
|
732
|
+
/// JavaScript renders both. Two places where reflection would have been
|
|
733
|
+
/// wrong, and silently:
|
|
734
|
+
///
|
|
735
|
+
/// - `deepLink` in Swift is `DEEP_LINK` in Kotlin, whose lowercased name is
|
|
736
|
+
/// `deep_link`. String(describing:) yields "deepLink", which no JS
|
|
737
|
+
/// renderer branches on - every deep-link button would quietly do nothing
|
|
738
|
+
/// on iOS only.
|
|
739
|
+
/// - MessageType is already a String enum with the right raw values, so it
|
|
740
|
+
/// uses rawValue rather than a second spelling of the same list.
|
|
741
|
+
///
|
|
742
|
+
/// So the mapping is written out explicitly. It is longer, and it cannot
|
|
743
|
+
/// drift into a wrong string without someone editing that string.
|
|
744
|
+
@objc func enableInAppMessages(_ capabilities: NSArray?) {
|
|
745
|
+
// What THIS app can render, declared from JS - see the Android module.
|
|
746
|
+
// Null keeps the previous behaviour rather than asserting anything, and an
|
|
747
|
+
// EMPTY list means the same as null (not declared), as on Android. It used
|
|
748
|
+
// to reach the server as "this app can draw nothing".
|
|
749
|
+
let filtered = (capabilities as? [String])?.filter { !$0.isEmpty } ?? []
|
|
750
|
+
let declared: [String]? = filtered.isEmpty ? nil : filtered
|
|
751
|
+
inAppHandedToJS = true
|
|
752
|
+
takeOwnership(.inApp)
|
|
753
|
+
|
|
754
|
+
Joryio.shared.setInAppMessageCallback({ [weak self] campaign in
|
|
755
|
+
guard let self else { return }
|
|
756
|
+
|
|
757
|
+
// RCTEventEmitter requires an active listener; matches emitSdkAuthError.
|
|
758
|
+
// Deliberately checked BEFORE the content guard so the two reasons a
|
|
759
|
+
// message is dropped stay distinguishable in the log.
|
|
760
|
+
guard self.hasListeners else {
|
|
761
|
+
NSLog("[Joryio] No JS listener for JoryioInAppMessage; dropping \(campaign.id)")
|
|
762
|
+
return
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
guard let content = campaign.content else {
|
|
766
|
+
// A campaign with no renderable document is DROPPED rather than emitted
|
|
767
|
+
// with empty strings - JS would otherwise render a blank container the
|
|
768
|
+
// user has to dismiss, and count an impression for it. Matches Android.
|
|
769
|
+
NSLog("[Joryio] Campaign \(campaign.id) has no content; not emitting")
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
var params: [String: Any] = [
|
|
774
|
+
"id": campaign.id,
|
|
775
|
+
"name": campaign.name,
|
|
776
|
+
// Wire value, not the case name: JS types this as
|
|
777
|
+
// 'modal' | 'banner' | 'slideup' | 'fullscreen' | 'custom'.
|
|
778
|
+
"type": campaign.type.rawValue,
|
|
779
|
+
"priority": campaign.priority,
|
|
780
|
+
]
|
|
781
|
+
|
|
782
|
+
switch content {
|
|
783
|
+
case let .html(html, css):
|
|
784
|
+
params["kind"] = "html"
|
|
785
|
+
params["html"] = html
|
|
786
|
+
params["css"] = css
|
|
787
|
+
|
|
788
|
+
case let .native(native):
|
|
789
|
+
params["kind"] = "native"
|
|
790
|
+
if let title = native.title { params["title"] = title }
|
|
791
|
+
params["body"] = native.body
|
|
792
|
+
if let imageUrl = native.imageUrl { params["imageUrl"] = imageUrl }
|
|
793
|
+
params["closeButton"] = native.closeButton
|
|
794
|
+
params["backdropDismissible"] = native.backdropDismissible
|
|
795
|
+
|
|
796
|
+
// Author style overrides. Forwarded rather than applied: the HOST draws
|
|
797
|
+
// the message, so resolving these here would compute the campaign's
|
|
798
|
+
// colours and then throw them away.
|
|
799
|
+
if let st = native.style {
|
|
800
|
+
var style: [String: Any] = [:]
|
|
801
|
+
if let v = st.backgroundColor { style["backgroundColor"] = v }
|
|
802
|
+
if let v = st.textColor { style["textColor"] = v }
|
|
803
|
+
if let v = st.primaryButtonColor { style["primaryButtonColor"] = v }
|
|
804
|
+
if let v = st.primaryButtonTextColor { style["primaryButtonTextColor"] = v }
|
|
805
|
+
if let v = st.cornerRadius { style["cornerRadius"] = v }
|
|
806
|
+
if let v = st.fontSize { style["fontSize"] = v }
|
|
807
|
+
if let v = st.titleWeight { style["titleWeight"] = v }
|
|
808
|
+
if let v = st.textAlign { style["textAlign"] = v }
|
|
809
|
+
if let v = st.fontFamily { style["fontFamily"] = v }
|
|
810
|
+
params["style"] = style
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
params["buttons"] = native.buttons.map { b -> [String: Any] in
|
|
814
|
+
var button: [String: Any] = [
|
|
815
|
+
"id": b.id,
|
|
816
|
+
"text": b.text,
|
|
817
|
+
"action": Self.buttonActionWireValue(b.action),
|
|
818
|
+
]
|
|
819
|
+
if let url = b.url { button["url"] = url }
|
|
820
|
+
return button
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
self.sendEvent(withName: "JoryioInAppMessage", body: params)
|
|
825
|
+
}, capabilities: declared)
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/// Hand in-app rendering back to the native SDK. The JS layer calls this
|
|
829
|
+
/// when its last onInAppMessage listener unsubscribes; before it existed,
|
|
830
|
+
/// messages kept being handed to JS and dropped for want of a listener, for
|
|
831
|
+
/// the rest of the session.
|
|
832
|
+
@objc func disableInAppMessages() {
|
|
833
|
+
inAppHandedToJS = false
|
|
834
|
+
_ = releaseOwnership(.inApp)
|
|
835
|
+
Joryio.shared.clearInAppMessageCallback()
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/// The wire spelling Android sends, written out rather than derived.
|
|
839
|
+
///
|
|
840
|
+
/// Kotlin's DEEP_LINK.lowercase() is "deep_link"; Swift's `.deepLink`
|
|
841
|
+
/// described is "deepLink". Deriving either from the case name produces a
|
|
842
|
+
/// value the JS renderer does not branch on, on one platform only.
|
|
843
|
+
private static func buttonActionWireValue(_ action: ButtonAction) -> String {
|
|
844
|
+
switch action {
|
|
845
|
+
case .dismiss: return "dismiss"
|
|
846
|
+
case .url: return "url"
|
|
847
|
+
case .deepLink: return "deep_link"
|
|
848
|
+
case .requestPushPermission: return "request_push_permission"
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/// Report an impression for a message JS rendered.
|
|
853
|
+
///
|
|
854
|
+
/// Also previously a no-op, and it could not stay one once the callback above
|
|
855
|
+
/// works: the SDK cannot see a message it did not draw. Reporting
|
|
856
|
+
/// `displayed` is what emits in_app.displayed AND spends the campaign's
|
|
857
|
+
/// frequency budget (contract item 17); an unreported campaign reads as
|
|
858
|
+
/// delivered to nobody and breaks revenue attribution.
|
|
859
|
+
@objc func trackInAppImpression(_ campaignId: String?, action: String?, elementId: String?) {
|
|
860
|
+
guard present("trackInAppImpression", "campaignId", campaignId), let campaignId,
|
|
861
|
+
present("trackInAppImpression", "action", action), let action else { return }
|
|
862
|
+
let element = (elementId?.isEmpty ?? true) ? nil : elementId
|
|
863
|
+
Joryio.shared.trackInAppImpression(campaignId: campaignId, action: action, elementId: element)
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/// A form inside an HTML message the app rendered itself. Resolves with the
|
|
867
|
+
/// server's verdict ({ ok, message?, redirect?, errors? }) - never rejects,
|
|
868
|
+
/// a failure is `ok: false`. Mirrors sdk-ios `Joryio.submitInAppForm`.
|
|
869
|
+
@objc func submitInAppForm(_ campaignId: String?, values: NSDictionary?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
870
|
+
guard present("submitInAppForm", "campaignId", campaignId), let campaignId else {
|
|
871
|
+
resolve(["ok": false, "message": "Something went wrong. Please try again."])
|
|
872
|
+
return
|
|
873
|
+
}
|
|
874
|
+
let dict = (values as? [String: Any]) ?? [:]
|
|
875
|
+
Task {
|
|
876
|
+
let r = await Joryio.shared.submitInAppForm(campaignId: campaignId, values: dict)
|
|
877
|
+
var out: [String: Any] = ["ok": r.ok]
|
|
878
|
+
if let m = r.message { out["message"] = m }
|
|
879
|
+
if let u = r.redirect { out["redirect"] = u }
|
|
880
|
+
if let e = r.errors { out["errors"] = e }
|
|
881
|
+
resolve(out)
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// MARK: - SDK Authentication
|
|
886
|
+
|
|
887
|
+
/// Supply/replace the customer-minted SDK-authentication JWT.
|
|
888
|
+
/// Mirrors sdk-ios `Joryio.setSdkAuthenticationToken(_:)`.
|
|
889
|
+
@objc func setSdkAuthenticationToken(_ token: String?) {
|
|
890
|
+
guard present("setSdkAuthenticationToken", "token", token), let token else { return }
|
|
891
|
+
Joryio.shared.setSdkAuthenticationToken(token)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/// Forward an SDK-authentication rejection to JS. Only emits when the JS side
|
|
895
|
+
/// has an active listener (RCTEventEmitter requirement).
|
|
896
|
+
private func emitSdkAuthError(_ authError: SdkAuthError) {
|
|
897
|
+
guard hasListeners else { return }
|
|
898
|
+
// All four fields, on both platforms. A JS handler is written once; it used
|
|
899
|
+
// to receive {reason, endpoint, refreshExhausted} here and
|
|
900
|
+
// {reason, rawReason} on Android, so half of any handler was silently dead
|
|
901
|
+
// on whichever platform the developer had not tested.
|
|
902
|
+
sendEvent(withName: "JoryioSdkAuthError", body: [
|
|
903
|
+
"reason": authError.reason.rawValue,
|
|
904
|
+
"rawReason": authError.rawReason as Any,
|
|
905
|
+
"endpoint": authError.endpoint,
|
|
906
|
+
"refreshExhausted": authError.refreshExhausted
|
|
907
|
+
])
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// MARK: - Utilities
|
|
911
|
+
|
|
912
|
+
@objc func flush() {
|
|
913
|
+
Joryio.shared.flush()
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
@objc func getAnonymousId(
|
|
917
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
918
|
+
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
919
|
+
) {
|
|
920
|
+
resolve(Joryio.shared.getAnonymousId())
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/// Fetch in-app campaigns now, and display any that are eligible.
|
|
924
|
+
///
|
|
925
|
+
/// The SDK syncs on session start and on foreground; this is for the moments
|
|
926
|
+
/// in between - after a reset, or after the user does something that should
|
|
927
|
+
/// make them newly eligible. The RN bridge had no sync at all, so a React
|
|
928
|
+
/// Native app could only wait for the next lifecycle event.
|
|
929
|
+
@objc func syncInAppCampaigns() {
|
|
930
|
+
Task { await Joryio.shared.syncInAppCampaigns() }
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/// Forget which campaigns have already been shown on this device.
|
|
934
|
+
///
|
|
935
|
+
/// The SDK filters out any campaign in `displayedCampaigns`, and that set is
|
|
936
|
+
/// PERSISTED - so a message shows once per install and then never again.
|
|
937
|
+
/// Native apps could already call this; React Native could not, which made a
|
|
938
|
+
/// campaign untestable after its first display without deleting the app.
|
|
939
|
+
@objc func resetDisplayedCampaigns() {
|
|
940
|
+
Joryio.shared.resetDisplayedCampaigns()
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/// What the SDK is ACTUALLY doing, as opposed to what the app configured.
|
|
944
|
+
///
|
|
945
|
+
/// A host app can only display the config it set, which stays cheerfully
|
|
946
|
+
/// green while every request is rejected. Returns the live endpoint and the
|
|
947
|
+
/// last transport failure so a status view can tell the truth.
|
|
948
|
+
@objc func getDiagnostics(
|
|
949
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
950
|
+
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
951
|
+
) {
|
|
952
|
+
var result: [String: Any] = [
|
|
953
|
+
"apiEndpoint": Joryio.shared.currentApiEndpoint ?? NSNull(),
|
|
954
|
+
]
|
|
955
|
+
if let err = Joryio.shared.lastTransportError {
|
|
956
|
+
result["lastError"] = [
|
|
957
|
+
"message": err.message,
|
|
958
|
+
"isUnauthorized": err.isUnauthorized,
|
|
959
|
+
"isRetryable": err.isRetryable,
|
|
960
|
+
"at": err.at.timeIntervalSince1970 * 1000,
|
|
961
|
+
]
|
|
962
|
+
}
|
|
963
|
+
resolve(result)
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
@objc func getUserId(
|
|
967
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
968
|
+
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
969
|
+
) {
|
|
970
|
+
resolve(Joryio.shared.getUserId())
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
@objc func getSessionId(
|
|
974
|
+
_ resolve: @escaping RCTPromiseResolveBlock,
|
|
975
|
+
rejecter reject: @escaping RCTPromiseRejectBlock
|
|
976
|
+
) {
|
|
977
|
+
resolve(Joryio.shared.getSessionId())
|
|
978
|
+
}
|
|
979
|
+
}
|