@loyalytics/swan-react-native-sdk 2.5.1-beta.5 → 2.5.1-beta.8

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.
@@ -116,7 +116,7 @@ function parseKeyValuePairs(data) {
116
116
  * Notifee handlers fire for the same notification.
117
117
  */
118
118
  const processedClickIds = new Set();
119
- const CLICK_ID_TTL_MS = 10_000; // Clear after 10 seconds
119
+ const CLICK_ID_TTL_MS = 30_000; // Clear after 30 seconds
120
120
 
121
121
  function markClickProcessed(id) {
122
122
  if (processedClickIds.has(id)) {
@@ -127,9 +127,17 @@ function markClickProcessed(id) {
127
127
  return true; // First time processing
128
128
  }
129
129
 
130
+ /**
131
+ * Track messageIds that already received a direct click ACK from the background
132
+ * handler when the SDK was not ready. Prevents setupNotifeeEventListeners from
133
+ * sending a duplicate click ACK for the same notification.
134
+ */
135
+ const directAckSentIds = new Set();
136
+
130
137
  /** @internal Test-only: reset click deduplication state */
131
138
  export function _resetClickDedup() {
132
139
  processedClickIds.clear();
140
+ directAckSentIds.clear();
133
141
  }
134
142
 
135
143
  /**
@@ -615,22 +623,32 @@ export default class SwanSDK {
615
623
  }
616
624
  this.listeners[event].push(callback);
617
625
 
618
- // On first NOTIFICATION_OPENED listener, check for pending cold-start notifications.
619
- // The native side holds click data until consumed we just need to ask when ready.
620
- if (event === SwanSDK.EVENTS.NOTIFICATION_OPENED && !this.coldStartCheckDone) {
621
- this.coldStartCheckDone = true;
626
+ // On every NOTIFICATION_OPENED listener registration, check for buffered notifications.
627
+ // Runs on cold start (process dead getInitialNotification buffered), warm restart
628
+ // (process alive but Activity destroyed by swipe-from-recents on Android), and
629
+ // Metro hot-reload (component unmount/remount cycles).
630
+ if (event === SwanSDK.EVENTS.NOTIFICATION_OPENED) {
622
631
  // Defer to next microtask so the listener is fully registered before events emit
623
632
  Promise.resolve().then(() => {
624
- // Deliver buffered standard/image push notification (one-shot APIs like
625
- // getInitialNotification() can't be re-read data was buffered in emitNotificationOpened).
626
- // Skip carousel notifications — checkPendingCarouselClick() handles those with per-item routes.
627
- const payload = this.pendingNotificationPayload;
628
- this.pendingNotificationPayload = null;
629
- if (payload && payload.notificationType !== 'carousel') {
630
- this.emitNotificationOpened(payload);
633
+ // Deliver buffered standard/image push notification.
634
+ // Skip carousel checkPendingCarouselClick() handles those with per-item routes.
635
+ if (this.pendingNotificationPayload) {
636
+ const payload = this.pendingNotificationPayload;
637
+ this.pendingNotificationPayload = null;
638
+ if (payload.notificationType !== 'carousel') {
639
+ this.emit(SwanSDK.EVENTS.NOTIFICATION_OPENED, payload);
640
+ this.emitDeepLinkOpened({
641
+ ...payload,
642
+ source: 'push'
643
+ });
644
+ }
631
645
  }
632
- // Check for pending carousel click (native side holds data until consumed)
646
+ // Check for pending carousel click (native side holds data until consumed).
647
+ // Unconditional: handles both cold start and warm restart carousel taps.
633
648
  this.checkPendingCarouselClick();
649
+ if (!this.coldStartCheckDone) {
650
+ this.coldStartCheckDone = true;
651
+ }
634
652
  });
635
653
  }
636
654
 
@@ -660,9 +678,11 @@ export default class SwanSDK {
660
678
  */
661
679
  emitNotificationOpened(payload) {
662
680
  const listeners = this.listeners[SwanSDK.EVENTS.NOTIFICATION_OPENED];
663
- if ((!listeners || listeners.length === 0) && !this.coldStartCheckDone) {
664
- // Cold start: buffer for delivery when first listener registers.
665
- // One-shot APIs like getInitialNotification() won't have this data later.
681
+ if (!listeners || listeners.length === 0) {
682
+ // No listeners yet — buffer for delivery when first listener registers.
683
+ // Handles: true cold start (process dead), Activity recreation (process alive
684
+ // but Activity destroyed by swipe-from-recents on Android), and Metro
685
+ // hot-reload (component unmount/remount cycles).
666
686
  Logger.log('[SwanSDK] Buffering notification payload for deferred delivery');
667
687
  this.pendingNotificationPayload = payload;
668
688
  return;
@@ -2882,31 +2902,48 @@ export default class SwanSDK {
2882
2902
  // similar to how Firebase requires onMessage() at module level.
2883
2903
 
2884
2904
  // Check if app was opened by tapping a Notifee-displayed notification (data-only architecture)
2885
- // Notifee's getInitialNotification() returns { notification, pressAction }.
2886
- // The FCM data we stored via displayNotification({ data: {...} }) lives at
2887
- // notification.data — NOT at the top-level .data (which doesn't exist).
2888
2905
  const initialNotification = await notifee.getInitialNotification();
2906
+ Logger.log('[SwanSDK] getInitialNotification result:', JSON.stringify(initialNotification, null, 2));
2889
2907
  if (initialNotification && !this.initialNotificationHandled) {
2890
- const messageId = initialNotification?.notification?.id;
2891
- const notificationData = initialNotification.notification?.data || {};
2892
- if (messageId && markClickProcessed(messageId)) {
2893
- Logger.log('[SwanSDK] App opened from Notifee notification tap:', messageId);
2894
- this.initialNotificationHandled = true;
2895
- await this.sendNotificationAck(messageId, 'clicked');
2908
+ // Notifee returned an initial notification — mark as handled
2909
+ // unconditionally to prevent Firebase fallback from double-emitting.
2910
+ this.initialNotificationHandled = true;
2911
+ if (!this.isPushEnabled()) {
2912
+ Logger.log('[SwanSDK] Push notifications disabled, ignoring initial notification');
2913
+ } else {
2914
+ const notificationData = initialNotification.notification?.data || {};
2915
+ const messageId = notificationData.messageId;
2916
+
2917
+ // Carousel notifications: skip standard path entirely.
2918
+ // checkPendingCarouselClick() (triggered by addListener) handles
2919
+ // per-item routes from native storage / App Group.
2920
+ if (notificationData.notificationType === 'carousel') {
2921
+ Logger.log('[SwanSDK] Carousel initial notification, deferring to checkPendingCarouselClick');
2922
+ } else if (messageId && !markClickProcessed(messageId)) {
2923
+ Logger.log('[SwanSDK] Click already processed for messageId:', messageId);
2924
+ } else {
2925
+ Logger.log('[SwanSDK] App opened from Notifee notification tap:', messageId);
2926
+
2927
+ // Extract deep link information
2928
+ const resolvedRoute = notificationData.route || notificationData.defaultRoute;
2929
+ const deepLinkPayload = {
2930
+ ...notificationData,
2931
+ route: resolvedRoute,
2932
+ keyValuePairs: parseKeyValuePairs(notificationData),
2933
+ title: initialNotification.notification?.title || notificationData.title,
2934
+ body: initialNotification.notification?.body || notificationData.body
2935
+ };
2936
+
2937
+ // Emit deep link FIRST to ensure delivery even if ACK fails
2938
+ Logger.log('[SwanSDK] Cold-start deepLinkPayload:', JSON.stringify(deepLinkPayload, null, 2));
2939
+ this.emitNotificationOpened(deepLinkPayload);
2940
+
2941
+ // Send click ACK after emit (skip if background handler already sent one)
2942
+ if (messageId && !directAckSentIds.has(messageId)) {
2943
+ await this.sendNotificationAck(messageId, 'clicked');
2944
+ }
2945
+ }
2896
2946
  }
2897
- // Extract deep link information
2898
- // For carousel notifications, the default route is in 'defaultRoute' field
2899
- const resolvedRoute = notificationData.route || notificationData.defaultRoute;
2900
- const deepLinkPayload = {
2901
- ...notificationData,
2902
- route: resolvedRoute,
2903
- keyValuePairs: parseKeyValuePairs(notificationData),
2904
- title: initialNotification.notification?.title || notificationData.title,
2905
- body: initialNotification.notification?.body || notificationData.body
2906
- };
2907
-
2908
- // Emit notificationOpened event for host app to handle deep linking
2909
- this.emitNotificationOpened(deepLinkPayload);
2910
2947
  }
2911
2948
 
2912
2949
  // Also check Firebase's getInitialNotification for iOS auto-displayed notifications
@@ -2930,7 +2967,21 @@ export default class SwanSDK {
2930
2967
  const messaging = require('@react-native-firebase/messaging').default;
2931
2968
  const initialNotification = await messaging().getInitialNotification();
2932
2969
  if (initialNotification && !this.initialNotificationHandled) {
2933
- const messageId = initialNotification.messageId;
2970
+ this.initialNotificationHandled = true;
2971
+ if (!this.isPushEnabled()) {
2972
+ Logger.log('[SwanSDK] Push notifications disabled, ignoring Firebase initial notification');
2973
+ return;
2974
+ }
2975
+ const notificationData = initialNotification.data || {};
2976
+ // Use custom messageId from data (consistent with Notifee handlers),
2977
+ // fall back to Firebase's system messageId
2978
+ const messageId = notificationData.messageId || initialNotification.messageId;
2979
+
2980
+ // Carousel notifications: defer to checkPendingCarouselClick for per-item routes
2981
+ if (notificationData.notificationType === 'carousel') {
2982
+ Logger.log('[SwanSDK] Carousel Firebase initial notification, deferring to checkPendingCarouselClick');
2983
+ return;
2984
+ }
2934
2985
 
2935
2986
  // Deduplication: prevent double click handling
2936
2987
  if (messageId && !markClickProcessed(messageId)) {
@@ -2939,12 +2990,7 @@ export default class SwanSDK {
2939
2990
  }
2940
2991
  Logger.log('[SwanSDK] App opened from Firebase notification (killed state):', messageId);
2941
2992
 
2942
- // Mark as handled to prevent duplicate ACKs
2943
- this.initialNotificationHandled = true;
2944
- const notificationData = initialNotification.data || {};
2945
-
2946
2993
  // Extract deep link information
2947
- // For carousel notifications, the default route is in 'defaultRoute' field
2948
2994
  const resolvedRoute = notificationData.route || notificationData.defaultRoute;
2949
2995
  const deepLinkPayload = {
2950
2996
  ...notificationData,
@@ -2954,13 +3000,11 @@ export default class SwanSDK {
2954
3000
  body: initialNotification.notification?.body || notificationData.body
2955
3001
  };
2956
3002
 
2957
- // Emit notificationOpened event for host app to handle deep linking
3003
+ // Emit deep link FIRST, then ACK
2958
3004
  this.emitNotificationOpened(deepLinkPayload);
2959
-
2960
- // Send click ACK
2961
- if (messageId) {
3005
+ if (messageId && !directAckSentIds.has(messageId)) {
2962
3006
  await this.sendNotificationAck(messageId, 'clicked');
2963
- Logger.log('[SwanSDK] Firebase initial notification click ACK sent');
3007
+ Logger.log('[SwanSDK] Firebase initial notification click ACK sent');
2964
3008
  }
2965
3009
  }
2966
3010
  } catch (error) {
@@ -3811,7 +3855,7 @@ export function createNotificationOpenedHandler() {
3811
3855
  Logger.warn('[SwanSDK] No messageId in notification data, skipping click ACK');
3812
3856
  }
3813
3857
  } catch (error) {
3814
- Logger.error('[SwanSDK] Error handling Notifee foreground event:', error);
3858
+ Logger.error('[SwanSDK] Error handling notification opened event:', error);
3815
3859
  }
3816
3860
  };
3817
3861
  }
@@ -3949,41 +3993,8 @@ export function createNotifeeBackgroundHandler() {
3949
3993
  // Get messageId and notification data
3950
3994
  const messageId = detail?.notification?.data?.messageId;
3951
3995
  const notificationData = detail?.notification?.data || {};
3952
-
3953
- // Deduplication: prevent double click handling
3954
- if (messageId && !markClickProcessed(messageId)) {
3955
- Logger.log('[SwanSDK] Click already processed for messageId:', messageId);
3956
- return;
3957
- }
3958
-
3959
- // Extract deep link information
3960
- // Note: route field can contain either a path (/products/123) or full URL (myapp://products/123)
3961
- // For carousel notifications, the default route is in 'defaultRoute' field
3962
- let route = notificationData.route || notificationData.defaultRoute;
3963
-
3964
- // For carousel notifications on iOS, the Content Extension saves per-item
3965
- // click data (including item-specific route) to the App Group.
3966
- if (Platform.OS === 'ios' && notificationData.notificationType === 'carousel') {
3967
- try {
3968
- const clickData = await SharedCredentialsManager.readTemplateClickData();
3969
- if (clickData?.route) {
3970
- Logger.log('[SwanSDK] Background: carousel item route from Content Extension:', clickData.route);
3971
- route = clickData.route;
3972
- }
3973
- } catch (err) {
3974
- Logger.warn('[SwanSDK] Failed to read Content Extension click data:', err);
3975
- }
3976
- }
3977
- const deepLinkPayload = {
3978
- ...notificationData,
3979
- route,
3980
- keyValuePairs: parseKeyValuePairs(notificationData),
3981
- title: detail?.notification?.title,
3982
- body: detail?.notification?.body
3983
- };
3984
3996
  Logger.log('[SwanSDK] Background notification clicked:', {
3985
3997
  messageId,
3986
- route: deepLinkPayload.route,
3987
3998
  type: notificationData.notificationType
3988
3999
  });
3989
4000
 
@@ -3991,10 +4002,39 @@ export function createNotifeeBackgroundHandler() {
3991
4002
  const sdkInstance = SwanSDK.getCurrentInstance();
3992
4003
  const isSDKReady = sdkInstance && sdkInstance.isReady();
3993
4004
  if (isSDKReady) {
4005
+ // Deduplication: only when SDK can deliver the deep link
4006
+ if (messageId && !markClickProcessed(messageId)) {
4007
+ Logger.log('[SwanSDK] Click already processed for messageId:', messageId);
4008
+ return;
4009
+ }
3994
4010
  if (!sdkInstance.isPushEnabled()) {
3995
4011
  return;
3996
4012
  }
3997
4013
 
4014
+ // Extract deep link information
4015
+ let route = notificationData.route || notificationData.defaultRoute;
4016
+
4017
+ // For carousel notifications on iOS, the Content Extension saves per-item
4018
+ // click data (including item-specific route) to the App Group.
4019
+ if (Platform.OS === 'ios' && notificationData.notificationType === 'carousel') {
4020
+ try {
4021
+ const clickData = await SharedCredentialsManager.readTemplateClickData();
4022
+ if (clickData?.route) {
4023
+ Logger.log('[SwanSDK] Background: carousel item route from Content Extension:', clickData.route);
4024
+ route = clickData.route;
4025
+ }
4026
+ } catch (err) {
4027
+ Logger.warn('[SwanSDK] Failed to read Content Extension click data:', err);
4028
+ }
4029
+ }
4030
+ const deepLinkPayload = {
4031
+ ...notificationData,
4032
+ route,
4033
+ keyValuePairs: parseKeyValuePairs(notificationData),
4034
+ title: detail?.notification?.title,
4035
+ body: detail?.notification?.body
4036
+ };
4037
+
3998
4038
  // Emit notificationOpened event for host app to handle
3999
4039
  sdkInstance.emitNotificationOpened(deepLinkPayload);
4000
4040
  Logger.log('[SwanSDK] Background: emitted notificationOpened with route:', deepLinkPayload.route);
@@ -4007,9 +4047,12 @@ export function createNotifeeBackgroundHandler() {
4007
4047
  return;
4008
4048
  }
4009
4049
 
4010
- // SDK not ready (app was killed) - send ACK directly via fetch
4050
+ // SDK not ready (app was killed) send ACK directly via fetch.
4051
+ // Do NOT markClickProcessed here: setupNotifeeEventListeners will
4052
+ // handle dedup and deep link routing when the SDK initializes.
4011
4053
  Logger.log('[SwanSDK] SDK not ready, sending click ACK via direct fetch');
4012
4054
  if (messageId) {
4055
+ directAckSentIds.add(messageId);
4013
4056
  await sendDirectNotificationAck(messageId, 'clicked');
4014
4057
  }
4015
4058
  } catch (error) {