@loyalytics/swan-react-native-sdk 2.7.1-beta.2 → 2.7.1-beta.3

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.
@@ -12,8 +12,8 @@ import UserNotificationsUI
12
12
  *
13
13
  * User interaction:
14
14
  * - Swipe left/right on the carousel to navigate between items
15
- * - Tap on the carousel image to open the deep link for the current item
16
- * - Deep link uses extensionContext.open(URL) with full URLs from carousel item routes
15
+ * - Tap on a carousel image to save click data to App Group and open the app
16
+ * - SDK's checkPendingCarouselClick() reads the click data and handles navigation
17
17
  *
18
18
  * Action button identifiers (reserved for future use if categories are registered):
19
19
  * - swan_prev: Navigate to previous carousel item
@@ -23,7 +23,6 @@ import UserNotificationsUI
23
23
  class NotificationViewController: UIViewController, UNNotificationContentExtension {
24
24
 
25
25
  private var carouselView: CarouselView?
26
- private var currentUserInfo: [AnyHashable: Any] = [:]
27
26
 
28
27
  /// Action button identifiers (reserved for future use if notification categories are registered)
29
28
  private enum ActionID {
@@ -32,6 +31,10 @@ class NotificationViewController: UIViewController, UNNotificationContentExtensi
32
31
  static let open = "swan_open"
33
32
  }
34
33
 
34
+ /// Set to true after onItemTapped saves click data, so didReceive doesn't overwrite
35
+ /// with a potentially stale currentIndex (e.g., if auto-scroll advanced between tap and callback).
36
+ private var tapHandled = false
37
+
35
38
  required init?(coder: NSCoder) {
36
39
  self.carouselView = nil
37
40
  super.init(coder: coder)
@@ -84,21 +87,16 @@ class NotificationViewController: UIViewController, UNNotificationContentExtensi
84
87
  completion(.doNotDismiss)
85
88
 
86
89
  case ActionID.open, UNNotificationDefaultActionIdentifier:
87
- guard let carousel = carouselView else {
88
- completion(.dismissAndForwardAction)
89
- return
90
- }
91
- openDeepLink(
92
- itemIndex: carousel.currentIndex,
93
- itemRoute: carousel.currentItem?.route,
94
- userInfo: userInfo
95
- ) { opened in
96
- if opened {
97
- completion(.dismiss)
98
- } else {
99
- completion(.dismissAndForwardAction)
100
- }
90
+ // If onItemTapped already saved click data, skip to avoid overwriting
91
+ // with a potentially stale currentIndex (auto-scroll race).
92
+ if !tapHandled, let carousel = carouselView {
93
+ handleCarouselTap(
94
+ itemIndex: carousel.currentIndex,
95
+ itemRoute: carousel.currentItem?.route,
96
+ userInfo: userInfo
97
+ )
101
98
  }
99
+ completion(.dismissAndForwardAction)
102
100
 
103
101
  default:
104
102
  completion(.dismissAndForwardAction)
@@ -129,22 +127,18 @@ class NotificationViewController: UIViewController, UNNotificationContentExtensi
129
127
  let mode: CarouselView.CarouselMode = modeString == "auto" ? .auto : .manual
130
128
  carousel.configure(with: items, mode: mode, autoScrollInterval: interval / 1000.0)
131
129
 
132
- self.currentUserInfo = userInfo
133
-
134
- // Tap on carousel image opens the app via deep link URL (CleverTap pattern)
135
- // Falls back to App Group + performNotificationDefaultAction if openURL fails
130
+ // Tap on carousel image: save click data to App Group, then open the app.
131
+ // SDK's checkPendingCarouselClick() reads the data and handles navigation.
136
132
  carousel.onItemTapped = { [weak self] tappedIndex in
137
- guard let self = self, let cv = self.carouselView else { return }
133
+ guard let self = self else { return }
138
134
  let tappedItem = tappedIndex < items.count ? items[tappedIndex] : nil
139
- self.openDeepLink(
135
+ self.handleCarouselTap(
140
136
  itemIndex: tappedIndex,
141
137
  itemRoute: tappedItem?.route,
142
138
  userInfo: userInfo
143
- ) { opened in
144
- if !opened {
145
- self.extensionContext?.performNotificationDefaultAction()
146
- }
147
- }
139
+ )
140
+ self.tapHandled = true
141
+ self.extensionContext?.performNotificationDefaultAction()
148
142
  }
149
143
 
150
144
  self.carouselView = carousel
@@ -155,74 +149,44 @@ class NotificationViewController: UIViewController, UNNotificationContentExtensi
155
149
  preferredContentSize = CGSize(width: width, height: width * imageAspectRatio + 80)
156
150
  }
157
151
 
158
- // MARK: - Deep Link via URL (CleverTap pattern)
159
-
160
- /// Try to open a deep link URL directly from the Content Extension.
161
- /// If the route is a full URL (contains "://"), appends swan_ tracking params and opens it.
162
- /// On failure or relative routes, falls back to saving click data to App Group.
163
- ///
164
- /// - Parameters:
165
- /// - itemIndex: Current carousel item index
166
- /// - itemRoute: Route from the tapped carousel item (e.g., "swanexample://product/123")
167
- /// - userInfo: Push notification payload
168
- /// - completion: Called with `true` if openURL succeeded, `false` if fell back
169
- private func openDeepLink(
152
+ // MARK: - Click Data Persistence
153
+ // Tested in SwanSDKTestable/Tests/SwanParsersTests.swift (ClickDataSerializationTests, RouteResolutionTests)
154
+
155
+ /// Save carousel click data to App Group for the SDK to read.
156
+ /// The SDK's checkPendingCarouselClick() reads this data on app open and
157
+ /// handles navigation + click ACK. This avoids extensionContext.open(url)
158
+ /// race conditions on cold start where the app hasn't finished initializing
159
+ /// React Navigation before the URL arrives.
160
+ private func handleCarouselTap(
170
161
  itemIndex: Int,
171
162
  itemRoute: String?,
172
- userInfo: [AnyHashable: Any],
173
- completion: @escaping (Bool) -> Void
163
+ userInfo: [AnyHashable: Any]
174
164
  ) {
175
- let messageId = userInfo["messageId"] as? String ?? ""
176
- let defaultRoute = userInfo["defaultRoute"] as? String
177
- // Use per-item route if non-nil and non-empty, otherwise fall back to defaultRoute.
165
+ // messageId: try data field first, then FCM's auto-generated key
166
+ let messageId = (userInfo["messageId"] as? String)
167
+ ?? (userInfo["gcm.message_id"] as? String)
168
+ ?? ""
169
+ // Use per-item route if non-nil and non-empty, otherwise fall back to defaultRoute/route.
178
170
  // Logic mirrors SwanParsers.resolveRoute() — keep in sync.
179
171
  let route: String
180
172
  if let itemRoute = itemRoute, !itemRoute.isEmpty {
181
173
  route = itemRoute
182
174
  } else {
183
- route = defaultRoute ?? ""
175
+ route = (userInfo["defaultRoute"] as? String)
176
+ ?? (userInfo["route"] as? String)
177
+ ?? ""
184
178
  }
185
179
 
186
- // Always save click data first — the async extensionContext.open() completion
187
- // uses [weak self], so saveClickData() could be skipped if self is deallocated.
188
- // Writing before the async call guarantees the data is persisted.
189
- saveClickData(messageId: messageId, itemIndex: itemIndex, route: route)
190
-
191
- // Try openURL if route is a full URL (contains "://")
192
- // URL construction logic mirrors SwanParsers.buildCarouselDeepLinkURL()
193
- // If changing this logic, update SwanParsers.swift too (and vice versa)
194
- if !route.isEmpty, route.contains("://") {
195
- guard var components = URLComponents(string: route) else {
196
- completion(false)
197
- return
198
- }
199
-
200
- // Merge swan_ tracking params with any existing query params from the route
201
- var queryItems = components.queryItems ?? []
202
- queryItems.append(contentsOf: [
203
- URLQueryItem(name: "swan_carousel", value: "1"),
204
- URLQueryItem(name: "swan_comm_id", value: messageId),
205
- URLQueryItem(name: "swan_item_index", value: "\(itemIndex)"),
206
- ])
207
- components.queryItems = queryItems
208
-
209
- if let url = components.url {
210
- self.extensionContext?.open(url) { success in
211
- DispatchQueue.main.async {
212
- completion(success)
213
- }
214
- }
215
- return
216
- }
217
- }
218
-
219
- completion(false)
180
+ saveClickData(
181
+ messageId: messageId,
182
+ itemIndex: itemIndex,
183
+ route: route,
184
+ title: userInfo["title"] as? String ?? "",
185
+ body: userInfo["body"] as? String ?? ""
186
+ )
220
187
  }
221
188
 
222
- // MARK: - Click Data Persistence
223
- // Tested in SwanSDKTestable/Tests/SwanParsersTests.swift (ClickDataSerializationTests)
224
-
225
- private func saveClickData(messageId: String, itemIndex: Int, route: String) {
189
+ private func saveClickData(messageId: String, itemIndex: Int, route: String, title: String, body: String) {
226
190
  let appGroupId = "group.swan.sdk.notifications"
227
191
  guard let defaults = UserDefaults(suiteName: appGroupId) else { return }
228
192
 
@@ -230,6 +194,8 @@ class NotificationViewController: UIViewController, UNNotificationContentExtensi
230
194
  "messageId": messageId,
231
195
  "itemIndex": itemIndex,
232
196
  "route": route,
197
+ "title": title,
198
+ "body": body,
233
199
  "timestamp": Date().timeIntervalSince1970,
234
200
  ]
235
201
 
@@ -279,6 +279,7 @@ extension CarouselView: UICollectionViewDataSource, UICollectionViewDelegate, UI
279
279
  }
280
280
 
281
281
  func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
282
+ guard scrollView.bounds.width > 0 else { return }
282
283
  let page = Int(scrollView.contentOffset.x / scrollView.bounds.width)
283
284
  if page != currentIndex {
284
285
  currentIndex = page
@@ -1891,6 +1891,12 @@ class SwanSDK {
1891
1891
  }
1892
1892
  }
1893
1893
  if (!clickData) return;
1894
+
1895
+ // Discard stale click data (>60s old) from a previous session
1896
+ if (clickData.timestamp && Date.now() / 1000 - clickData.timestamp > 60) {
1897
+ _Logger.default.log('[SwanSDK] Stale carousel click data (>60s old), discarding');
1898
+ return;
1899
+ }
1894
1900
  _Logger.default.log('[SwanSDK] Pending carousel click found:', JSON.stringify(clickData));
1895
1901
  const {
1896
1902
  messageId,