diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index bedb7ef52..16447ac65 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -771,6 +771,16 @@ public void getEmbeddedMessages(@Nullable ReadableArray placementIds, Promise pr } } + public void trackEmbeddedClick(ReadableMap messageMap, String buttonId, String clickedUrl) { + IterableLogger.d(TAG, "trackEmbeddedClick: buttonId: " + buttonId + " clickedUrl: " + clickedUrl); + IterableEmbeddedMessage message = Serialization.embeddedMessageFromReadableMap(messageMap); + if (message != null) { + IterableApi.getInstance().trackEmbeddedClick(message, buttonId, clickedUrl); + } else { + IterableLogger.e(TAG, "Failed to convert message map to IterableEmbeddedMessage"); + } + } + // --------------------------------------------------------------------------------------- // endregion } diff --git a/android/src/main/java/com/iterable/reactnative/Serialization.java b/android/src/main/java/com/iterable/reactnative/Serialization.java index 2f0c4e1ca..97aa52f49 100644 --- a/android/src/main/java/com/iterable/reactnative/Serialization.java +++ b/android/src/main/java/com/iterable/reactnative/Serialization.java @@ -149,6 +149,22 @@ static JSONArray serializeEmbeddedMessages(List embedde return embeddedMessagesJson; } + /** + * Converts a ReadableMap to an IterableEmbeddedMessage. + * + * This is needed as in new arch you can only pass in basic types, which + * then need to be converted in the native layer. + */ + static IterableEmbeddedMessage embeddedMessageFromReadableMap(ReadableMap messageMap) { + try { + JSONObject messageJson = convertMapToJson(messageMap); + return IterableEmbeddedMessage.Companion.fromJSONObject(messageJson); + } catch (JSONException e) { + IterableLogger.e(TAG, "Failed to convert ReadableMap to IterableEmbeddedMessage: " + e.getLocalizedMessage()); + return null; + } + } + static IterableConfig.Builder getConfigFromReadableMap(ReadableMap iterableContextMap) { try { JSONObject iterableContextJSON = convertMapToJson(iterableContextMap); diff --git a/android/src/newarch/java/com/RNIterableAPIModule.java b/android/src/newarch/java/com/RNIterableAPIModule.java index 457788ba8..056a5649d 100644 --- a/android/src/newarch/java/com/RNIterableAPIModule.java +++ b/android/src/newarch/java/com/RNIterableAPIModule.java @@ -259,6 +259,11 @@ public void getEmbeddedMessages(@Nullable ReadableArray placementIds, Promise pr moduleImpl.getEmbeddedMessages(placementIds, promise); } + @Override + public void trackEmbeddedClick(ReadableMap message, String buttonId, String clickedUrl) { + moduleImpl.trackEmbeddedClick(message, buttonId, clickedUrl); + } + public void sendEvent(@NonNull String eventName, @Nullable Object eventData) { moduleImpl.sendEvent(eventName, eventData); } diff --git a/android/src/oldarch/java/com/RNIterableAPIModule.java b/android/src/oldarch/java/com/RNIterableAPIModule.java index fb6c76d76..f387ed681 100644 --- a/android/src/oldarch/java/com/RNIterableAPIModule.java +++ b/android/src/oldarch/java/com/RNIterableAPIModule.java @@ -263,6 +263,11 @@ public void getEmbeddedMessages(@Nullable ReadableArray placementIds, Promise pr moduleImpl.getEmbeddedMessages(placementIds, promise); } + @ReactMethod + public void trackEmbeddedClick(ReadableMap message, String buttonId, String clickedUrl) { + moduleImpl.trackEmbeddedClick(message, buttonId, clickedUrl); + } + public void sendEvent(@NonNull String eventName, @Nullable Object eventData) { moduleImpl.sendEvent(eventName, eventData); } diff --git a/example/src/components/Embedded/Embedded.tsx b/example/src/components/Embedded/Embedded.tsx index cf74d579c..785d722d6 100644 --- a/example/src/components/Embedded/Embedded.tsx +++ b/example/src/components/Embedded/Embedded.tsx @@ -2,6 +2,7 @@ import { ScrollView, Text, TouchableOpacity, View } from 'react-native'; import { useCallback, useState } from 'react'; import { Iterable, + type IterableAction, type IterableEmbeddedMessage, } from '@iterable/react-native-sdk'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -69,6 +70,18 @@ export const Embedded = () => { [] ); + const handleClick = useCallback( + ( + message: IterableEmbeddedMessage, + buttonId: string | null, + action?: IterableAction | null + ) => { + console.log(`handleClick:`, message); + Iterable.embeddedManager.handleClick(message, buttonId, action); + }, + [] + ); + return ( EMBEDDED @@ -105,7 +118,9 @@ export const Embedded = () => { {embeddedMessages.map((message) => ( - Embedded message | + Embedded message + + startEmbeddedImpression(message)} > @@ -117,15 +132,42 @@ export const Embedded = () => { > Pause impression + | + + handleClick(message, null, message.elements?.defaultAction) + } + > + Handle click + metadata.messageId: {message.metadata.messageId} metadata.placementId: {message.metadata.placementId} elements.title: {message.elements?.title} elements.body: {message.elements?.body} + + elements.defaultAction.data:{' '} + {message.elements?.defaultAction?.data} + + + elements.defaultAction.type:{' '} + {message.elements?.defaultAction?.type} + {(message.elements?.buttons ?? []).map((button, buttonIndex) => ( - Button {buttonIndex + 1} + + Button {buttonIndex + 1} + | + + handleClick(message, button.id, button.action) + } + > + Handle click + + + button.id: {button.id} button.title: {button.title} button.action?.data: {button.action?.data} diff --git a/src/__mocks__/MockRNIterableAPI.ts b/src/__mocks__/MockRNIterableAPI.ts index ce954bbb5..b79eebce9 100644 --- a/src/__mocks__/MockRNIterableAPI.ts +++ b/src/__mocks__/MockRNIterableAPI.ts @@ -182,6 +182,8 @@ export class MockRNIterableAPI { static pauseEmbeddedImpression = jest.fn(); + static trackEmbeddedClick = jest.fn(); + // set messages function is to set the messages static property // this is for testing purposes only static setMessages(messages: IterableInAppMessage[]): void { diff --git a/src/api/NativeRNIterableAPI.ts b/src/api/NativeRNIterableAPI.ts index 6e03cb2b7..ed1590528 100644 --- a/src/api/NativeRNIterableAPI.ts +++ b/src/api/NativeRNIterableAPI.ts @@ -155,6 +155,11 @@ export interface Spec extends TurboModule { getEmbeddedMessages( placementIds: number[] | null ): Promise; + trackEmbeddedClick( + message: EmbeddedMessage, + buttonId: string | null, + clickedUrl: string | null + ): void; // Wake app -- android only wakeApp(): void; diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index 63cbe527a..d9c98a572 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -1,8 +1,9 @@ -import { Linking, NativeEventEmitter, Platform } from 'react-native'; +import { NativeEventEmitter, Platform } from 'react-native'; import { buildInfo } from '../../itblBuildInfo'; import { RNIterableAPI } from '../../api'; +import { IterableEmbeddedManager } from '../../embedded/classes/IterableEmbeddedManager'; import { IterableInAppManager } from '../../inApp/classes/IterableInAppManager'; import { IterableInAppMessage } from '../../inApp/classes/IterableInAppMessage'; import { IterableInAppCloseSource } from '../../inApp/enums/IterableInAppCloseSource'; @@ -11,6 +12,7 @@ import { IterableInAppLocation } from '../../inApp/enums/IterableInAppLocation'; import { IterableAuthResponseResult } from '../enums/IterableAuthResponseResult'; import { IterableEventName } from '../enums/IterableEventName'; import type { IterableAuthFailure } from '../types/IterableAuthFailure'; +import { callUrlHandler } from '../utils/callUrlHandler'; import { IterableAction } from './IterableAction'; import { IterableActionContext } from './IterableActionContext'; import { IterableApi } from './IterableApi'; @@ -20,10 +22,11 @@ import { IterableAuthResponse } from './IterableAuthResponse'; import type { IterableCommerceItem } from './IterableCommerceItem'; import { IterableConfig } from './IterableConfig'; import { IterableLogger } from './IterableLogger'; -import { IterableEmbeddedManager } from '../../embedded/classes/IterableEmbeddedManager'; const RNEventEmitter = new NativeEventEmitter(RNIterableAPI); +const defaultConfig = new IterableConfig(); + /** * Checks if the response is an IterableAuthResponse */ @@ -63,7 +66,7 @@ export class Iterable { /** * Current configuration of the Iterable SDK */ - static savedConfig: IterableConfig = new IterableConfig(); + static savedConfig: IterableConfig = defaultConfig; /** * In-app message manager for the current user. @@ -115,8 +118,9 @@ export class Iterable { * }); * ``` */ - static embeddedManager: IterableEmbeddedManager = - new IterableEmbeddedManager(); + static embeddedManager: IterableEmbeddedManager = new IterableEmbeddedManager( + defaultConfig + ); /** * Initializes the Iterable React Native SDK in your app's Javascript or Typescript code. @@ -195,9 +199,7 @@ export class Iterable { IterableLogger.setLoggingEnabled(config.logReactNativeSdkCalls ?? true); IterableLogger.setLogLevel(config.logLevel); - Iterable.embeddedManager.setEnabled( - config.enableEmbeddedMessaging ?? false - ); + Iterable.embeddedManager = new IterableEmbeddedManager(config); } this.setupEventHandlers(); @@ -983,10 +985,10 @@ export class Iterable { if (Platform.OS === 'android') { //Give enough time for Activity to wake up. setTimeout(() => { - callUrlHandler(url, context); + callUrlHandler(Iterable.savedConfig, url, context); }, 1000); } else { - callUrlHandler(url, context); + callUrlHandler(Iterable.savedConfig, url, context); } }); } @@ -1081,22 +1083,6 @@ export class Iterable { } ); } - - function callUrlHandler(url: string, context: IterableActionContext) { - // MOB-10424: Figure out if this is purposeful - // eslint-disable-next-line eqeqeq - if (Iterable.savedConfig.urlHandler?.(url, context) == false) { - Linking.canOpenURL(url) - .then((canOpen) => { - if (canOpen) { - Linking.openURL(url); - } - }) - .catch((reason) => { - IterableLogger?.log('could not open url: ' + reason); - }); - } - } } /** diff --git a/src/core/classes/IterableApi.ts b/src/core/classes/IterableApi.ts index bbe687605..09d0dc44f 100644 --- a/src/core/classes/IterableApi.ts +++ b/src/core/classes/IterableApi.ts @@ -571,6 +571,18 @@ export class IterableApi { return RNIterableAPI.getEmbeddedMessages(placementIds); } + /** + * Track an embedded click. + */ + static trackEmbeddedClick( + message: IterableEmbeddedMessage, + buttonId: string | null, + clickedUrl: string | null + ) { + IterableLogger.log('trackEmbeddedClick: ', message, buttonId, clickedUrl); + return RNIterableAPI.trackEmbeddedClick(message, buttonId, clickedUrl); + } + // ---- End EMBEDDED ---- // // ====================================================== // diff --git a/src/core/enums/IterableActionSource.ts b/src/core/enums/IterableActionSource.ts index 3692e6361..437bb9808 100644 --- a/src/core/enums/IterableActionSource.ts +++ b/src/core/enums/IterableActionSource.ts @@ -8,4 +8,6 @@ export enum IterableActionSource { appLink = 1, /** The action source was an in-app message */ inApp = 2, + /** The action source was an embedded message */ + embedded = 3, } diff --git a/src/core/enums/IterableCustomActionPrefix.ts b/src/core/enums/IterableCustomActionPrefix.ts new file mode 100644 index 000000000..c8135a009 --- /dev/null +++ b/src/core/enums/IterableCustomActionPrefix.ts @@ -0,0 +1,9 @@ +/** + * Enum representing the prefix of build-in custom action URL. + */ +export enum IterableCustomActionPrefix { + /** Current action prefix */ + Action = 'action://', + /** Deprecated action prefix */ + Itbl = 'itbl://', +} diff --git a/src/core/enums/index.ts b/src/core/enums/index.ts index 52f4eb20d..21f06dee7 100644 --- a/src/core/enums/index.ts +++ b/src/core/enums/index.ts @@ -6,3 +6,4 @@ export * from './IterableEventName'; export * from './IterableLogLevel'; export * from './IterablePushPlatform'; export * from './IterableRetryBackoff'; +export * from './IterableCustomActionPrefix'; diff --git a/src/core/utils/callUrlHandler.ts b/src/core/utils/callUrlHandler.ts new file mode 100644 index 000000000..4125de5d2 --- /dev/null +++ b/src/core/utils/callUrlHandler.ts @@ -0,0 +1,31 @@ +import { Linking } from 'react-native'; +import type { IterableActionContext } from '../classes/IterableActionContext'; +import { IterableLogger } from '../classes/IterableLogger'; +import type { IterableConfig } from '../classes/IterableConfig'; + +/** + * Calls the URL handler and attempts to open the URL if the handler returns false. + * + * @param config - The config to use. + * @param url - The URL to call. + * @param context - The context to use. + */ +export function callUrlHandler( + config: IterableConfig, + url: string, + context: IterableActionContext +) { + if (!config.urlHandler?.(url, context)) { + Linking.canOpenURL(url) + .then((canOpen) => { + if (canOpen) { + Linking.openURL(url); + } else { + IterableLogger?.log('Url cannot be opened: ' + url); + } + }) + .catch((reason) => { + IterableLogger?.log('Error opening url: ' + reason); + }); + } +} diff --git a/src/core/utils/getActionPrefix.test.ts b/src/core/utils/getActionPrefix.test.ts new file mode 100644 index 000000000..7099d7c0f --- /dev/null +++ b/src/core/utils/getActionPrefix.test.ts @@ -0,0 +1,227 @@ +import { getActionPrefix } from './getActionPrefix'; +import { IterableCustomActionPrefix } from '../enums/IterableCustomActionPrefix'; + +/** + * Tests for getActionPrefix utility function. + */ +describe('getActionPrefix', () => { + describe('when string starts with action:// prefix', () => { + it('should return Action prefix for exact action:// string', () => { + // GIVEN a string that is exactly the action prefix + const str = 'action://'; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return the Action prefix + expect(result).toBe(IterableCustomActionPrefix.Action); + }); + + it('should return Action prefix for action:// with additional path', () => { + // GIVEN a string starting with action:// and additional path + const str = 'action://some/path'; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return the Action prefix + expect(result).toBe(IterableCustomActionPrefix.Action); + }); + + it('should return Action prefix for action:// with query params', () => { + // GIVEN a string starting with action:// and query parameters + const str = 'action://deeplink?param1=value1¶m2=value2'; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return the Action prefix + expect(result).toBe(IterableCustomActionPrefix.Action); + }); + }); + + describe('when string starts with itbl:// prefix', () => { + it('should return Itbl prefix for exact itbl:// string', () => { + // GIVEN a string that is exactly the deprecated itbl prefix + const str = 'itbl://'; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return the Itbl prefix + expect(result).toBe(IterableCustomActionPrefix.Itbl); + }); + + it('should return Itbl prefix for itbl:// with additional path', () => { + // GIVEN a string starting with itbl:// and additional path + const str = 'itbl://some/path'; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return the Itbl prefix + expect(result).toBe(IterableCustomActionPrefix.Itbl); + }); + + it('should return Itbl prefix for itbl:// with query params', () => { + // GIVEN a string starting with itbl:// and query parameters + const str = 'itbl://deeplink?param1=value1¶m2=value2'; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return the Itbl prefix + expect(result).toBe(IterableCustomActionPrefix.Itbl); + }); + }); + + describe('when string does not have a recognized prefix', () => { + it('should return null for regular URL', () => { + // GIVEN a regular https URL + const str = 'https://example.com'; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return null + expect(result).toBeNull(); + }); + + it('should return null for http URL', () => { + // GIVEN a regular http URL + const str = 'http://example.com'; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return null + expect(result).toBeNull(); + }); + + it('should return null for custom scheme URL', () => { + // GIVEN a custom scheme URL that is not action:// or itbl:// + const str = 'myapp://deeplink'; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return null + expect(result).toBeNull(); + }); + + it('should return null for plain text', () => { + // GIVEN a plain text string + const str = 'just some text'; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return null + expect(result).toBeNull(); + }); + + it('should return null for empty string', () => { + // GIVEN an empty string + const str = ''; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return null + expect(result).toBeNull(); + }); + }); + + describe('when string is null or undefined', () => { + it('should return null for undefined string', () => { + // GIVEN an undefined string + const str = undefined; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return null + expect(result).toBeNull(); + }); + + it('should return null for null string', () => { + // GIVEN a null string + const str = null; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return null + expect(result).toBeNull(); + }); + }); + + describe('edge cases and case sensitivity', () => { + it('should be case sensitive and not match ACTION://', () => { + // GIVEN a string with uppercase ACTION:// + const str = 'ACTION://deeplink'; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return null (case sensitive) + expect(result).toBeNull(); + }); + + it('should be case sensitive and not match ITBL://', () => { + // GIVEN a string with uppercase ITBL:// + const str = 'ITBL://deeplink'; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return null (case sensitive) + expect(result).toBeNull(); + }); + + it('should not match action:// in the middle of string', () => { + // GIVEN a string with action:// not at the start + const str = 'prefix action://deeplink'; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return null + expect(result).toBeNull(); + }); + + it('should not match itbl:// in the middle of string', () => { + // GIVEN a string with itbl:// not at the start + const str = 'prefix itbl://deeplink'; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return null + expect(result).toBeNull(); + }); + + it('should not match partial prefix action:', () => { + // GIVEN a string with incomplete action prefix + const str = 'action:deeplink'; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return null + expect(result).toBeNull(); + }); + + it('should not match partial prefix itbl:', () => { + // GIVEN a string with incomplete itbl prefix + const str = 'itbl:deeplink'; + + // WHEN getting the action prefix + const result = getActionPrefix(str); + + // THEN it should return null + expect(result).toBeNull(); + }); + }); +}); + diff --git a/src/core/utils/getActionPrefix.ts b/src/core/utils/getActionPrefix.ts new file mode 100644 index 000000000..b4fcf9fbe --- /dev/null +++ b/src/core/utils/getActionPrefix.ts @@ -0,0 +1,20 @@ +import { IterableCustomActionPrefix } from '../enums/IterableCustomActionPrefix'; + +/** + * Gets the action prefix from a string. + * + * @param str - The string to get the action prefix from. + * @returns The action prefix. + */ +export const getActionPrefix = ( + str?: string | null +): IterableCustomActionPrefix | null => { + if (!str) return null; + if (str.startsWith(IterableCustomActionPrefix.Action)) { + return IterableCustomActionPrefix.Action; + } + if (str.startsWith(IterableCustomActionPrefix.Itbl)) { + return IterableCustomActionPrefix.Itbl; + } + return null; +}; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts new file mode 100644 index 000000000..b489ab8b4 --- /dev/null +++ b/src/core/utils/index.ts @@ -0,0 +1,2 @@ +export * from './getActionPrefix'; +export * from './callUrlHandler'; diff --git a/src/embedded/classes/IterableEmbeddedManager.test.ts b/src/embedded/classes/IterableEmbeddedManager.test.ts index 5a0e15f4e..a00a2e448 100644 --- a/src/embedded/classes/IterableEmbeddedManager.test.ts +++ b/src/embedded/classes/IterableEmbeddedManager.test.ts @@ -1,4 +1,8 @@ import { MockRNIterableAPI } from '../../__mocks__/MockRNIterableAPI'; +import { IterableAction } from '../../core/classes/IterableAction'; +import { IterableConfig } from '../../core/classes/IterableConfig'; +import { IterableLogger } from '../../core/classes/IterableLogger'; +import type { IterableEmbeddedMessage } from '../types/IterableEmbeddedMessage'; import { IterableEmbeddedManager } from './IterableEmbeddedManager'; // Mock the RNIterableAPI module @@ -7,11 +11,39 @@ jest.mock('../../api', () => ({ default: MockRNIterableAPI, })); +// Mock the callUrlHandler utility +jest.mock('../../core/utils/callUrlHandler', () => ({ + callUrlHandler: jest.fn(), +})); + +// Mock the IterableLogger +jest.mock('../../core/classes/IterableLogger', () => ({ + IterableLogger: { + log: jest.fn(), + }, +})); + describe('IterableEmbeddedManager', () => { let embeddedManager: IterableEmbeddedManager; + let config: IterableConfig; + + // Mock embedded message for testing + const mockEmbeddedMessage: IterableEmbeddedMessage = { + metadata: { + messageId: 'test-message-id', + campaignId: 12345, + placementId: 1, + }, + elements: { + title: 'Test Message', + body: 'Test body', + }, + payload: { customKey: 'customValue' }, + }; beforeEach(() => { - embeddedManager = new IterableEmbeddedManager(); + config = new IterableConfig(); + embeddedManager = new IterableEmbeddedManager(config); jest.clearAllMocks(); }); @@ -254,5 +286,392 @@ describe('IterableEmbeddedManager', () => { ); }); }); + + describe('trackClick', () => { + it('should call IterableApi.trackEmbeddedClick with message, buttonId and clickedUrl', () => { + // GIVEN a message, button ID and clicked URL + const buttonId = 'button-1'; + const clickedUrl = 'https://example.com'; + + // WHEN trackClick is called + embeddedManager.trackClick(mockEmbeddedMessage, buttonId, clickedUrl); + + // THEN IterableApi.trackEmbeddedClick is called with the correct parameters + expect(MockRNIterableAPI.trackEmbeddedClick).toHaveBeenCalledTimes(1); + expect(MockRNIterableAPI.trackEmbeddedClick).toHaveBeenCalledWith( + mockEmbeddedMessage, + buttonId, + clickedUrl + ); + }); + + it('should handle null buttonId', () => { + // GIVEN a message with null buttonId + const buttonId = null; + const clickedUrl = 'https://example.com'; + + // WHEN trackClick is called + embeddedManager.trackClick(mockEmbeddedMessage, buttonId, clickedUrl); + + // THEN IterableApi.trackEmbeddedClick is called with null buttonId + expect(MockRNIterableAPI.trackEmbeddedClick).toHaveBeenCalledTimes(1); + expect(MockRNIterableAPI.trackEmbeddedClick).toHaveBeenCalledWith( + mockEmbeddedMessage, + null, + clickedUrl + ); + }); + + it('should handle null clickedUrl', () => { + // GIVEN a message with null clickedUrl + const buttonId = 'button-1'; + const clickedUrl = null; + + // WHEN trackClick is called + embeddedManager.trackClick(mockEmbeddedMessage, buttonId, clickedUrl); + + // THEN IterableApi.trackEmbeddedClick is called with null clickedUrl + expect(MockRNIterableAPI.trackEmbeddedClick).toHaveBeenCalledTimes(1); + expect(MockRNIterableAPI.trackEmbeddedClick).toHaveBeenCalledWith( + mockEmbeddedMessage, + buttonId, + null + ); + }); + + it('should handle multiple trackClick calls', () => { + // GIVEN multiple click events + const buttonId1 = 'button-1'; + const clickedUrl1 = 'https://example.com/1'; + const buttonId2 = 'button-2'; + const clickedUrl2 = 'https://example.com/2'; + + // WHEN trackClick is called multiple times + embeddedManager.trackClick(mockEmbeddedMessage, buttonId1, clickedUrl1); + embeddedManager.trackClick(mockEmbeddedMessage, buttonId2, clickedUrl2); + + // THEN IterableApi.trackEmbeddedClick is called twice + expect(MockRNIterableAPI.trackEmbeddedClick).toHaveBeenCalledTimes(2); + expect(MockRNIterableAPI.trackEmbeddedClick).toHaveBeenNthCalledWith( + 1, + mockEmbeddedMessage, + buttonId1, + clickedUrl1 + ); + expect(MockRNIterableAPI.trackEmbeddedClick).toHaveBeenNthCalledWith( + 2, + mockEmbeddedMessage, + buttonId2, + clickedUrl2 + ); + }); + }); + + describe('handleClick', () => { + // Import the mocked callUrlHandler + const { callUrlHandler } = require('../../core/utils/callUrlHandler'); + + beforeEach(() => { + // Add trackEmbeddedClick mock if not already present + MockRNIterableAPI.trackEmbeddedClick = jest.fn(); + }); + + it('should return early and log when no clickedUrl is provided', () => { + // GIVEN no action is provided + const buttonId = 'button-1'; + + // WHEN handleClick is called without an action + embeddedManager.handleClick(mockEmbeddedMessage, buttonId, null); + + // THEN it should log the error + expect(IterableLogger.log).toHaveBeenCalledWith( + 'Iterable.embeddedManager.handleClick:', + 'A url or action is required to handle an embedded click', + undefined + ); + + // AND trackClick should not be called + expect(MockRNIterableAPI.trackEmbeddedClick).not.toHaveBeenCalled(); + }); + + it('should return early and log when action has empty data and empty type', () => { + // GIVEN an action with empty data and type + const buttonId = 'button-1'; + const action = new IterableAction('', '', ''); + + // WHEN handleClick is called + embeddedManager.handleClick(mockEmbeddedMessage, buttonId, action); + + // THEN it should log the error (with empty string since that's what we get from action) + expect(IterableLogger.log).toHaveBeenCalledWith( + 'Iterable.embeddedManager.handleClick:', + 'A url or action is required to handle an embedded click', + '' + ); + + // AND trackClick should not be called + expect(MockRNIterableAPI.trackEmbeddedClick).not.toHaveBeenCalled(); + }); + + it('should handle action:// prefix and call customActionHandler', () => { + // GIVEN an action with action:// prefix and a custom action handler + const buttonId = 'button-1'; + const action = new IterableAction('', 'action://myAction', ''); + const customActionHandler = jest.fn(); + config.customActionHandler = customActionHandler; + + // WHEN handleClick is called + embeddedManager.handleClick(mockEmbeddedMessage, buttonId, action); + + // THEN it should log the click + expect(IterableLogger.log).toHaveBeenCalledWith( + 'Iterable.embeddedManager.handleClick', + mockEmbeddedMessage, + buttonId, + 'action://myAction' + ); + + // AND trackClick should be called + expect(MockRNIterableAPI.trackEmbeddedClick).toHaveBeenCalledWith( + mockEmbeddedMessage, + buttonId, + 'action://myAction' + ); + + // AND customActionHandler should be called with the correct action + expect(customActionHandler).toHaveBeenCalledTimes(1); + const calledAction = customActionHandler.mock.calls[0][0]; + const calledContext = customActionHandler.mock.calls[0][1]; + expect(calledAction.type).toBe('myAction'); + expect(calledContext.source).toBe(3); // IterableActionSource.embedded + }); + + it('should handle itbl:// prefix and call customActionHandler', () => { + // GIVEN an action with itbl:// prefix and a custom action handler + const buttonId = 'button-1'; + const action = new IterableAction('', 'itbl://legacyAction', ''); + const customActionHandler = jest.fn(); + config.customActionHandler = customActionHandler; + + // WHEN handleClick is called + embeddedManager.handleClick(mockEmbeddedMessage, buttonId, action); + + // THEN it should log the click + expect(IterableLogger.log).toHaveBeenCalledWith( + 'Iterable.embeddedManager.handleClick', + mockEmbeddedMessage, + buttonId, + 'itbl://legacyAction' + ); + + // AND trackClick should be called + expect(MockRNIterableAPI.trackEmbeddedClick).toHaveBeenCalledWith( + mockEmbeddedMessage, + buttonId, + 'itbl://legacyAction' + ); + + // AND customActionHandler should be called + expect(customActionHandler).toHaveBeenCalledTimes(1); + const calledAction = customActionHandler.mock.calls[0][0]; + expect(calledAction.type).toBe('legacyAction'); + }); + + it('should not call customActionHandler if action prefix exists but handler is not configured', () => { + // GIVEN an action with action:// prefix but no custom action handler + const buttonId = 'button-1'; + const action = new IterableAction('', 'action://myAction', ''); + + // WHEN handleClick is called + embeddedManager.handleClick(mockEmbeddedMessage, buttonId, action); + + // THEN trackClick should be called + expect(MockRNIterableAPI.trackEmbeddedClick).toHaveBeenCalledWith( + mockEmbeddedMessage, + buttonId, + 'action://myAction' + ); + + // AND customActionHandler should not error (it's undefined) + // Just verify trackClick was called + expect(MockRNIterableAPI.trackEmbeddedClick).toHaveBeenCalledTimes(1); + }); + + it('should handle regular URL and call urlHandler', () => { + // GIVEN a regular URL action and a URL handler + const buttonId = 'button-1'; + const action = new IterableAction('', 'https://example.com', ''); + const urlHandler = jest.fn().mockReturnValue(true); + config.urlHandler = urlHandler; + + // WHEN handleClick is called + embeddedManager.handleClick(mockEmbeddedMessage, buttonId, action); + + // THEN it should log the click + expect(IterableLogger.log).toHaveBeenCalledWith( + 'Iterable.embeddedManager.handleClick', + mockEmbeddedMessage, + buttonId, + 'https://example.com' + ); + + // AND trackClick should be called + expect(MockRNIterableAPI.trackEmbeddedClick).toHaveBeenCalledWith( + mockEmbeddedMessage, + buttonId, + 'https://example.com' + ); + + // AND callUrlHandler should be called + expect(callUrlHandler).toHaveBeenCalledTimes(1); + expect(callUrlHandler).toHaveBeenCalledWith( + config, + 'https://example.com', + expect.objectContaining({ + action: expect.objectContaining({ + type: 'openUrl', + data: 'https://example.com', + }), + source: 3, // IterableActionSource.embedded + }) + ); + }); + + it('should handle regular URL without urlHandler configured', () => { + // GIVEN a regular URL action without a URL handler + const buttonId = 'button-1'; + const action = new IterableAction('', 'https://example.com', ''); + + // WHEN handleClick is called + embeddedManager.handleClick(mockEmbeddedMessage, buttonId, action); + + // THEN trackClick should be called + expect(MockRNIterableAPI.trackEmbeddedClick).toHaveBeenCalledWith( + mockEmbeddedMessage, + buttonId, + 'https://example.com' + ); + + // AND callUrlHandler should be called + expect(callUrlHandler).toHaveBeenCalledTimes(1); + }); + + it('should prefer action.data over action.type when data is available', () => { + // GIVEN an action with both data and type + const buttonId = 'button-1'; + const action = new IterableAction('someType', 'https://example.com', ''); + + // WHEN handleClick is called + embeddedManager.handleClick(mockEmbeddedMessage, buttonId, action); + + // THEN it should use data as clickedUrl + expect(IterableLogger.log).toHaveBeenCalledWith( + 'Iterable.embeddedManager.handleClick', + mockEmbeddedMessage, + buttonId, + 'https://example.com' + ); + + // AND trackClick should be called with the data + expect(MockRNIterableAPI.trackEmbeddedClick).toHaveBeenCalledWith( + mockEmbeddedMessage, + buttonId, + 'https://example.com' + ); + }); + + it('should use action.type when data is empty', () => { + // GIVEN an action with empty data but valid type + const buttonId = 'button-1'; + const action = new IterableAction('https://example.com', '', ''); + + // WHEN handleClick is called + embeddedManager.handleClick(mockEmbeddedMessage, buttonId, action); + + // THEN it should use type as clickedUrl + expect(IterableLogger.log).toHaveBeenCalledWith( + 'Iterable.embeddedManager.handleClick', + mockEmbeddedMessage, + buttonId, + 'https://example.com' + ); + + // AND trackClick should be called with the type + expect(MockRNIterableAPI.trackEmbeddedClick).toHaveBeenCalledWith( + mockEmbeddedMessage, + buttonId, + 'https://example.com' + ); + }); + + it('should handle null buttonId', () => { + // GIVEN an action with null buttonId + const buttonId = null; + const action = new IterableAction('', 'https://example.com', ''); + + // WHEN handleClick is called + embeddedManager.handleClick(mockEmbeddedMessage, buttonId, action); + + // THEN trackClick should be called with null buttonId + expect(MockRNIterableAPI.trackEmbeddedClick).toHaveBeenCalledWith( + mockEmbeddedMessage, + null, + 'https://example.com' + ); + }); + + it('should handle action with undefined action parameter', () => { + // GIVEN no action parameter + const buttonId = 'button-1'; + + // WHEN handleClick is called with undefined action + embeddedManager.handleClick(mockEmbeddedMessage, buttonId, undefined); + + // THEN it should log the error and not track + expect(IterableLogger.log).toHaveBeenCalledWith( + 'Iterable.embeddedManager.handleClick:', + 'A url or action is required to handle an embedded click', + undefined + ); + expect(MockRNIterableAPI.trackEmbeddedClick).not.toHaveBeenCalled(); + }); + }); + + describe('constructor', () => { + it('should initialize with embedded messaging enabled when config flag is true', () => { + // GIVEN a config with embedded messaging enabled + const configWithEnabled = new IterableConfig(); + configWithEnabled.enableEmbeddedMessaging = true; + + // WHEN creating a new embedded manager + const manager = new IterableEmbeddedManager(configWithEnabled); + + // THEN isEnabled should be true + expect(manager.isEnabled).toBe(true); + }); + + it('should initialize with embedded messaging disabled when config flag is false', () => { + // GIVEN a config with embedded messaging disabled + const configWithDisabled = new IterableConfig(); + configWithDisabled.enableEmbeddedMessaging = false; + + // WHEN creating a new embedded manager + const manager = new IterableEmbeddedManager(configWithDisabled); + + // THEN isEnabled should be false + expect(manager.isEnabled).toBe(false); + }); + + it('should initialize with embedded messaging disabled when config flag is undefined', () => { + // GIVEN a config without the flag set + const configWithUndefined = new IterableConfig(); + + // WHEN creating a new embedded manager + const manager = new IterableEmbeddedManager(configWithUndefined); + + // THEN isEnabled should be false (default) + expect(manager.isEnabled).toBe(false); + }); + }); }); diff --git a/src/embedded/classes/IterableEmbeddedManager.ts b/src/embedded/classes/IterableEmbeddedManager.ts index 03013ed40..277fcf819 100644 --- a/src/embedded/classes/IterableEmbeddedManager.ts +++ b/src/embedded/classes/IterableEmbeddedManager.ts @@ -1,4 +1,11 @@ +import { IterableAction } from '../../core/classes/IterableAction'; +import { IterableActionContext } from '../../core/classes/IterableActionContext'; import { IterableApi } from '../../core/classes/IterableApi'; +import { IterableConfig } from '../../core/classes/IterableConfig'; +import { IterableLogger } from '../../core/classes/IterableLogger'; +import { IterableActionSource } from '../../core/enums/IterableActionSource'; +import { callUrlHandler } from '../../core/utils/callUrlHandler'; +import { getActionPrefix } from '../../core/utils/getActionPrefix'; import type { IterableEmbeddedMessage } from '../types/IterableEmbeddedMessage'; /** @@ -40,6 +47,16 @@ export class IterableEmbeddedManager { this._isEnabled = enabled; } + /** + * The config for the Iterable SDK. + */ + private _config: IterableConfig = new IterableConfig(); + + constructor(config: IterableConfig) { + this._config = config; + this._isEnabled = config.enableEmbeddedMessaging ?? false; + } + /** * Syncs embedded local cache with the server. * @@ -174,4 +191,86 @@ export class IterableEmbeddedManager { pauseImpression(messageId: string) { return IterableApi.pauseEmbeddedImpression(messageId); } + + /** + * Tracks a click on an embedded message. + * + * This is called internally when `Iterable.embeddedManager.handleClick` is + * called. However, if you want to implement your own click handling, you can + * use this method to track the click you implement. + * + * @param message - The embedded message. + * @param buttonId - The button ID. + * @param clickedUrl - The clicked URL. + * + * @example + * ```typescript + * Iterable.embeddedManager.trackClick(message, buttonId, clickedUrl); + * ``` + */ + trackClick( + message: IterableEmbeddedMessage, + buttonId: string | null, + clickedUrl: string | null + ) { + return IterableApi.trackEmbeddedClick(message, buttonId, clickedUrl); + } + + /** + * Handles a click on an embedded message. + * + * This will fire the correct handlers set in the config, and will track the + * click. It should be use on either a button click or a click on the message itself. + * + * @param message - The embedded message. + * @param buttonId - The button ID. + * @param clickedUrl - The clicked URL. + * + * @example + * ```typescript + * Iterable.embeddedManager.handleClick(message, buttonId, clickedUrl); + * ``` + */ + handleClick( + message: IterableEmbeddedMessage, + buttonId: string | null, + action?: IterableAction | null + ) { + const { data, type: actionType } = action ?? {}; + const clickedUrl = data && data?.length > 0 ? data : actionType; + + IterableLogger.log( + 'Iterable.embeddedManager.handleClick', + message, + buttonId, + clickedUrl + ); + + if (!clickedUrl) { + IterableLogger.log( + 'Iterable.embeddedManager.handleClick:', + 'A url or action is required to handle an embedded click', + clickedUrl + ); + return; + } + + const actionPrefix = getActionPrefix(clickedUrl); + const source = IterableActionSource.embedded; + + this.trackClick(message, buttonId, clickedUrl); + + if (actionPrefix) { + const actionName = clickedUrl?.replace(actionPrefix, ''); + const actionDetails = new IterableAction(actionName, '', ''); + const context = new IterableActionContext(actionDetails, source); + if (this._config.customActionHandler) { + this._config.customActionHandler(actionDetails, context); + } + } else { + const actionDetails = new IterableAction('openUrl', clickedUrl, ''); + const context = new IterableActionContext(actionDetails, source); + callUrlHandler(this._config, clickedUrl, context); + } + } }