Skip to content

Commit cf28ade

Browse files
committed
feat: add pushOpenHandler callback for push notification opens (#71)
Add a new pushOpenHandler callback to IterableConfig that fires when a push notification is opened, providing the raw push payload data. This eliminates the need for polling getLastPushPayload() and gives apps reliable access to push notification data on user interaction. The handler fires for push-originated URL actions, custom actions, and cold-start push opens without actions. https://claude.ai/code/session_01UHEH39nQzFoTkCh7EzShqn
1 parent e69c305 commit cf28ade

6 files changed

Lines changed: 161 additions & 2 deletions

File tree

android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ public class RNIterableAPIModuleImpl implements IterableUrlHandler, IterableCust
6969

7070
private final InboxSessionManager sessionManager = new InboxSessionManager();
7171

72+
private boolean pushOpenHandlerPresent = false;
73+
7274
public RNIterableAPIModuleImpl(ReactApplicationContext reactContext) {
7375
this.reactContext = reactContext;
7476
}
@@ -93,6 +95,8 @@ public void initializeWithApiKey(String apiKey, ReadableMap configReadableMap, S
9395
configBuilder.setAuthHandler(this);
9496
}
9597

98+
pushOpenHandlerPresent = configReadableMap.hasKey("pushOpenHandlerPresent") && configReadableMap.getBoolean("pushOpenHandlerPresent");
99+
96100
// Check if embedded messaging is enabled before building config
97101
boolean enableEmbeddedMessaging = configReadableMap.hasKey("enableEmbeddedMessaging") && configReadableMap.getBoolean("enableEmbeddedMessaging");
98102

@@ -136,6 +140,9 @@ public void initializeWithApiKey(String apiKey, ReadableMap configReadableMap, S
136140
IterableApi.getInstance().getEmbeddedManager().addUpdateListener(this);
137141
}
138142

143+
// Emit push open event for cold-start push opens
144+
emitPushOpenIfPresent();
145+
139146
// MOB-10421: Figure out what the error cases are and handle them appropriately
140147
// This is just here to match the TS types and let the JS thread know when we are done initializing
141148
promise.resolve(true);
@@ -161,6 +168,8 @@ public void initialize2WithApiKey(String apiKey, ReadableMap configReadableMap,
161168
configBuilder.setAuthHandler(this);
162169
}
163170

171+
pushOpenHandlerPresent = configReadableMap.hasKey("pushOpenHandlerPresent") && configReadableMap.getBoolean("pushOpenHandlerPresent");
172+
164173
// NOTE: There does not seem to be a way to set the API endpoint
165174
// override in the Android SDK. Check with @Ayyanchira and @evantk91 to
166175
// see what the best approach is.
@@ -208,6 +217,9 @@ public void initialize2WithApiKey(String apiKey, ReadableMap configReadableMap,
208217
IterableApi.getInstance().getEmbeddedManager().addUpdateListener(this);
209218
}
210219

220+
// Emit push open event for cold-start push opens
221+
emitPushOpenIfPresent();
222+
211223
// MOB-10421: Figure out what the error cases are and handle them appropriately
212224
// This is just here to match the TS types and let the JS thread know when we are done initializing
213225
promise.resolve(true);
@@ -596,6 +608,12 @@ public boolean handleIterableCustomAction(@NonNull IterableAction action, @NonNu
596608
} catch (JSONException e) {
597609
IterableLogger.e(TAG, "Failed handling custom action");
598610
}
611+
612+
// Also emit push open event when the custom action originated from a push notification
613+
if (pushOpenHandlerPresent && actionContext.source.ordinal() == 0 /* PUSH */) {
614+
emitPushOpenIfPresent();
615+
}
616+
599617
// The Android SDK will not bring the app into focus is this is `true`. It still respects the `openApp` bool flag.
600618
return false;
601619
}
@@ -635,6 +653,12 @@ public boolean handleIterableURL(@NonNull Uri uri, @NonNull IterableActionContex
635653
} catch (JSONException e) {
636654
IterableLogger.e(TAG, e.getLocalizedMessage());
637655
}
656+
657+
// Also emit push open event when the URL action originated from a push notification
658+
if (pushOpenHandlerPresent && actionContext.source.ordinal() == 0 /* PUSH */) {
659+
emitPushOpenIfPresent();
660+
}
661+
638662
return true;
639663
}
640664

@@ -701,6 +725,18 @@ public void sendEvent(@NonNull String eventName, @Nullable Object eventData) {
701725
reactContext.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class).emit(eventName, eventData);
702726
}
703727

728+
private void emitPushOpenIfPresent() {
729+
if (!pushOpenHandlerPresent) {
730+
return;
731+
}
732+
Bundle payloadData = IterableApi.getInstance().getPayloadData();
733+
if (payloadData != null) {
734+
WritableMap eventData = Arguments.createMap();
735+
eventData.putMap("pushPayload", Arguments.fromBundle(payloadData));
736+
sendEvent(EventName.handlePushOpenCalled.name(), eventData);
737+
}
738+
}
739+
704740
@Override
705741
public void onInboxUpdated() {
706742
sendEvent(EventName.receivedIterableInboxChanged.name(), null);
@@ -809,6 +845,7 @@ enum EventName {
809845
handleEmbeddedMessageUpdateCalled,
810846
handleEmbeddedMessagingDisabledCalled,
811847
handleInAppCalled,
848+
handlePushOpenCalled,
812849
handleUrlCalled,
813850
receivedIterableEmbeddedMessagesChanged,
814851
receivedIterableInboxChanged

ios/RNIterableAPI/ReactIterableAPI.swift

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import React
3535
case handleAuthFailureCalled
3636
case handleEmbeddedMessageUpdateCalled
3737
case handleEmbeddedMessagingDisabledCalled
38+
case handlePushOpenCalled
3839
}
3940

4041
@objc public static var supportedEvents: [String] {
@@ -599,6 +600,8 @@ import React
599600

600601
private let inboxSessionManager = InboxSessionManager()
601602

603+
private var pushOpenHandlerPresent = false
604+
602605
@objc func initialize(
603606
withApiKey apiKey: String,
604607
config configDict: NSDictionary,
@@ -644,6 +647,8 @@ import React
644647
self, selector: #selector(receivedIterableInboxChanged),
645648
name: Notification.Name.iterableInboxChanged, object: nil)
646649

650+
self.pushOpenHandlerPresent = configDict["pushOpenHandlerPresent"] as? Bool ?? false
651+
647652
DispatchQueue.main.async {
648653
IterableAPI.initialize2(
649654
apiKey: apiKey,
@@ -655,14 +660,21 @@ import React
655660
}
656661

657662
IterableAPI.setDeviceAttribute(name: "reactNativeSDKVersion", value: version)
658-
663+
659664
// Add embedded update listener if any callback is present
660665
let onEmbeddedMessageUpdatePresent = configDict["onEmbeddedMessageUpdatePresent"] as? Bool ?? false
661666
let onEmbeddedMessagingDisabledPresent = configDict["onEmbeddedMessagingDisabledPresent"] as? Bool ?? false
662-
667+
663668
if onEmbeddedMessageUpdatePresent || onEmbeddedMessagingDisabledPresent {
664669
IterableAPI.embeddedManager.addUpdateListener(self)
665670
}
671+
672+
// Emit push open event for cold-start push opens
673+
if self.pushOpenHandlerPresent, let pushPayload = IterableAPI.lastPushPayload {
674+
self.delegate?.sendEvent(
675+
withName: EventName.handlePushOpenCalled.rawValue,
676+
body: ["pushPayload": pushPayload])
677+
}
666678
}
667679
}
668680

@@ -710,6 +722,14 @@ extension ReactIterableAPI: IterableURLDelegate {
710722
"url": url.absoluteString,
711723
"context": contextDict,
712724
] as [String: Any])
725+
726+
// Also emit push open event when the URL action originated from a push notification
727+
if pushOpenHandlerPresent && context.source == .push {
728+
let pushPayload = IterableAPI.lastPushPayload ?? [:]
729+
delegate?.sendEvent(
730+
withName: EventName.handlePushOpenCalled.rawValue,
731+
body: ["pushPayload": pushPayload])
732+
}
713733
return true
714734
}
715735

@@ -749,6 +769,14 @@ extension ReactIterableAPI: IterableCustomActionDelegate {
749769
"action": actionDict,
750770
"context": contextDict,
751771
])
772+
773+
// Also emit push open event when the custom action originated from a push notification
774+
if pushOpenHandlerPresent && context.source == .push {
775+
let pushPayload = IterableAPI.lastPushPayload ?? [:]
776+
delegate?.sendEvent(
777+
withName: EventName.handlePushOpenCalled.rawValue,
778+
body: ["pushPayload": pushPayload])
779+
}
752780
return true
753781
}
754782
}

src/core/classes/Iterable.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,7 @@ describe('Iterable', () => {
321321
expect(config.logLevel).toBe(IterableLogLevel.debug);
322322
expect(config.logReactNativeSdkCalls).toBe(true);
323323
expect(config.pushIntegrationName).toBe(undefined);
324+
expect(config.pushOpenHandler).toBe(undefined);
324325
expect(config.urlHandler).toBe(undefined);
325326
expect(config.useInMemoryStorageForInApps).toBe(false);
326327
const configDict = config.toDict();
@@ -337,11 +338,59 @@ describe('Iterable', () => {
337338
expect(configDict.inAppHandlerPresent).toBe(false);
338339
expect(configDict.logLevel).toBe(IterableLogLevel.debug);
339340
expect(configDict.pushIntegrationName).toBe(undefined);
341+
expect(configDict.pushOpenHandlerPresent).toBe(false);
340342
expect(configDict.urlHandlerPresent).toBe(false);
341343
expect(configDict.useInMemoryStorageForInApps).toBe(false);
342344
});
343345
});
344346

347+
describe('pushOpenHandler', () => {
348+
it('should be called with the push payload when handlePushOpenCalled event is emitted', () => {
349+
// sets up event emitter
350+
const nativeEmitter = new NativeEventEmitter();
351+
nativeEmitter.removeAllListeners(IterableEventName.handlePushOpenCalled);
352+
// sets up config file and pushOpenHandler function
353+
const config = new IterableConfig();
354+
config.logReactNativeSdkCalls = false;
355+
config.pushOpenHandler = jest.fn(
356+
(_pushPayload: Record<string, unknown>) => {}
357+
);
358+
// initialize Iterable object
359+
Iterable.initialize('apiKey', config);
360+
// GIVEN a push payload
361+
const expectedPayload = {
362+
campaignId: 123,
363+
templateId: 456,
364+
customKey: 'customValue',
365+
};
366+
const dict = { pushPayload: expectedPayload };
367+
// WHEN handlePushOpenCalled event is emitted
368+
nativeEmitter.emit(IterableEventName.handlePushOpenCalled, dict);
369+
// THEN pushOpenHandler is called with expected payload
370+
expect(config.pushOpenHandler).toBeCalledWith(expectedPayload);
371+
});
372+
373+
it('should pass empty object when pushPayload is undefined', () => {
374+
// sets up event emitter
375+
const nativeEmitter = new NativeEventEmitter();
376+
nativeEmitter.removeAllListeners(IterableEventName.handlePushOpenCalled);
377+
// sets up config file and pushOpenHandler function
378+
const config = new IterableConfig();
379+
config.logReactNativeSdkCalls = false;
380+
config.pushOpenHandler = jest.fn(
381+
(_pushPayload: Record<string, unknown>) => {}
382+
);
383+
// initialize Iterable object
384+
Iterable.initialize('apiKey', config);
385+
// GIVEN no push payload in the event
386+
const dict = {};
387+
// WHEN handlePushOpenCalled event is emitted
388+
nativeEmitter.emit(IterableEventName.handlePushOpenCalled, dict);
389+
// THEN pushOpenHandler is called with empty object
390+
expect(config.pushOpenHandler).toBeCalledWith({});
391+
});
392+
});
393+
345394
describe('urlHandler', () => {
346395
it('should open the url when canOpenURL returns true and urlHandler returns false', async () => {
347396
// sets up event emitter

src/core/classes/Iterable.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -956,6 +956,9 @@ export class Iterable {
956956
RNEventEmitter.removeAllListeners(
957957
IterableEventName.handleEmbeddedMessagingDisabledCalled
958958
);
959+
RNEventEmitter.removeAllListeners(
960+
IterableEventName.handlePushOpenCalled
961+
);
959962
}
960963

961964
/**
@@ -1109,6 +1112,16 @@ export class Iterable {
11091112
);
11101113
}
11111114
}
1115+
1116+
if (Iterable.savedConfig.pushOpenHandler) {
1117+
RNEventEmitter.addListener(
1118+
IterableEventName.handlePushOpenCalled,
1119+
(dict) => {
1120+
const pushPayload = dict.pushPayload ?? {};
1121+
Iterable.savedConfig.pushOpenHandler?.(pushPayload);
1122+
}
1123+
);
1124+
}
11121125
}
11131126

11141127
/**

src/core/classes/IterableConfig.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,31 @@ export class IterableConfig {
375375
*/
376376
onEmbeddedMessagingDisabled?: () => void;
377377

378+
/**
379+
* A callback function that is called when a push notification is opened by the user.
380+
*
381+
* This handler provides the raw push notification payload, giving you access
382+
* to all custom data fields sent with the notification. It fires for all
383+
* push-originated interactions, regardless of whether the notification has
384+
* a URL action, custom action, or no action at all.
385+
*
386+
* @param pushPayload - The raw push notification payload as a dictionary.
387+
*
388+
* @example
389+
* ```typescript
390+
* const config = new IterableConfig();
391+
* config.pushOpenHandler = (pushPayload) => {
392+
* console.log('Push notification opened:', pushPayload);
393+
* // Navigate based on custom data in the payload
394+
* if (pushPayload.screen) {
395+
* navigation.navigate(pushPayload.screen);
396+
* }
397+
* };
398+
* Iterable.initialize('<YOUR_API_KEY>', config);
399+
* ```
400+
*/
401+
pushOpenHandler?: (pushPayload: Record<string, unknown>) => void;
402+
378403
/**
379404
* Converts the IterableConfig instance to a dictionary object.
380405
*
@@ -428,6 +453,11 @@ export class IterableConfig {
428453
// eslint-disable-next-line eqeqeq
429454
onEmbeddedMessagingDisabledPresent:
430455
this.onEmbeddedMessagingDisabled != undefined,
456+
/**
457+
* A boolean indicating if a push open handler is present.
458+
*/
459+
// eslint-disable-next-line eqeqeq
460+
pushOpenHandlerPresent: this.pushOpenHandler != undefined,
431461
/** The log level for the SDK. */
432462
logLevel: this.logLevel,
433463
expiringAuthTokenRefreshPeriod: this.expiringAuthTokenRefreshPeriod,

src/core/enums/IterableEventName.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,6 @@ export enum IterableEventName {
2323
handleEmbeddedMessageUpdateCalled = 'handleEmbeddedMessageUpdateCalled',
2424
/** Event that fires when embedded messaging is disabled */
2525
handleEmbeddedMessagingDisabledCalled = 'handleEmbeddedMessagingDisabledCalled',
26+
/** Event that fires when a push notification is opened */
27+
handlePushOpenCalled = 'handlePushOpenCalled',
2628
}

0 commit comments

Comments
 (0)