@digia-engage/core 1.0.0-beta.5 → 1.0.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 (44) hide show
  1. package/DigiaEngageReactNative.podspec +12 -15
  2. package/README.md +8 -17
  3. package/android/.project +28 -0
  4. package/android/build.gradle +1 -1
  5. package/android/settings.gradle +1 -2
  6. package/android/src/main/java/com/digia/engage/rn/DigiaModule.kt +1 -1
  7. package/android/src/main/java/com/digia/engage/rn/DigiaSlotViewManager.kt +146 -31
  8. package/ios/DigiaEngageModule.m +1 -0
  9. package/ios/DigiaHostViewManager.swift +41 -17
  10. package/ios/DigiaModule.swift +55 -6
  11. package/ios/DigiaSlotViewManager.swift +190 -22
  12. package/ios/RNEventBridgePlugin.swift +10 -11
  13. package/lib/commonjs/Digia.js +50 -0
  14. package/lib/commonjs/Digia.js.map +1 -1
  15. package/lib/commonjs/DigiaHostView.js +4 -50
  16. package/lib/commonjs/DigiaHostView.js.map +1 -1
  17. package/lib/commonjs/DigiaSlotView.js +35 -53
  18. package/lib/commonjs/DigiaSlotView.js.map +1 -1
  19. package/lib/commonjs/NativeDigiaEngage.js.map +1 -1
  20. package/lib/module/Digia.js +50 -0
  21. package/lib/module/Digia.js.map +1 -1
  22. package/lib/module/DigiaHostView.js +4 -51
  23. package/lib/module/DigiaHostView.js.map +1 -1
  24. package/lib/module/DigiaSlotView.js +35 -51
  25. package/lib/module/DigiaSlotView.js.map +1 -1
  26. package/lib/module/NativeDigiaEngage.js.map +1 -1
  27. package/lib/typescript/Digia.d.ts +12 -0
  28. package/lib/typescript/Digia.d.ts.map +1 -1
  29. package/lib/typescript/DigiaHostView.d.ts +2 -29
  30. package/lib/typescript/DigiaHostView.d.ts.map +1 -1
  31. package/lib/typescript/DigiaSlotView.d.ts +3 -40
  32. package/lib/typescript/DigiaSlotView.d.ts.map +1 -1
  33. package/lib/typescript/NativeDigiaEngage.d.ts.map +1 -1
  34. package/lib/typescript/index.d.ts +1 -1
  35. package/lib/typescript/index.d.ts.map +1 -1
  36. package/lib/typescript/types.d.ts +21 -0
  37. package/lib/typescript/types.d.ts.map +1 -1
  38. package/package.json +8 -18
  39. package/src/Digia.ts +60 -1
  40. package/src/DigiaHostView.tsx +5 -48
  41. package/src/DigiaSlotView.tsx +40 -48
  42. package/src/NativeDigiaEngage.ts +1 -0
  43. package/src/index.ts +1 -1
  44. package/src/types.ts +30 -0
@@ -8,13 +8,17 @@ Pod::Spec.new do |s|
8
8
  (Jetpack Compose) using the New Architecture (TurboModules / Fabric).
9
9
  DESC
10
10
 
11
- s.homepage = 'https://github.com/Digia-Technology-Private-Limited/digia_engage'
12
11
  s.license = { :type => 'MIT', :file => '../LICENSE' }
13
- s.author = { 'Digia Technology Private Limited' => '' }
14
- s.source = { :git => 'https://github.com/Digia-Technology-Private-Limited/digia_engage.git', :tag => s.version.to_s }
15
12
 
16
- # DigiaEngage iOS SDK requires iOS 16+ (SwiftUI features used internally).
17
- s.ios.deployment_target = '16.0'
13
+ s.authors = { 'Digia Technology Private Limited' => 'https://digia.tech' }
14
+ s.homepage = 'https://github.com/Digia-Technology-Private-Limited/digia_engage'
15
+ s.source = {
16
+ :git => 'https://github.com/Digia-Technology-Private-Limited/digia_engage.git',
17
+ :tag => "react-native-v#{s.version}"
18
+ }
19
+
20
+ # DigiaEngage iOS SDK requires iOS 17+ (SwiftUI features used internally).
21
+ s.ios.deployment_target = '17.0'
18
22
 
19
23
  s.source_files = 'ios/**/*.{h,m,mm,swift}'
20
24
 
@@ -24,17 +28,10 @@ Pod::Spec.new do |s|
24
28
  s.dependency 'React-Core'
25
29
 
26
30
  # ── Digia Engage iOS SDK ──────────────────────────────────────────────────
27
- # Published Swift Package: https://swiftpackageindex.com/Digia-Technology-Private-Limited/digia_engage_iOS
28
- # Source: https://github.com/Digia-Technology-Private-Limited/digia_engage_iOS.git
29
- #
30
- # When integrating via CocoaPods, add the git source to your Podfile:
31
- # pod 'DigiaEngage', :git => 'https://github.com/Digia-Technology-Private-Limited/digia_engage_iOS.git',
32
- # :tag => '1.0.0-beta.1'
33
- s.dependency 'DigiaEngage', '1.0.0-beta.1'
31
+ # Available on SPM: https://swiftpackageindex.com/Digia-Technology-Private-Limited/digia_engage_iOS
32
+ # CocoaPods: host app Podfile must declare the git source (see README).
33
+ s.dependency 'DigiaEngage'
34
34
 
35
35
  # ── New Architecture (Fabric / TurboModules) support ─────────────────────
36
- # install_modules_dependencies wires the pod into both Old Architecture
37
- # (bridge) and New Architecture (JSI / Fabric) automatically when the host
38
- # app has `use_frameworks!` / `use_react_native!` with :fabric_enabled.
39
36
  install_modules_dependencies(s)
40
37
  end
package/README.md CHANGED
@@ -158,10 +158,9 @@ const navRef = useNavigationContainerRef();
158
158
  >
159
159
  ```
160
160
 
161
- ### 3 – Open the Digia UI navigation flow (Android)
161
+ ### 3 – Open the Digia UI navigation flow
162
162
 
163
- Launch the full-screen Compose navigation activity managed by the Digia DSL
164
- configuration:
163
+ Launch the full-screen native SDUI stack:
165
164
 
166
165
  ```tsx
167
166
  import { Digia } from '@digia/engage-react-native';
@@ -170,12 +169,7 @@ function MyScreen() {
170
169
  return (
171
170
  <Button
172
171
  title="Open Digia Experience"
173
- onPress={() =>
174
- Digia.openNavigation({
175
- startPageId: 'onboarding',
176
- pageArgs: { userId: '123' },
177
- })
178
- }
172
+ onPress={() => Digia.createInitialPage()}
179
173
  />
180
174
  );
181
175
  }
@@ -216,7 +210,7 @@ const styles = StyleSheet.create({ root: { flex: 1 } });
216
210
  |---|---|---|
217
211
  | `initialize` | `(config: DigiaConfig) => Promise<void>` | Initialise the SDK and mount the Compose overlay host. |
218
212
  | `setCurrentScreen` | `(name: string) => void` | Notify the SDK of the current screen. |
219
- | `openNavigation` | `(options?: DigiaNavigationOptions) => void` | Launch the full-screen Digia UI navigation activity. |
213
+ | `createInitialPage` | `() => void` | Full-screen Digia SDUI (Android: `DigiaUINavigationActivity`; iOS: modal `DigiaNavigationView`). |
220
214
 
221
215
  ### `DigiaConfig`
222
216
 
@@ -226,12 +220,9 @@ const styles = StyleSheet.create({ root: { flex: 1 } });
226
220
  | `environment` | `'production' \| 'sandbox'` | `'production'` | Target environment. |
227
221
  | `logLevel` | `'none' \| 'error' \| 'verbose'` | `'error'` | Log verbosity. |
228
222
 
229
- ### `DigiaNavigationOptions`
223
+ ### `CreateInitialPageOptions`
230
224
 
231
- | Prop | Type | Description |
232
- |---|---|---|
233
- | `startPageId` | `string?` | DSL page ID to start from. |
234
- | `pageArgs` | `Record<string, string>?` | Key/value args forwarded to the start page. |
225
+ Empty interface reserved for future optional arguments.
235
226
 
236
227
  ### `<DigiaHostView>`
237
228
 
@@ -251,14 +242,14 @@ react-native/
251
242
  │ ├── index.ts ← Public API exports
252
243
  │ ├── types.ts ← TypeScript interfaces
253
244
  │ ├── Digia.ts ← High-level JS SDK wrapper
254
- │ ├── NativeDigiaModule.ts ← Low-level native module binding
245
+ │ ├── NativeDigiaEngage.ts ← Low-level native module binding
255
246
  │ └── DigiaHostView.tsx ← <DigiaHostView> React component
256
247
 
257
248
  ├── android/
258
249
  │ ├── build.gradle ← Android library build config
259
250
  │ └── src/main/java/com/digia/engage/rn/
260
251
  │ ├── DigiaPackage.kt ← ReactPackage (registers module + view)
261
- │ ├── DigiaModule.kt ← NativeModule (initialize, setCurrentScreen, openNavigation)
252
+ │ ├── DigiaModule.kt ← NativeModule (initialize, setCurrentScreen, createInitialPage)
262
253
  │ ├── DigiaViewManager.kt ← ViewManager for <DigiaHostView>
263
254
  │ └── DigiaHostComposeView.kt ← AbstractComposeView hosting DigiaHost { }
264
255
 
@@ -0,0 +1,28 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <projectDescription>
3
+ <name>digia-engage_core</name>
4
+ <comment>Project digia-engage_core created by Buildship.</comment>
5
+ <projects>
6
+ </projects>
7
+ <buildSpec>
8
+ <buildCommand>
9
+ <name>org.eclipse.buildship.core.gradleprojectbuilder</name>
10
+ <arguments>
11
+ </arguments>
12
+ </buildCommand>
13
+ </buildSpec>
14
+ <natures>
15
+ <nature>org.eclipse.buildship.core.gradleprojectnature</nature>
16
+ </natures>
17
+ <filteredResources>
18
+ <filter>
19
+ <id>1775559486270</id>
20
+ <name></name>
21
+ <type>30</type>
22
+ <matcher>
23
+ <id>org.eclipse.core.resources.regexFilterMatcher</id>
24
+ <arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
25
+ </matcher>
26
+ </filter>
27
+ </filteredResources>
28
+ </projectDescription>
@@ -71,7 +71,7 @@ android {
71
71
 
72
72
  dependencies {
73
73
  // Digia Engage Android library
74
- implementation 'tech.digia:engage:1.0.0-beta.03'
74
+ implementation 'tech.digia:engage:1.0.0-beta.04'
75
75
 
76
76
  // ── React Native ─────────────────────────────────────────────────────────
77
77
  // React Native is provided by the host app; mark as compileOnly so it is
@@ -17,8 +17,7 @@ dependencyResolutionManagement {
17
17
  repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
18
18
  repositories {
19
19
  google()
20
- mavenCentral() // tech.digia:engage published at https://central.sonatype.com/artifact/tech.digia/engage
21
- maven { url 'https://jitpack.io' }
20
+ mavenCentral()
22
21
  }
23
22
  }
24
23
 
@@ -222,7 +222,7 @@ internal class RNEventBridgePlugin(
222
222
  is DigiaExperienceEvent.Dismissed -> putString("type", "dismissed")
223
223
  }
224
224
  }
225
- emit("digiaOverlayEvent", params)
225
+ emit("digiaEngageEvent", params)
226
226
  }
227
227
 
228
228
  override fun teardown() {
@@ -1,15 +1,8 @@
1
- /**
2
- * DigiaSlotViewManager
3
- *
4
- * React Native ViewManager that exposes [DigiaSlotView] (from the Digia Android SDK) as the native
5
- * view behind the JS `<DigiaSlotView>` component.
6
- *
7
- * Supported JS props:
8
- * - `placementKey` (String) — matches the placement key set in the Digia dashboard.
9
- */
10
1
  package com.digia.engage.rn
11
2
 
12
3
  import android.content.Context
4
+ import android.view.View
5
+ import android.view.ViewTreeObserver
13
6
  import android.widget.FrameLayout
14
7
  import androidx.lifecycle.LifecycleOwner
15
8
  import androidx.lifecycle.ViewModelStoreOwner
@@ -18,43 +11,165 @@ import androidx.lifecycle.setViewTreeViewModelStoreOwner
18
11
  import androidx.savedstate.SavedStateRegistryOwner
19
12
  import androidx.savedstate.setViewTreeSavedStateRegistryOwner
20
13
  import com.digia.engage.DigiaSlotView
14
+ import com.facebook.react.bridge.Arguments
15
+ import com.facebook.react.bridge.WritableMap
21
16
  import com.facebook.react.uimanager.SimpleViewManager
22
17
  import com.facebook.react.uimanager.ThemedReactContext
18
+ import com.facebook.react.uimanager.UIManagerHelper
23
19
  import com.facebook.react.uimanager.annotations.ReactProp
20
+ import com.facebook.react.uimanager.events.Event
21
+ import java.util.concurrent.atomic.AtomicInteger
24
22
 
25
- internal class DigiaSlotViewManager : SimpleViewManager<DigiaSlotView>() {
23
+ // ── ContentSizeChangeEvent ────────────────────────────────────────────────────
26
24
 
27
- override fun getName(): String = VIEW_NAME
25
+ private class ContentSizeChangeEvent(
26
+ surfaceId: Int,
27
+ viewTag: Int,
28
+ private val heightDp: Double,
29
+ ) : Event<ContentSizeChangeEvent>(surfaceId, viewTag) {
30
+ override fun getEventName(): String = "onContentSizeChange"
31
+ override fun getEventData(): WritableMap =
32
+ Arguments.createMap().apply { putDouble("height", heightDp) }
33
+ }
28
34
 
29
- override fun createViewInstance(context: ThemedReactContext): DigiaSlotView {
30
- val activityContext: Context = context.currentActivity ?: context
35
+ // ── DigiaSlotContainerView ────────────────────────────────────────────────────
31
36
 
32
- val view = DigiaSlotView(activityContext)
37
+ // Plain FrameLayout wrapper: Fabric can measure it before window attachment.
38
+ // The inner DigiaSlotView (ComposeView) is created lazily in onAttachedToWindow.
39
+ internal class DigiaSlotContainerView(context: Context) : FrameLayout(context) {
33
40
 
34
- // Wire Architecture Component owners so the Compose runtime works correctly.
35
- val activity = context.currentActivity
36
- if (activity is LifecycleOwner) {
37
- view.setViewTreeLifecycleOwner(activity)
38
- }
39
- if (activity is ViewModelStoreOwner) {
40
- view.setViewTreeViewModelStoreOwner(activity)
41
+ var rnContext: ThemedReactContext? = null
42
+
43
+ private var _slotView: DigiaSlotView? = null
44
+ private val lastReportedHeightPx = AtomicInteger(-1)
45
+
46
+ var placementKey: String = ""
47
+ set(value) {
48
+ field = value
49
+ val slot = _slotView ?: return
50
+ slot.placementKey = value
51
+ lastReportedHeightPx.set(-1)
52
+ // Defer measure so the slot has a non-zero width. Do NOT call requestLayout() here —
53
+ // it sets PFLAG_FORCE_LAYOUT on the container and RN bypasses a full traversal,
54
+ // causing subsequent Compose requestLayout() calls to be swallowed.
55
+ post { measureAndDispatch() }
56
+ postDelayed({
57
+ lastReportedHeightPx.set(-1)
58
+ measureAndDispatch()
59
+ }, DELAYED_MEASURE_MS)
41
60
  }
42
- if (activity is SavedStateRegistryOwner) {
43
- view.setViewTreeSavedStateRegistryOwner(activity)
61
+
62
+ private val globalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
63
+ measureAndDispatch()
64
+ }
65
+
66
+ // preDrawListener catches Compose content changes that globalLayoutListener misses in RN's
67
+ // layout model: Compose's invalidate() propagates to ViewRootImpl even when requestLayout()
68
+ // is blocked by PFLAG_FORCE_LAYOUT.
69
+ private val preDrawListener = ViewTreeObserver.OnPreDrawListener {
70
+ measureAndDispatch()
71
+ true
72
+ }
73
+
74
+ override fun onAttachedToWindow() {
75
+ super.onAttachedToWindow()
76
+ if (_slotView == null) createSlotView()
77
+ viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener)
78
+ viewTreeObserver.addOnPreDrawListener(preDrawListener)
79
+ post { measureAndDispatch() }
80
+ // Retry: catches campaigns that arrive slightly after mount.
81
+ postDelayed({
82
+ if (lastReportedHeightPx.get() <= 0) {
83
+ lastReportedHeightPx.set(-1)
84
+ measureAndDispatch()
85
+ }
86
+ }, DELAYED_MEASURE_MS)
87
+ }
88
+
89
+ override fun onDetachedFromWindow() {
90
+ viewTreeObserver.removeOnGlobalLayoutListener(globalLayoutListener)
91
+ viewTreeObserver.removeOnPreDrawListener(preDrawListener)
92
+ super.onDetachedFromWindow()
93
+ lastReportedHeightPx.set(-1)
94
+ }
95
+
96
+ // Defer measureAndDispatch to avoid re-measuring during Compose's composition phase,
97
+ // which crashes with "pending composition has not been applied".
98
+ override fun requestLayout() {
99
+ super.requestLayout()
100
+ post { measureAndDispatch() }
101
+ }
102
+
103
+ private fun createSlotView() {
104
+ val themedCtx = context as? ThemedReactContext
105
+ val activityCtx: Context = themedCtx?.currentActivity ?: context
106
+
107
+ val slot = DigiaSlotView(activityCtx)
108
+
109
+ val activity = themedCtx?.currentActivity
110
+ if (activity is LifecycleOwner) slot.setViewTreeLifecycleOwner(activity)
111
+ if (activity is ViewModelStoreOwner) slot.setViewTreeViewModelStoreOwner(activity)
112
+ if (activity is SavedStateRegistryOwner) slot.setViewTreeSavedStateRegistryOwner(activity)
113
+
114
+ slot.placementKey = placementKey
115
+ slot.layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)
116
+ slot.addOnLayoutChangeListener { _: View, l: Int, _: Int, r: Int, _: Int,
117
+ _: Int, _: Int, _: Int, _: Int ->
118
+ if (r - l > 0) measureAndDispatch()
44
119
  }
45
120
 
46
- // React Native controls sizing; fill whatever space the JS stylesheet allocates.
47
- view.layoutParams =
48
- FrameLayout.LayoutParams(
49
- FrameLayout.LayoutParams.MATCH_PARENT,
50
- FrameLayout.LayoutParams.MATCH_PARENT,
51
- )
121
+ addView(slot)
122
+ _slotView = slot
123
+ }
124
+
125
+ fun measureAndDispatch() {
126
+ val slot = _slotView ?: return
127
+ val ctx = rnContext ?: return
128
+ val viewWidth = width
129
+ if (viewWidth <= 0) return
130
+
131
+ val widthSpec = MeasureSpec.makeMeasureSpec(viewWidth, MeasureSpec.EXACTLY)
132
+ val heightSpec = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)
133
+ slot.measure(widthSpec, heightSpec)
134
+ val intrinsicHeightPx = slot.measuredHeight
135
+
136
+ if (intrinsicHeightPx == lastReportedHeightPx.get()) return
137
+ lastReportedHeightPx.set(intrinsicHeightPx)
138
+
139
+ val density = resources.displayMetrics.density
140
+ val heightDp = intrinsicHeightPx / density
141
+
142
+ val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(ctx, id) ?: return
143
+ val surfaceId = UIManagerHelper.getSurfaceId(this)
144
+ dispatcher.dispatchEvent(ContentSizeChangeEvent(surfaceId, id, heightDp.toDouble()))
145
+ }
146
+
147
+ companion object {
148
+ private const val DELAYED_MEASURE_MS = 300L
149
+ }
150
+ }
151
+
152
+ // ── DigiaSlotViewManager ──────────────────────────────────────────────────────
153
+
154
+ internal class DigiaSlotViewManager : SimpleViewManager<DigiaSlotContainerView>() {
155
+
156
+ override fun getName(): String = VIEW_NAME
157
+
158
+ override fun getExportedCustomDirectEventTypeConstants(): Map<String, Any> =
159
+ mapOf("onContentSizeChange" to mapOf("registrationName" to "onContentSizeChange"))
52
160
 
53
- return view
161
+ override fun createViewInstance(context: ThemedReactContext): DigiaSlotContainerView {
162
+ val container = DigiaSlotContainerView(context)
163
+ container.rnContext = context
164
+ container.layoutParams = FrameLayout.LayoutParams(
165
+ FrameLayout.LayoutParams.MATCH_PARENT,
166
+ FrameLayout.LayoutParams.MATCH_PARENT,
167
+ )
168
+ return container
54
169
  }
55
170
 
56
171
  @ReactProp(name = "placementKey")
57
- fun setPlacementKey(view: DigiaSlotView, placementKey: String?) {
172
+ fun setPlacementKey(view: DigiaSlotContainerView, placementKey: String?) {
58
173
  view.placementKey = placementKey.orEmpty()
59
174
  }
60
175
 
@@ -48,4 +48,5 @@ RCT_EXTERN_METHOD(invalidateCampaign:(NSString *)campaignId)
48
48
 
49
49
  @interface RCT_EXTERN_MODULE(DigiaSlotView, RCTViewManager)
50
50
  RCT_EXPORT_VIEW_PROPERTY(placementKey, NSString)
51
+ RCT_EXPORT_VIEW_PROPERTY(onContentSizeChange, RCTDirectEventBlock)
51
52
  @end
@@ -48,38 +48,62 @@ final class DigiaHostUIView: UIView {
48
48
  }
49
49
 
50
50
  private func mountHostingController() {
51
- guard let parentVC = parentViewController() else { return }
51
+ guard let parentVC = parentViewController() else { return}
52
52
 
53
53
  let swiftUIView = DigiaHostWrapperView()
54
54
  let hc = UIHostingController(rootView: swiftUIView)
55
55
  hc.view.translatesAutoresizingMaskIntoConstraints = false
56
56
  hc.view.backgroundColor = .clear
57
- // Disable touch interception so all taps pass through to React Native
58
- // content below. The dialog/bottom-sheet overlays are presented as
59
- // separate UIViewControllers (via ViewControllerUtil.present) so they
60
- // independently capture touches when visible.
61
- hc.view.isUserInteractionEnabled = false
62
57
 
63
58
  parentVC.addChild(hc)
64
- addSubview(hc.view)
59
+ // Mount onto parentVC.view (full-screen) rather than self, because
60
+ // DigiaHostView is intentionally sized 0×0 in React Native (it takes no
61
+ // screen space). A zero-size UIHostingController frame prevents SwiftUI
62
+ // from rendering its body, which means @ObservedObject subscriptions and
63
+ // .onChange(of:) modifiers are never established — so activePayload
64
+ // changes are silently dropped and no overlay is ever shown.
65
+ // Anchoring to parentVC.view guarantees a non-zero frame so SwiftUI's
66
+ // rendering loop runs and reacts to SDK state changes.
67
+ parentVC.view.addSubview(hc.view)
65
68
  hc.didMove(toParent: parentVC)
66
69
 
67
70
  NSLayoutConstraint.activate([
68
- hc.view.leadingAnchor.constraint(equalTo: leadingAnchor),
69
- hc.view.trailingAnchor.constraint(equalTo: trailingAnchor),
70
- hc.view.topAnchor.constraint(equalTo: topAnchor),
71
- hc.view.bottomAnchor.constraint(equalTo: bottomAnchor),
71
+ hc.view.leadingAnchor.constraint(equalTo: parentVC.view.leadingAnchor),
72
+ hc.view.trailingAnchor.constraint(equalTo: parentVC.view.trailingAnchor),
73
+ hc.view.topAnchor.constraint(equalTo: parentVC.view.topAnchor),
74
+ hc.view.bottomAnchor.constraint(equalTo: parentVC.view.bottomAnchor),
72
75
  ])
73
76
 
74
77
  hostingController = hc
75
78
  }
76
79
 
77
- // Walk the responder chain to find the nearest UIViewController.
80
+ // Pass touches through to RN when no overlay is active.
81
+ // When an overlay renders in-host (bottom sheet / dialog inside DigiaHost's ZStack),
82
+ // SwiftUI's hit test returns the overlay view and we forward that — making the
83
+ // overlay fully interactive. When nothing is rendered (EmptyView), SwiftUI returns
84
+ // nil and we return nil, so UIKit falls through to RN content below.
85
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
86
+ return hostingController?.view.hitTest(point, with: event)
87
+ }
88
+
89
+ /// Prefer React Native’s `UIView.reactViewController`, then walk `next` from each view’s `.next`.
78
90
  private func parentViewController() -> UIViewController? {
79
- var responder: UIResponder? = self
80
- while let r = responder {
81
- if let vc = r as? UIViewController { return vc }
82
- responder = r.next
91
+ let reactSel = NSSelectorFromString("reactViewController")
92
+ var view: UIView? = self
93
+ while let v = view {
94
+ if v.responds(to: reactSel), let raw = v.perform(reactSel)?.takeUnretainedValue() {
95
+ if let vc = raw as? UIViewController { return vc }
96
+ }
97
+ view = v.superview
98
+ }
99
+ view = self
100
+ while let v = view {
101
+ var r: UIResponder? = v.next
102
+ while let responder = r {
103
+ if let vc = responder as? UIViewController { return vc }
104
+ r = responder.next
105
+ }
106
+ view = v.superview
83
107
  }
84
108
  return nil
85
109
  }
@@ -91,7 +115,7 @@ final class DigiaHostUIView: UIView {
91
115
  /// content because React Native's own navigation already manages the app's
92
116
  /// view hierarchy — DigiaHost only needs to be mounted to activate the overlay
93
117
  /// layer.
94
- private struct DigiaHostWrapperView: View {
118
+ struct DigiaHostWrapperView: View {
95
119
  var body: some View {
96
120
  DigiaHost {
97
121
  EmptyView()
@@ -9,6 +9,7 @@
9
9
  * setCurrentScreen(name): void
10
10
  * triggerCampaign(id, content, cepContext): void
11
11
  * invalidateCampaign(campaignId): void
12
+ * createInitialPage(): void // AppConfig initial route
12
13
  *
13
14
  * Architecture
14
15
  * ────────────
@@ -27,6 +28,8 @@
27
28
  */
28
29
  import Foundation
29
30
  import React
31
+ import SwiftUI
32
+ import UIKit
30
33
  import DigiaEngage
31
34
 
32
35
  @objc(DigiaEngageModule)
@@ -41,12 +44,16 @@ final class DigiaModule: RCTEventEmitter {
41
44
 
42
45
  override static func requiresMainQueueSetup() -> Bool { true }
43
46
 
47
+ override init() {
48
+ super.init()
49
+ // Pre-seed _listenerCount = 1 so sendEventWithName: never silently drops
50
+ // events when JS uses DeviceEventEmitter (which doesn't call native
51
+ // addListener: on iOS and therefore never increments the count).
52
+ addListener("digiaEngageEvent")
53
+ }
54
+
44
55
  override func supportedEvents() -> [String]! {
45
- return [
46
- "digia_experience_impressed",
47
- "digia_experience_clicked",
48
- "digia_experience_dismissed",
49
- ]
56
+ return ["digiaEngageEvent"]
50
57
  }
51
58
 
52
59
  // ────────────────────────────────────────────────────────────────────────
@@ -77,6 +84,7 @@ final class DigiaModule: RCTEventEmitter {
77
84
  Task { @MainActor in
78
85
  do {
79
86
  try await Digia.initialize(config)
87
+ self.mountDigiaHost()
80
88
  resolve(nil)
81
89
  } catch {
82
90
  reject("DIGIA_INIT_ERROR", error.localizedDescription, error)
@@ -143,17 +151,58 @@ final class DigiaModule: RCTEventEmitter {
143
151
  }
144
152
  }
145
153
 
154
+ // ────────────────────────────────────────────────────────────────────────
155
+ // MARK: - Internal: mount the SwiftUI overlay host
156
+
157
+ /// Mirrors Android's DigiaModule.mountDigiaHost().
158
+ /// Called once after Digia.initialize() succeeds — no need for a manual
159
+ /// <DigiaHostView> anywhere in the JS component tree.
160
+ @MainActor
161
+ private func mountDigiaHost() {
162
+ // Locate the key window's root view controller.
163
+ guard let rootVC = UIApplication.shared
164
+ .connectedScenes
165
+ .compactMap({ ($0 as? UIWindowScene)?.keyWindow })
166
+ .first?
167
+ .rootViewController else { return }
168
+
169
+ // Guard against double-mounting (e.g. fast-refresh).
170
+ let mountTag = 0xD19140
171
+ if rootVC.view.viewWithTag(mountTag) != nil { return }
172
+
173
+ let hc = UIHostingController(rootView: DigiaHostWrapperView())
174
+ hc.view.tag = mountTag
175
+ hc.view.translatesAutoresizingMaskIntoConstraints = false
176
+ hc.view.backgroundColor = .clear
177
+ // Pass touches through to React Native content below.
178
+ hc.view.isUserInteractionEnabled = false
179
+
180
+ rootVC.addChild(hc)
181
+ rootVC.view.addSubview(hc.view)
182
+ hc.didMove(toParent: rootVC)
183
+
184
+ NSLayoutConstraint.activate([
185
+ hc.view.leadingAnchor.constraint(equalTo: rootVC.view.leadingAnchor),
186
+ hc.view.trailingAnchor.constraint(equalTo: rootVC.view.trailingAnchor),
187
+ hc.view.topAnchor.constraint(equalTo: rootVC.view.topAnchor),
188
+ hc.view.bottomAnchor.constraint(equalTo: rootVC.view.bottomAnchor),
189
+ ])
190
+ }
191
+
146
192
  // ────────────────────────────────────────────────────────────────────────
147
193
  // MARK: - Private helpers
148
194
 
149
195
  private func buildInAppPayloadContent(from map: NSDictionary) -> InAppPayloadContent {
150
- let type = (map["type"] as? String) ?? "dialog"
151
196
  let pk = map["placementKey"] as? String
152
197
  let title = map["title"] as? String
153
198
  let text = map["text"] as? String
154
199
  let viewId = map["viewId"] as? String
155
200
  let command = map["command"] as? String
156
201
  let screenId = map["screenId"] as? String
202
+ var type = (map["type"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
203
+ if type.isEmpty {
204
+ type = (pk?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "").isEmpty ? "dialog" : "inline"
205
+ }
157
206
  let args: [String: JSONValue] = {
158
207
  guard let raw = map["args"] as? [String: Any] else { return [:] }
159
208
  return raw.compactMapValues { JSONValue(rawValue: $0) }