@klarna/react-native-klarna-network-messaging 0.0.1 → 2.6.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 (72) hide show
  1. package/README.md +80 -28
  2. package/android/build.gradle +97 -0
  3. package/android/src/main/AndroidManifest.xml +2 -0
  4. package/android/src/main/java/com/klarna/mobile/sdk/reactnative/klarnanetwork/messaging/KlarnaMessagingPlacementErrorEvent.kt +25 -0
  5. package/android/src/main/java/com/klarna/mobile/sdk/reactnative/klarnanetwork/messaging/KlarnaMessagingPlacementResizedEvent.kt +20 -0
  6. package/android/src/main/java/com/klarna/mobile/sdk/reactnative/klarnanetwork/messaging/KlarnaMessagingPlacementViewManager.kt +251 -0
  7. package/android/src/main/java/com/klarna/mobile/sdk/reactnative/klarnanetwork/messaging/KlarnaMessagingViewManagerHelpers.kt +52 -0
  8. package/android/src/main/java/com/klarna/mobile/sdk/reactnative/klarnanetwork/messaging/KlarnaNetworkMessagingPackage.kt +19 -0
  9. package/android/src/main/java/com/klarna/mobile/sdk/reactnative/klarnanetwork/messaging/RNKlarnaMessagingPlacementViewSpec.kt +22 -0
  10. package/android/src/test/java/com/klarna/mobile/sdk/reactnative/klarnanetwork/messaging/KlarnaMessagingPlacementEventsTest.kt +39 -0
  11. package/android/src/test/java/com/klarna/mobile/sdk/reactnative/klarnanetwork/messaging/KlarnaMessagingPlacementViewManagerTest.kt +76 -0
  12. package/android/src/test/java/com/klarna/mobile/sdk/reactnative/klarnanetwork/messaging/KlarnaMessagingViewManagerHelpersTest.kt +200 -0
  13. package/ios/Sources/KlarnaMessagingPlacementViewFactory.swift +101 -0
  14. package/ios/Sources/KlarnaMessagingPlacementViewHelper.swift +171 -0
  15. package/ios/Sources/KlarnaMessagingPlacementViewManager.h +5 -0
  16. package/ios/Sources/KlarnaMessagingPlacementViewManager.mm +37 -0
  17. package/ios/Sources/view/KlarnaMessagingPlacementViewWrapper.h +7 -0
  18. package/ios/Sources/view/KlarnaMessagingPlacementViewWrapper.mm +183 -0
  19. package/ios/Tests/KlarnaMessagingHeightObserverTests.swift +204 -0
  20. package/ios/Tests/KlarnaMessagingPlacementViewFactoryTests.swift +167 -0
  21. package/ios/Tests/KlarnaMessagingPlacementViewHelperTests.swift +118 -0
  22. package/lib/commonjs/KlarnaMessagingPlacementView.js +102 -0
  23. package/lib/commonjs/KlarnaMessagingPlacementView.js.map +1 -0
  24. package/lib/commonjs/Specs/KlarnaMessagingPlacementViewNativeComponent.js +10 -0
  25. package/lib/commonjs/Specs/KlarnaMessagingPlacementViewNativeComponent.js.map +1 -0
  26. package/lib/commonjs/index.js +27 -0
  27. package/lib/commonjs/index.js.map +1 -0
  28. package/lib/commonjs/internal.js +45 -0
  29. package/lib/commonjs/internal.js.map +1 -0
  30. package/lib/commonjs/types/KlarnaMessagingError.js +2 -0
  31. package/lib/commonjs/types/KlarnaMessagingError.js.map +1 -0
  32. package/lib/commonjs/types/KlarnaMessagingPlacementConfiguration.js +6 -0
  33. package/lib/commonjs/types/KlarnaMessagingPlacementConfiguration.js.map +1 -0
  34. package/lib/commonjs/types/KlarnaMessagingPlacementType.js +15 -0
  35. package/lib/commonjs/types/KlarnaMessagingPlacementType.js.map +1 -0
  36. package/lib/module/KlarnaMessagingPlacementView.js +94 -0
  37. package/lib/module/KlarnaMessagingPlacementView.js.map +1 -0
  38. package/lib/module/Specs/KlarnaMessagingPlacementViewNativeComponent.js +3 -0
  39. package/lib/module/Specs/KlarnaMessagingPlacementViewNativeComponent.js.map +1 -0
  40. package/lib/module/index.js +4 -0
  41. package/lib/module/index.js.map +1 -0
  42. package/lib/module/internal.js +38 -0
  43. package/lib/module/internal.js.map +1 -0
  44. package/lib/module/types/KlarnaMessagingError.js +2 -0
  45. package/lib/module/types/KlarnaMessagingError.js.map +1 -0
  46. package/lib/module/types/KlarnaMessagingPlacementConfiguration.js +2 -0
  47. package/lib/module/types/KlarnaMessagingPlacementConfiguration.js.map +1 -0
  48. package/lib/module/types/KlarnaMessagingPlacementType.js +9 -0
  49. package/lib/module/types/KlarnaMessagingPlacementType.js.map +1 -0
  50. package/lib/typescript/KlarnaMessagingPlacementView.d.ts +39 -0
  51. package/lib/typescript/KlarnaMessagingPlacementView.d.ts.map +1 -0
  52. package/lib/typescript/Specs/KlarnaMessagingPlacementViewNativeComponent.d.ts +23 -0
  53. package/lib/typescript/Specs/KlarnaMessagingPlacementViewNativeComponent.d.ts.map +1 -0
  54. package/lib/typescript/index.d.ts +6 -0
  55. package/lib/typescript/index.d.ts.map +1 -0
  56. package/lib/typescript/internal.d.ts +25 -0
  57. package/lib/typescript/internal.d.ts.map +1 -0
  58. package/lib/typescript/types/KlarnaMessagingError.d.ts +8 -0
  59. package/lib/typescript/types/KlarnaMessagingError.d.ts.map +1 -0
  60. package/lib/typescript/types/KlarnaMessagingPlacementConfiguration.d.ts +25 -0
  61. package/lib/typescript/types/KlarnaMessagingPlacementConfiguration.d.ts.map +1 -0
  62. package/lib/typescript/types/KlarnaMessagingPlacementType.d.ts +8 -0
  63. package/lib/typescript/types/KlarnaMessagingPlacementType.d.ts.map +1 -0
  64. package/package.json +122 -6
  65. package/react-native-klarna-network-messaging.podspec +28 -0
  66. package/src/KlarnaMessagingPlacementView.tsx +139 -0
  67. package/src/Specs/KlarnaMessagingPlacementViewNativeComponent.ts +32 -0
  68. package/src/index.tsx +12 -0
  69. package/src/internal.ts +48 -0
  70. package/src/types/KlarnaMessagingError.ts +7 -0
  71. package/src/types/KlarnaMessagingPlacementConfiguration.ts +29 -0
  72. package/src/types/KlarnaMessagingPlacementType.ts +7 -0
@@ -0,0 +1,76 @@
1
+ package com.klarna.mobile.sdk.reactnative.klarnanetwork.messaging
2
+
3
+ import com.facebook.react.bridge.Arguments
4
+ import com.facebook.react.bridge.JavaOnlyMap
5
+ import org.junit.After
6
+ import org.junit.Assert.assertEquals
7
+ import org.junit.Assert.assertNotNull
8
+ import org.junit.Assert.assertTrue
9
+ import org.junit.Before
10
+ import org.junit.Test
11
+ import org.mockito.MockedStatic
12
+ import org.mockito.Mockito
13
+
14
+ class KlarnaMessagingPlacementViewManagerTest {
15
+
16
+ private lateinit var manager: KlarnaMessagingPlacementViewManager
17
+ private lateinit var mockedArguments: MockedStatic<Arguments>
18
+
19
+ @Before
20
+ fun setUp() {
21
+ manager = KlarnaMessagingPlacementViewManager()
22
+ mockedArguments = Mockito.mockStatic(Arguments::class.java)
23
+ mockedArguments.`when`<JavaOnlyMap> { Arguments.createMap() }
24
+ .thenAnswer { JavaOnlyMap() }
25
+ }
26
+
27
+ @After
28
+ fun tearDown() {
29
+ mockedArguments.close()
30
+ }
31
+
32
+ // MARK: - getName
33
+
34
+ @Test
35
+ fun getName_returnsCorrectReactClass() {
36
+ assertEquals("RNKlarnaMessagingPlacementView", manager.name)
37
+ }
38
+
39
+ // MARK: - getExportedCustomDirectEventTypeConstants
40
+
41
+ @Test
42
+ fun eventConstants_containsOnError() {
43
+ val constants = manager.exportedCustomDirectEventTypeConstants
44
+ assertNotNull(constants)
45
+ assertTrue(constants!!.containsKey("onError"))
46
+ }
47
+
48
+ @Test
49
+ fun eventConstants_containsOnResized() {
50
+ val constants = manager.exportedCustomDirectEventTypeConstants
51
+ assertNotNull(constants)
52
+ assertTrue(constants!!.containsKey("onResized"))
53
+ }
54
+
55
+ @Test
56
+ fun eventConstants_onErrorRegistrationName() {
57
+ val constants = manager.exportedCustomDirectEventTypeConstants!!
58
+ @Suppress("UNCHECKED_CAST")
59
+ val onError = constants["onError"] as Map<String, Any>
60
+ assertEquals("onError", onError["registrationName"])
61
+ }
62
+
63
+ @Test
64
+ fun eventConstants_onResizedRegistrationName() {
65
+ val constants = manager.exportedCustomDirectEventTypeConstants!!
66
+ @Suppress("UNCHECKED_CAST")
67
+ val onResized = constants["onResized"] as Map<String, Any>
68
+ assertEquals("onResized", onResized["registrationName"])
69
+ }
70
+
71
+ @Test
72
+ fun eventConstants_hasExactlyTwoEntries() {
73
+ val constants = manager.exportedCustomDirectEventTypeConstants!!
74
+ assertEquals(2, constants.size)
75
+ }
76
+ }
@@ -0,0 +1,200 @@
1
+ package com.klarna.mobile.sdk.reactnative.klarnanetwork.messaging
2
+
3
+ import com.klarna.mobile.sdk.reactnative.klarnanetwork.messaging.KlarnaMessagingViewManagerHelpers.buildConfiguration
4
+ import com.klarna.mobile.sdk.reactnative.klarnanetwork.messaging.KlarnaMessagingViewManagerHelpers.parseAmount
5
+ import com.klarna.mobile.sdk.reactnative.klarnanetwork.messaging.KlarnaMessagingViewManagerHelpers.parseTheme
6
+ import com.klarna.mobile.sdk.reactnative.klarnanetwork.messaging.KlarnaMessagingViewManagerHelpers.resolvePlacementType
7
+ import com.klarna.mobile.sdk.reactnative.klarnanetwork.messaging.KlarnaMessagingViewManagerHelpers.shouldReportHeight
8
+ import com.klarna.mobile.sdk.api.KlarnaTheme
9
+ import com.klarna.mobile.sdk.klarna.network.messaging.api.KlarnaMessagingPlacementConfiguration
10
+ import org.junit.Assert.assertEquals
11
+ import org.junit.Assert.assertFalse
12
+ import org.junit.Assert.assertNull
13
+ import org.junit.Assert.assertTrue
14
+ import org.junit.Test
15
+
16
+ class KlarnaMessagingViewManagerHelpersTest {
17
+
18
+ // MARK: - parseTheme
19
+
20
+ @Test
21
+ fun parseTheme_light() {
22
+ assertEquals(KlarnaTheme.LIGHT, parseTheme("light"))
23
+ }
24
+
25
+ @Test
26
+ fun parseTheme_dark() {
27
+ assertEquals(KlarnaTheme.DARK, parseTheme("dark"))
28
+ }
29
+
30
+ @Test
31
+ fun parseTheme_automatic() {
32
+ assertEquals(KlarnaTheme.AUTOMATIC, parseTheme("automatic"))
33
+ }
34
+
35
+ @Test
36
+ fun parseTheme_nullInput_returnsNull() {
37
+ assertNull(parseTheme(null))
38
+ }
39
+
40
+ @Test
41
+ fun parseTheme_unknownValue_returnsNull() {
42
+ assertNull(parseTheme("neon"))
43
+ }
44
+
45
+ @Test
46
+ fun parseTheme_isCaseSensitive() {
47
+ // Contract is lowercase only; uppercase should not match.
48
+ assertNull(parseTheme("LIGHT"))
49
+ assertNull(parseTheme("Light"))
50
+ }
51
+
52
+ @Test
53
+ fun parseTheme_emptyString_returnsNull() {
54
+ assertNull(parseTheme(""))
55
+ }
56
+
57
+ // MARK: - parseAmount
58
+
59
+ @Test
60
+ fun parseAmount_validInteger() {
61
+ assertEquals(12345L, parseAmount("12345"))
62
+ }
63
+
64
+ @Test
65
+ fun parseAmount_zero() {
66
+ assertEquals(0L, parseAmount("0"))
67
+ }
68
+
69
+ @Test
70
+ fun parseAmount_negative() {
71
+ assertEquals(-500L, parseAmount("-500"))
72
+ }
73
+
74
+ @Test
75
+ fun parseAmount_null_returnsNull() {
76
+ assertNull(parseAmount(null))
77
+ }
78
+
79
+ @Test
80
+ fun parseAmount_empty_returnsNull() {
81
+ assertNull(parseAmount(""))
82
+ }
83
+
84
+ @Test
85
+ fun parseAmount_nonNumeric_returnsNull() {
86
+ assertNull(parseAmount("abc"))
87
+ }
88
+
89
+ @Test
90
+ fun parseAmount_fractional_returnsNull() {
91
+ // Amounts are minor units (integers). Decimals must be rejected.
92
+ assertNull(parseAmount("12.5"))
93
+ }
94
+
95
+ @Test
96
+ fun parseAmount_overflow_returnsNull() {
97
+ // Larger than Long.MAX_VALUE.
98
+ assertNull(parseAmount("99999999999999999999"))
99
+ }
100
+
101
+ // MARK: - resolvePlacementType
102
+
103
+ @Test
104
+ fun resolvePlacementType_badge_returnsBadge() {
105
+ assertEquals(PlacementType.BADGE, resolvePlacementType("CreditPromotionBadge"))
106
+ }
107
+
108
+ @Test
109
+ fun resolvePlacementType_autoSize_returnsAutoSize() {
110
+ assertEquals(PlacementType.AUTO_SIZE, resolvePlacementType("CreditPromotionAutoSize"))
111
+ }
112
+
113
+ @Test
114
+ fun resolvePlacementType_null_defaultsToAutoSize() {
115
+ assertEquals(PlacementType.AUTO_SIZE, resolvePlacementType(null))
116
+ }
117
+
118
+ @Test
119
+ fun resolvePlacementType_unknown_defaultsToAutoSize() {
120
+ assertEquals(PlacementType.AUTO_SIZE, resolvePlacementType("SomethingElse"))
121
+ }
122
+
123
+ @Test
124
+ fun resolvePlacementType_caseSensitive() {
125
+ // Only exact "CreditPromotionBadge" returns badge.
126
+ assertEquals(PlacementType.AUTO_SIZE, resolvePlacementType("creditpromotionbadge"))
127
+ }
128
+
129
+ // MARK: - buildConfiguration
130
+
131
+ @Test
132
+ fun buildConfiguration_badge_returnsBadgeConfiguration() {
133
+ val config = buildConfiguration("CreditPromotionBadge", KlarnaTheme.LIGHT, 1000L, "USD")
134
+ assertTrue(
135
+ "Expected CreditPromotionBadge but got ${config::class.simpleName}",
136
+ config is KlarnaMessagingPlacementConfiguration.CreditPromotionBadge
137
+ )
138
+ }
139
+
140
+ @Test
141
+ fun buildConfiguration_autoSize_returnsAutoSizeConfiguration() {
142
+ val config = buildConfiguration("CreditPromotionAutoSize", KlarnaTheme.DARK, 2000L, "EUR")
143
+ assertTrue(
144
+ "Expected CreditPromotionAutoSize but got ${config::class.simpleName}",
145
+ config is KlarnaMessagingPlacementConfiguration.CreditPromotionAutoSize
146
+ )
147
+ }
148
+
149
+ @Test
150
+ fun buildConfiguration_null_defaultsToAutoSize() {
151
+ val config = buildConfiguration(null, null, 100L, "SEK")
152
+ assertTrue(config is KlarnaMessagingPlacementConfiguration.CreditPromotionAutoSize)
153
+ }
154
+
155
+ @Test
156
+ fun buildConfiguration_unknown_defaultsToAutoSize() {
157
+ val config = buildConfiguration("UnknownType", null, 100L, "SEK")
158
+ assertTrue(config is KlarnaMessagingPlacementConfiguration.CreditPromotionAutoSize)
159
+ }
160
+
161
+ @Test
162
+ fun buildConfiguration_passesNullThroughWhenThemeIsNull() {
163
+ // Should not throw when theme is null.
164
+ val config = buildConfiguration("CreditPromotionBadge", null, 100L, "GBP")
165
+ assertTrue(config is KlarnaMessagingPlacementConfiguration.CreditPromotionBadge)
166
+ }
167
+
168
+ // MARK: - shouldReportHeight
169
+
170
+ @Test
171
+ fun shouldReportHeight_firstReport_isReported() {
172
+ assertTrue(shouldReportHeight(null, 100))
173
+ }
174
+
175
+ @Test
176
+ fun shouldReportHeight_changedHeight_isReported() {
177
+ assertTrue(shouldReportHeight(100, 150))
178
+ }
179
+
180
+ @Test
181
+ fun shouldReportHeight_sameHeight_isSkipped() {
182
+ assertFalse(shouldReportHeight(100, 100))
183
+ }
184
+
185
+ @Test
186
+ fun shouldReportHeight_zero_isSkipped() {
187
+ assertFalse(shouldReportHeight(null, 0))
188
+ }
189
+
190
+ @Test
191
+ fun shouldReportHeight_negative_isSkipped() {
192
+ assertFalse(shouldReportHeight(null, -10))
193
+ }
194
+
195
+ @Test
196
+ fun shouldReportHeight_zeroAfterValidHeight_isSkipped() {
197
+ // A spurious 0 report must not clobber a previously reported height.
198
+ assertFalse(shouldReportHeight(100, 0))
199
+ }
200
+ }
@@ -0,0 +1,101 @@
1
+ import Foundation
2
+ import UIKit
3
+ import KlarnaNetworkMessaging
4
+ import KlarnaNetworkCore
5
+ import KlarnaCore
6
+ @_spi(RNKlarnaNetworkCore) import react_native_klarna_network_core
7
+
8
+ enum PlacementType: String {
9
+ case autoSize = "CreditPromotionAutoSize"
10
+ case badge = "CreditPromotionBadge"
11
+
12
+ init(rawString: String?) {
13
+ self = PlacementType(rawValue: rawString ?? "") ?? .autoSize
14
+ }
15
+ }
16
+
17
+ @objc(KlarnaMessagingPlacementViewFactory)
18
+ public class KlarnaMessagingPlacementViewFactory: NSObject {
19
+
20
+ @objc public static let defaultPlacementType = PlacementType.autoSize.rawValue
21
+
22
+ @objc public static func resolveKlarna(instanceId: String) -> AnyObject? {
23
+ return KlarnaNetworkCoreModuleImpl.getInstance(instanceId: instanceId)
24
+ }
25
+
26
+ static func parseTheme(_ value: String) -> KlarnaTheme? {
27
+ switch value {
28
+ case "light": return .light
29
+ case "dark": return .dark
30
+ case "automatic": return .automatic
31
+ default: return nil
32
+ }
33
+ }
34
+
35
+ static func makeConfiguration(
36
+ placementType: String,
37
+ theme: KlarnaTheme?,
38
+ amount: Int64,
39
+ currency: String
40
+ ) -> KlarnaMessagingPlacementConfiguration {
41
+ let intAmount = Int(amount)
42
+ switch PlacementType(rawString: placementType) {
43
+ case .badge:
44
+ return .creditPromotionBadge(amount: intAmount, currency: currency, theme: theme)
45
+ case .autoSize:
46
+ return .creditPromotionAutoSize(amount: intAmount, currency: currency, theme: theme)
47
+ }
48
+ }
49
+
50
+ @objc public static func createPlacementView(
51
+ klarna: AnyObject,
52
+ placementType: String,
53
+ theme: String,
54
+ amount: Int64,
55
+ currency: String
56
+ ) -> UIView? {
57
+ guard let klarnaInstance = klarna as? Klarna else {
58
+ return nil
59
+ }
60
+
61
+ let parsedTheme = parseTheme(theme)
62
+ let configuration = makeConfiguration(
63
+ placementType: placementType,
64
+ theme: parsedTheme,
65
+ amount: amount,
66
+ currency: currency
67
+ )
68
+ let view = KlarnaMessagingPlacementView(klarna: klarnaInstance, configuration: configuration)
69
+ // Align the trait collection with the explicit theme so the SDK's
70
+ // trait-driven colors don't conflict with theme-driven colors when the
71
+ // JS prop opposes the phone's system mode (e.g. theme=dark on a light phone).
72
+ switch parsedTheme {
73
+ case .light:
74
+ view.overrideUserInterfaceStyle = .light
75
+ case .dark:
76
+ view.overrideUserInterfaceStyle = .dark
77
+ default:
78
+ view.overrideUserInterfaceStyle = .unspecified
79
+ }
80
+ // Match Android: render the SDK placement on a transparent surface so the
81
+ // host app's background shows through. The wrapper re-applies this on every
82
+ // layout pass (see clearBackgrounds:) because the SDK repaints the root and
83
+ // its UITextView with theme-derived colors after construction.
84
+ view.backgroundColor = .clear
85
+ return view
86
+ }
87
+
88
+ /// Sets `backgroundColor = .clear` on the SDK root and on any UITextView in its
89
+ /// subtree so the host app's background shows through. Called from the Fabric
90
+ /// wrapper's `layoutSubviews` to survive SDK repaints (the SDK resets the root
91
+ /// to its theme background and the UITextView to `systemBackgroundColor`).
92
+ @objc public static func clearBackgrounds(_ view: UIView) {
93
+ view.backgroundColor = .clear
94
+ for subview in view.subviews {
95
+ if subview is UITextView {
96
+ subview.backgroundColor = .clear
97
+ }
98
+ clearBackgrounds(subview)
99
+ }
100
+ }
101
+ }
@@ -0,0 +1,171 @@
1
+ import Foundation
2
+ import UIKit
3
+
4
+ @objc public protocol KlarnaMessagingHeightDelegate: AnyObject {
5
+ func placementViewDidUpdateHeight(_ height: CGFloat)
6
+ }
7
+
8
+ @objc(KlarnaMessagingPlacementViewHelper)
9
+ public class KlarnaMessagingPlacementViewHelper: NSObject {
10
+
11
+ // MARK: - Amount Parsing
12
+
13
+ /// Strict integer-only parser that mirrors Android's `String.toLongOrNull()`.
14
+ /// Returns `nil` for empty, non-integer, or out-of-range inputs instead of
15
+ /// falling back to `NSString.longLongValue`, which silently coerces `"abc"` /
16
+ /// `"12.5"` to `0`.
17
+ @objc public static func parseAmount(_ value: String?) -> NSNumber? {
18
+ guard let value = value, !value.isEmpty, let parsed = Int64(value) else {
19
+ return nil
20
+ }
21
+ return NSNumber(value: parsed)
22
+ }
23
+
24
+ // MARK: - Height Calculation
25
+
26
+ /// Determines the natural content height of `view` using a three-tier fallback:
27
+ /// 1. Auto Layout `systemLayoutSizeFitting` constrained to `containerWidth`.
28
+ /// 2. The view's current `frame.size.height` (for views laid out manually).
29
+ /// 3. The bottommost subview's `maxY` (last-resort introspection).
30
+ @objc public static func calculateHeight(for view: UIView, containerWidth: CGFloat) -> CGFloat {
31
+ view.setNeedsLayout()
32
+ view.layoutIfNeeded()
33
+
34
+ let fittingWidth = containerWidth > 0 ? containerWidth : UIScreen.main.bounds.width
35
+ let fittingSize = view.systemLayoutSizeFitting(
36
+ CGSize(width: fittingWidth, height: UIView.layoutFittingCompressedSize.height),
37
+ withHorizontalFittingPriority: .required,
38
+ verticalFittingPriority: .fittingSizeLevel
39
+ )
40
+ if fittingSize.height > 0 {
41
+ return fittingSize.height
42
+ }
43
+
44
+ if view.frame.size.height > 0 {
45
+ return view.frame.size.height
46
+ }
47
+
48
+ var maxBottom: CGFloat = 0
49
+ for subview in view.subviews {
50
+ let bottom = subview.frame.maxY
51
+ if bottom > maxBottom {
52
+ maxBottom = bottom
53
+ }
54
+ }
55
+ return maxBottom
56
+ }
57
+
58
+ // MARK: - Subview Setup
59
+
60
+ /// Adds `placementView` to `container` pinned to top/leading/trailing edges.
61
+ /// When `pinWidth` is `true` an explicit width constraint equal to
62
+ /// `container.bounds.width` is also added (no-op if the container has zero
63
+ /// width). The Fabric wrapper currently always passes `false` and lets Auto
64
+ /// Layout drive width via the trailing constraint.
65
+ @objc public static func addPlacementSubview(_ placementView: UIView, to container: UIView, pinWidth: Bool) {
66
+ placementView.translatesAutoresizingMaskIntoConstraints = false
67
+ container.addSubview(placementView)
68
+
69
+ NSLayoutConstraint.activate([
70
+ placementView.topAnchor.constraint(equalTo: container.topAnchor),
71
+ placementView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
72
+ placementView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
73
+ ])
74
+
75
+ if pinWidth {
76
+ let width = container.bounds.size.width
77
+ if width > 0 {
78
+ NSLayoutConstraint.activate([
79
+ placementView.widthAnchor.constraint(equalToConstant: width),
80
+ ])
81
+ }
82
+ }
83
+ }
84
+ }
85
+
86
+ // MARK: - Height Observer
87
+
88
+ /// Observes a placement view's `bounds` via KVO and fires staggered delayed checks
89
+ /// to catch asynchronous content loads. Reports distinct height changes to its `delegate`.
90
+ @objc(KlarnaMessagingHeightObserver)
91
+ public class KlarnaMessagingHeightObserver: NSObject {
92
+
93
+ @objc public weak var delegate: KlarnaMessagingHeightDelegate?
94
+
95
+ /// Held strongly while observation is active so `removeObserver` always sees a
96
+ /// live target (KVO crashes if the observed object deallocates first).
97
+ /// Cleared in `stopObserving` after the observer is removed.
98
+ private var observedView: UIView?
99
+ private var isObserving = false
100
+ private var lastReportedHeight: CGFloat = 0
101
+ private var pendingDelayedChecks: Int = 0
102
+ private let delayIntervals: [Double]
103
+
104
+ @objc public init(delayIntervals: [Double]) {
105
+ self.delayIntervals = delayIntervals
106
+ super.init()
107
+ }
108
+
109
+ /// Starts observing `view` for height changes using KVO on `bounds` plus
110
+ /// staggered delayed checks (configured via `delayIntervals`).
111
+ @objc public func observe(_ view: UIView) {
112
+ stopObserving()
113
+ observedView = view
114
+ lastReportedHeight = 0
115
+ pendingDelayedChecks = delayIntervals.count
116
+ isObserving = true
117
+ view.addObserver(self, forKeyPath: "bounds", options: .new, context: nil)
118
+
119
+ for delay in delayIntervals {
120
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
121
+ guard let self = self else { return }
122
+ self.checkAndReportHeight()
123
+ self.pendingDelayedChecks -= 1
124
+ if self.pendingDelayedChecks <= 0 && self.lastReportedHeight == 0 {
125
+ self.delegate?.placementViewDidUpdateHeight(0)
126
+ }
127
+ }
128
+ }
129
+ }
130
+
131
+ @objc public func stopObserving() {
132
+ if isObserving, let view = observedView {
133
+ view.removeObserver(self, forKeyPath: "bounds")
134
+ }
135
+ isObserving = false
136
+ observedView = nil
137
+ }
138
+
139
+ /// Measures the observed view's content height and notifies the delegate if
140
+ /// the value changed since the last report (deduplicates identical heights).
141
+ @objc public func checkAndReportHeight() {
142
+ guard let view = observedView else { return }
143
+
144
+ let containerWidth = view.superview?.bounds.size.width ?? 0
145
+ let height = KlarnaMessagingPlacementViewHelper.calculateHeight(for: view, containerWidth: containerWidth)
146
+
147
+ if height > 0 && height != lastReportedHeight {
148
+ lastReportedHeight = height
149
+ delegate?.placementViewDidUpdateHeight(height)
150
+ }
151
+ }
152
+
153
+ // MARK: - KVO
154
+
155
+ public override func observeValue(
156
+ forKeyPath keyPath: String?,
157
+ of object: Any?,
158
+ change: [NSKeyValueChangeKey: Any]?,
159
+ context: UnsafeMutableRawPointer?
160
+ ) {
161
+ if keyPath == "bounds", (object as? UIView) === observedView {
162
+ DispatchQueue.main.async { [weak self] in
163
+ self?.checkAndReportHeight()
164
+ }
165
+ }
166
+ }
167
+
168
+ deinit {
169
+ stopObserving()
170
+ }
171
+ }
@@ -0,0 +1,5 @@
1
+ #import <React/RCTViewManager.h>
2
+
3
+ @interface KlarnaMessagingPlacementViewManager : RCTViewManager
4
+
5
+ @end
@@ -0,0 +1,37 @@
1
+ #import <React/RCTUIManager.h>
2
+ #import <React/RCTLog.h>
3
+ #import "KlarnaMessagingPlacementViewManager.h"
4
+ #import "view/KlarnaMessagingPlacementViewWrapper.h"
5
+
6
+ @implementation KlarnaMessagingPlacementViewManager
7
+
8
+ + (void)initialize
9
+ {
10
+ if (self != [KlarnaMessagingPlacementViewManager class]) {
11
+ return;
12
+ }
13
+ #ifndef RCT_NEW_ARCH_ENABLED
14
+ RCTLogError(@"KlarnaMessagingPlacementViewManager: react-native-klarna-network-messaging does not support the old architecture. Please enable the New Architecture (RCT_NEW_ARCH_ENABLED=1) before running pod install.");
15
+ #endif
16
+ }
17
+
18
+ RCT_EXPORT_MODULE(RNKlarnaMessagingPlacementView)
19
+
20
+ #pragma mark - View Properties
21
+
22
+ RCT_EXPORT_VIEW_PROPERTY(placementType, NSString)
23
+ RCT_EXPORT_VIEW_PROPERTY(theme, NSString)
24
+ RCT_EXPORT_VIEW_PROPERTY(amount, NSString)
25
+ RCT_EXPORT_VIEW_PROPERTY(currency, NSString)
26
+ RCT_EXPORT_VIEW_PROPERTY(onError, RCTDirectEventBlock)
27
+ RCT_EXPORT_VIEW_PROPERTY(onResized, RCTDirectEventBlock)
28
+
29
+ #pragma mark - View Creation
30
+
31
+ - (UIView *)view
32
+ {
33
+ KlarnaMessagingPlacementViewWrapper *wrapper = [KlarnaMessagingPlacementViewWrapper new];
34
+ return wrapper;
35
+ }
36
+
37
+ @end
@@ -0,0 +1,7 @@
1
+ #import <React/RCTUIManager.h>
2
+ #import <React/RCTViewComponentView.h>
3
+ #import <UIKit/UIKit.h>
4
+
5
+ @interface KlarnaMessagingPlacementViewWrapper : RCTViewComponentView
6
+
7
+ @end