diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index 98340a1f7..16447ac65 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -15,6 +15,7 @@ import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.UiThreadUtil; +import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; import com.facebook.react.modules.core.DeviceEventManagerModule; @@ -23,11 +24,12 @@ import com.iterable.iterableapi.IterableAction; import com.iterable.iterableapi.IterableActionContext; import com.iterable.iterableapi.IterableApi; +import com.iterable.iterableapi.IterableAttributionInfo; import com.iterable.iterableapi.IterableAuthHandler; import com.iterable.iterableapi.IterableAuthManager; import com.iterable.iterableapi.IterableConfig; import com.iterable.iterableapi.IterableCustomActionHandler; -import com.iterable.iterableapi.IterableAttributionInfo; +import com.iterable.iterableapi.IterableEmbeddedMessage; import com.iterable.iterableapi.IterableHelper; import com.iterable.iterableapi.IterableInAppCloseAction; import com.iterable.iterableapi.IterableInAppHandler; @@ -46,6 +48,7 @@ import java.util.Map; import java.util.HashMap; import java.util.List; +import java.util.ArrayList; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -122,6 +125,7 @@ public void initializeWithApiKey(String apiKey, ReadableMap configReadableMap, S IterableApi.getInstance().setDeviceAttribute("reactNativeSDKVersion", version); IterableApi.getInstance().getInAppManager().addListener(this); + IterableApi.getInstance().getEmbeddedManager().syncMessages(); // MOB-10421: Figure out what the error cases are and handle them appropriately // This is just here to match the TS types and let the JS thread know when we are done initializing @@ -185,6 +189,7 @@ public void initialize2WithApiKey(String apiKey, ReadableMap configReadableMap, IterableApi.getInstance().setDeviceAttribute("reactNativeSDKVersion", version); IterableApi.getInstance().getInAppManager().addListener(this); + IterableApi.getInstance().getEmbeddedManager().syncMessages(); // MOB-10421: Figure out what the error cases are and handle them appropriately // This is just here to match the TS types and let the JS thread know when we are done initializing @@ -683,14 +688,110 @@ public void sendEvent(@NonNull String eventName, @Nullable Object eventData) { public void onInboxUpdated() { sendEvent(EventName.receivedIterableInboxChanged.name(), null); } + // --------------------------------------------------------------------------------------- + // endregion + + // --------------------------------------------------------------------------------------- + // region Embedded messaging + + public void syncEmbeddedMessages() { + IterableLogger.d(TAG, "syncEmbeddedMessages"); + IterableApi.getInstance().getEmbeddedManager().syncMessages(); + } + + public void startEmbeddedSession() { + IterableLogger.d(TAG, "startEmbeddedSession"); + IterableApi.getInstance().getEmbeddedManager().getEmbeddedSessionManager().startSession(); + } + + public void endEmbeddedSession() { + IterableLogger.d(TAG, "endEmbeddedSession"); + IterableApi.getInstance().getEmbeddedManager().getEmbeddedSessionManager().endSession(); + } + + public void startEmbeddedImpression(String messageId, int placementId) { + IterableLogger.d(TAG, "startEmbeddedImpression"); + IterableApi.getInstance().getEmbeddedManager().getEmbeddedSessionManager().startImpression(messageId, placementId); + } + + public void pauseEmbeddedImpression(String messageId) { + IterableLogger.d(TAG, "pauseEmbeddedImpression"); + IterableApi.getInstance().getEmbeddedManager().getEmbeddedSessionManager().pauseImpression(messageId); + } + + public void getEmbeddedPlacementIds(Promise promise) { + IterableLogger.d(TAG, "getEmbeddedPlacementIds"); + try { + List placementIds = IterableApi.getInstance().getEmbeddedManager().getPlacementIds(); + WritableArray writableArray = Arguments.createArray(); + if (placementIds != null) { + for (Long placementId : placementIds) { + writableArray.pushDouble(placementId.doubleValue()); + } + } + promise.resolve(writableArray); + } catch (Exception e) { + IterableLogger.e(TAG, "Error getting placement IDs: " + e.getLocalizedMessage()); + promise.reject("", "Failed to get placement IDs: " + e.getLocalizedMessage()); + } + } + + public void getEmbeddedMessages(@Nullable ReadableArray placementIds, Promise promise) { + IterableLogger.d(TAG, "getEmbeddedMessages for placements: " + placementIds); + + try { + List allMessages = new ArrayList<>(); + + if (placementIds == null || placementIds.size() == 0) { + // If no placement IDs provided, we need to get messages for all possible placements + // Since the Android SDK requires a placement ID, we'll use 0 as a default + // This might need to be adjusted based on the actual SDK behavior + List messages = IterableApi.getInstance().getEmbeddedManager().getMessages(0L); + if (messages != null) { + allMessages.addAll(messages); + } + } else { + // Convert ReadableArray to individual placement IDs and get messages for each + for (int i = 0; i < placementIds.size(); i++) { + long placementId = placementIds.getInt(i); + List messages = IterableApi.getInstance().getEmbeddedManager().getMessages(placementId); + if (messages != null) { + allMessages.addAll(messages); + } + } + } + + JSONArray embeddedMessageJsonArray = Serialization.serializeEmbeddedMessages(allMessages); + IterableLogger.d(TAG, "Messages for placements: " + embeddedMessageJsonArray); + + promise.resolve(Serialization.convertJsonToArray(embeddedMessageJsonArray)); + } catch (JSONException e) { + IterableLogger.e(TAG, e.getLocalizedMessage()); + promise.reject("", "Failed to fetch messages with error " + e.getLocalizedMessage()); + } + } + + 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 } enum EventName { - handleUrlCalled, - handleCustomActionCalled, - handleInAppCalled, handleAuthCalled, - receivedIterableInboxChanged, + handleAuthFailureCalled, handleAuthSuccessCalled, - handleAuthFailureCalled + handleCustomActionCalled, + handleInAppCalled, + handleUrlCalled, + receivedIterableEmbeddedMessagesChanged, + receivedIterableInboxChanged } diff --git a/android/src/main/java/com/iterable/reactnative/Serialization.java b/android/src/main/java/com/iterable/reactnative/Serialization.java index 92c549554..97aa52f49 100644 --- a/android/src/main/java/com/iterable/reactnative/Serialization.java +++ b/android/src/main/java/com/iterable/reactnative/Serialization.java @@ -16,6 +16,7 @@ import com.iterable.iterableapi.IterableActionContext; import com.iterable.iterableapi.IterableConfig; import com.iterable.iterableapi.IterableDataRegion; +import com.iterable.iterableapi.IterableEmbeddedMessage; import com.iterable.iterableapi.IterableInAppCloseAction; import com.iterable.iterableapi.IterableInAppDeleteActionType; import com.iterable.iterableapi.IterableInAppHandler; @@ -23,8 +24,8 @@ import com.iterable.iterableapi.IterableInAppMessage; import com.iterable.iterableapi.IterableInboxSession; import com.iterable.iterableapi.IterableLogger; -import com.iterable.iterableapi.RNIterableInternal; import com.iterable.iterableapi.RetryPolicy; +import com.iterable.iterableapi.RNIterableInternal; import org.json.JSONArray; import org.json.JSONException; @@ -137,6 +138,33 @@ static JSONArray serializeInAppMessages(List inAppMessages return inAppMessagesJson; } + static JSONArray serializeEmbeddedMessages(List embeddedMessages) { + JSONArray embeddedMessagesJson = new JSONArray(); + if (embeddedMessages != null) { + for (IterableEmbeddedMessage message : embeddedMessages) { + JSONObject messageJson = IterableEmbeddedMessage.Companion.toJSONObject(message); + embeddedMessagesJson.put(messageJson); + } + } + 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); @@ -218,6 +246,10 @@ static IterableConfig.Builder getConfigFromReadableMap(ReadableMap iterableConte configBuilder.setDataRegion(iterableDataRegion); } + if (iterableContextJSON.has("enableEmbeddedMessaging")) { + configBuilder.setEnableEmbeddedMessaging(iterableContextJSON.optBoolean("enableEmbeddedMessaging")); + } + if (iterableContextJSON.has("retryPolicy")) { JSONObject retryPolicyJson = iterableContextJSON.getJSONObject("retryPolicy"); int maxRetry = retryPolicyJson.getInt("maxRetry"); diff --git a/android/src/newarch/java/com/RNIterableAPIModule.java b/android/src/newarch/java/com/RNIterableAPIModule.java index f145bab10..056a5649d 100644 --- a/android/src/newarch/java/com/RNIterableAPIModule.java +++ b/android/src/newarch/java/com/RNIterableAPIModule.java @@ -224,6 +224,46 @@ public void pauseAuthRetries(boolean pauseRetry) { moduleImpl.pauseAuthRetries(pauseRetry); } + @Override + public void syncEmbeddedMessages() { + moduleImpl.syncEmbeddedMessages(); + } + + @Override + public void startEmbeddedSession() { + moduleImpl.startEmbeddedSession(); + } + + @Override + public void endEmbeddedSession() { + moduleImpl.endEmbeddedSession(); + } + + @Override + public void startEmbeddedImpression(String messageId, double placementId) { + moduleImpl.startEmbeddedImpression(messageId, (int) placementId); + } + + @Override + public void pauseEmbeddedImpression(String messageId) { + moduleImpl.pauseEmbeddedImpression(messageId); + } + + @Override + public void getEmbeddedPlacementIds(Promise promise) { + moduleImpl.getEmbeddedPlacementIds(promise); + } + + @Override + public void getEmbeddedMessages(@Nullable ReadableArray placementIds, Promise promise) { + 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 c3a72339b..f387ed681 100644 --- a/android/src/oldarch/java/com/RNIterableAPIModule.java +++ b/android/src/oldarch/java/com/RNIterableAPIModule.java @@ -228,6 +228,45 @@ public void pauseAuthRetries(boolean pauseRetry) { moduleImpl.pauseAuthRetries(pauseRetry); } + @ReactMethod + public void syncEmbeddedMessages() { + moduleImpl.syncEmbeddedMessages(); + } + + @ReactMethod + public void startEmbeddedSession() { + moduleImpl.startEmbeddedSession(); + } + + @ReactMethod + public void endEmbeddedSession() { + moduleImpl.endEmbeddedSession(); + } + + @ReactMethod + public void startEmbeddedImpression(String messageId, double placementId) { + moduleImpl.startEmbeddedImpression(messageId, (int) placementId); + } + + @ReactMethod + public void pauseEmbeddedImpression(String messageId) { + moduleImpl.pauseEmbeddedImpression(messageId); + } + + @ReactMethod + public void getEmbeddedPlacementIds(Promise promise) { + moduleImpl.getEmbeddedPlacementIds(promise); + } + + @ReactMethod + public void getEmbeddedMessages(@Nullable ReadableArray placementIds, Promise promise) { + 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/App/App.constants.ts b/example/src/components/App/App.constants.ts index 65bf314e6..84f8cf78f 100644 --- a/example/src/components/App/App.constants.ts +++ b/example/src/components/App/App.constants.ts @@ -12,8 +12,11 @@ export const personIcon = export const mailIcon = 'data:image/png;base64,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'; + const chatIcon = 'data:image/png;base64,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' + export const routeIcon = { [Route.Commerce]: cashIcon, + [Route.Embedded]: chatIcon, [Route.Inbox]: mailIcon, [Route.User]: personIcon, }; diff --git a/example/src/components/App/App.tsx b/example/src/components/App/App.tsx index 42769db1d..d71b9fe95 100644 --- a/example/src/components/App/App.tsx +++ b/example/src/components/App/App.tsx @@ -1,16 +1,59 @@ +/* eslint-disable react-native/split-platform-components */ import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { useEffect } from 'react'; +import { PermissionsAndroid, Platform } from 'react-native'; import { Route } from '../../constants/routes'; import { useIterableApp } from '../../hooks/useIterableApp'; +import type { RootStackParamList } from '../../types'; import { Login } from '../Login'; import { Main } from './Main'; -import type { RootStackParamList } from '../../types'; const Stack = createNativeStackNavigator(); +const requestNotificationPermission = async () => { + if (Platform.OS === 'android') { + const apiLevel = Platform.Version; // Get the Android API level + + if (apiLevel >= 33) { + // Check if Android 13 or higher + try { + const granted = await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS, + { + title: 'Notification Permission', + message: + 'This app needs access to your notifications for push, in-app messages, embedded messages and more.', + buttonNeutral: 'Ask Me Later', + buttonNegative: 'Cancel', + buttonPositive: 'OK', + } + ); + if (granted === PermissionsAndroid.RESULTS.GRANTED) { + console.log('Notification permission granted'); + } else { + console.log('Notification permission denied'); + } + } catch (err) { + console.warn(err); + } + } else { + // For Android versions below 13, notification permission is generally not required + // or is automatically granted upon app installation. + console.log( + 'Notification permission not required for this Android version.' + ); + } + } +}; + export const App = () => { const { isLoggedIn } = useIterableApp(); + useEffect(() => { + requestNotificationPermission(); + }, []); + return ( {isLoggedIn ? ( diff --git a/example/src/components/App/Main.tsx b/example/src/components/App/Main.tsx index b944801fe..956973149 100644 --- a/example/src/components/App/Main.tsx +++ b/example/src/components/App/Main.tsx @@ -8,6 +8,7 @@ import { User } from '../User'; import { Inbox } from '../Inbox'; import { useIterableApp } from '../../hooks'; import { Commerce } from '../Commerce'; +import { Embedded } from '../Embedded'; const Tab = createBottomTabNavigator(); @@ -44,6 +45,13 @@ export const Main = () => { }, })} /> + ({ + tabPress: () => setIsInboxTab(false), + })} + /> { }; return ( - - - Commerce - - Purchase will be tracked when "Buy" is clicked. See logs for - output. - - {items.map((item) => ( - - - - - - - {item.name} - {item.subtitle} - ${item.price} - handleClick(item)} - > - Buy - + + + + Commerce + + Purchase will be tracked when "Buy" is clicked. See logs for + output. + + {items.map((item) => ( + + + + + + + {item.name} + {item.subtitle} + ${item.price} + handleClick(item)} + > + Buy + + - - ))} - - + ))} + + + ); }; diff --git a/example/src/components/Embedded/Embedded.styles.ts b/example/src/components/Embedded/Embedded.styles.ts new file mode 100644 index 000000000..56241c676 --- /dev/null +++ b/example/src/components/Embedded/Embedded.styles.ts @@ -0,0 +1,31 @@ +import { StyleSheet } from 'react-native'; +import { button, buttonText, container, hr, link } from '../../constants'; + +const styles = StyleSheet.create({ + button, + buttonText, + container: { ...container, paddingHorizontal: 0 }, + embeddedSection: { + display: 'flex', + flexDirection: 'column', + gap: 16, + paddingHorizontal: 16, + }, + embeddedTitle: { + fontSize: 16, + fontWeight: 'bold', + lineHeight: 20, + }, + embeddedTitleContainer: { + display: 'flex', + flexDirection: 'row', + }, + hr, + link, + text: { textAlign: 'center' }, + utilitySection: { + paddingHorizontal: 16, + }, +}); + +export default styles; diff --git a/example/src/components/Embedded/Embedded.tsx b/example/src/components/Embedded/Embedded.tsx new file mode 100644 index 000000000..785d722d6 --- /dev/null +++ b/example/src/components/Embedded/Embedded.tsx @@ -0,0 +1,186 @@ +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'; + +import styles from './Embedded.styles'; + +export const Embedded = () => { + const [placementIds, setPlacementIds] = useState([]); + const [embeddedMessages, setEmbeddedMessages] = useState< + IterableEmbeddedMessage[] + >([]); + + const syncEmbeddedMessages = useCallback(() => { + Iterable.embeddedManager.syncMessages(); + }, []); + + const getPlacementIds = useCallback(() => { + return Iterable.embeddedManager.getPlacementIds().then((ids: unknown) => { + console.log(ids); + setPlacementIds(ids as number[]); + return ids; + }); + }, []); + + const startEmbeddedSession = useCallback(() => { + console.log( + 'startEmbeddedSession --> check android/ios logs to check if it worked' + ); + Iterable.embeddedManager.startSession(); + }, []); + + const endEmbeddedSession = useCallback(() => { + console.log( + 'endEmbeddedSession --> check android/ios logs to check if it worked' + ); + Iterable.embeddedManager.endSession(); + }, []); + + const getEmbeddedMessages = useCallback(() => { + getPlacementIds() + .then((ids: number[]) => Iterable.embeddedManager.getMessages(ids)) + .then((messages: IterableEmbeddedMessage[]) => { + setEmbeddedMessages(messages); + console.log(messages); + }); + }, [getPlacementIds]); + + const startEmbeddedImpression = useCallback( + (message: IterableEmbeddedMessage) => { + console.log(`startEmbeddedImpression`, message); + Iterable.embeddedManager.startImpression( + message.metadata.messageId, + // TODO: check if this should be changed to a number, as per the type + Number(message.metadata.placementId) + ); + }, + [] + ); + + const pauseEmbeddedImpression = useCallback( + (message: IterableEmbeddedMessage) => { + console.log(`pauseEmbeddedImpression:`, message); + Iterable.embeddedManager.pauseImpression(message.metadata.messageId); + }, + [] + ); + + const handleClick = useCallback( + ( + message: IterableEmbeddedMessage, + buttonId: string | null, + action?: IterableAction | null + ) => { + console.log(`handleClick:`, message); + Iterable.embeddedManager.handleClick(message, buttonId, action); + }, + [] + ); + + return ( + + EMBEDDED + + + Does embedded class exist? {Iterable.embeddedManager ? 'Yes' : 'No'} + + + Is embedded manager enabled?{' '} + {Iterable.embeddedManager.isEnabled ? 'Yes' : 'No'} + + + Placement ids: [{placementIds.join(', ')}] + + + Sync messages + + + Get placement ids + + + Start session + + + End session + + + Get messages + + + + + + {embeddedMessages.map((message) => ( + + + Embedded message + + + startEmbeddedImpression(message)} + > + Start impression + + | + pauseEmbeddedImpression(message)} + > + 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} + | + + handleClick(message, button.id, button.action) + } + > + Handle click + + + + button.id: {button.id} + button.title: {button.title} + button.action?.data: {button.action?.data} + button.action?.type: {button.action?.type} + + ))} + payload: {JSON.stringify(message.payload)} + + ))} + + + + ); +}; + +export default Embedded; diff --git a/example/src/components/Embedded/index.ts b/example/src/components/Embedded/index.ts new file mode 100644 index 000000000..f608dd476 --- /dev/null +++ b/example/src/components/Embedded/index.ts @@ -0,0 +1 @@ +export * from './Embedded'; diff --git a/example/src/components/Login/Login.tsx b/example/src/components/Login/Login.tsx index 9712792ed..988e9c8db 100644 --- a/example/src/components/Login/Login.tsx +++ b/example/src/components/Login/Login.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { ActivityIndicator, Pressable, @@ -5,7 +6,7 @@ import { TextInput, View, } from 'react-native'; -import { useMemo } from 'react'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { colors, type Route } from '../../constants'; import { useIterableApp } from '../../hooks'; @@ -18,7 +19,7 @@ export const Login = ({ navigation }: RootStackScreenProps) => { const loginIsEnabled = useMemo(() => apiKey && userId, [apiKey, userId]); return ( - + {loginInProgress ? ( @@ -66,7 +67,7 @@ export const Login = ({ navigation }: RootStackScreenProps) => { )} - + ); }; diff --git a/example/src/components/User/User.tsx b/example/src/components/User/User.tsx index 23f8361a5..4f3ee5be1 100644 --- a/example/src/components/User/User.tsx +++ b/example/src/components/User/User.tsx @@ -1,6 +1,7 @@ import { Iterable } from '@iterable/react-native-sdk'; import { useEffect, useState } from 'react'; -import { Text, TouchableOpacity, View } from 'react-native'; +import { Text, TouchableOpacity } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; import { useIterableApp } from '../../hooks'; import styles from './User.styles'; @@ -18,13 +19,13 @@ export const User = () => { }, [isLoggedIn]); return ( - + Welcome Iterator Logged in as {loggedInAs} Logout - + ); }; diff --git a/example/src/constants/routes.ts b/example/src/constants/routes.ts index 4af27c548..c2087cacf 100644 --- a/example/src/constants/routes.ts +++ b/example/src/constants/routes.ts @@ -1,5 +1,6 @@ export enum Route { Commerce = 'Commerce', + Embedded = 'Embedded', Inbox = 'Inbox', Login = 'Login', Main = 'Main', diff --git a/example/src/constants/styles/index.ts b/example/src/constants/styles/index.ts index 794f9680c..b8c3bac5e 100644 --- a/example/src/constants/styles/index.ts +++ b/example/src/constants/styles/index.ts @@ -1,5 +1,6 @@ export * from './colors'; export * from './containers'; export * from './formElements'; +export * from './miscElements'; export * from './shadows'; export * from './typography'; diff --git a/example/src/constants/styles/miscElements.ts b/example/src/constants/styles/miscElements.ts new file mode 100644 index 000000000..470605ea7 --- /dev/null +++ b/example/src/constants/styles/miscElements.ts @@ -0,0 +1,9 @@ +import { type ViewStyle } from 'react-native'; +import { colors } from './colors'; + +export const hr: ViewStyle = { + backgroundColor: colors.borderPrimary, + height: 1, + marginVertical: 20, + marginHorizontal: 0, +}; diff --git a/example/src/constants/styles/typography.ts b/example/src/constants/styles/typography.ts index 09b6c2405..d28b2f328 100644 --- a/example/src/constants/styles/typography.ts +++ b/example/src/constants/styles/typography.ts @@ -6,7 +6,6 @@ export const appName: TextStyle = { fontWeight: 'bold', fontSize: 14, width: '100%', - marginTop: 41, marginBottom: 64, textTransform: 'uppercase', letterSpacing: 2, @@ -57,3 +56,8 @@ export const requiredStar: TextStyle = { ...label, color: colors.textDestructive, }; + +export const link: TextStyle = { + color: colors.textInteractive, + textDecorationLine: 'underline', +}; diff --git a/example/src/hooks/useIterableApp.tsx b/example/src/hooks/useIterableApp.tsx index 0022fdb4c..0affc08ce 100644 --- a/example/src/hooks/useIterableApp.tsx +++ b/example/src/hooks/useIterableApp.tsx @@ -125,7 +125,7 @@ export const IterableAppProvider: FunctionComponent< return jwtToken; }, [userId]); - const login = useCallback(() => { + const login = useCallback(async () => { const id = userId ?? process.env.ITBL_ID; if (!id) return Promise.reject('No User ID or Email set'); @@ -134,12 +134,18 @@ export const IterableAppProvider: FunctionComponent< const fn = getIsEmail(id) ? Iterable.setEmail : Iterable.setUserId; - fn(id); + let token; + + if (process.env.ITBL_IS_JWT_ENABLED === 'true' && process.env.ITBL_JWT_SECRET) { + token = await getJwtToken(); + } + + fn(id, token); setIsLoggedIn(true); setLoginInProgress(false); return Promise.resolve(true); - }, [userId]); + }, [getJwtToken, userId]); const initialize = useCallback( (navigation: Navigation) => { @@ -155,6 +161,8 @@ export const IterableAppProvider: FunctionComponent< retryBackoff: IterableRetryBackoff.linear, }; + config.enableEmbeddedMessaging = true; + config.onJwtError = (authFailure) => { console.log('onJwtError', authFailure); @@ -192,6 +200,8 @@ export const IterableAppProvider: FunctionComponent< config.logLevel = IterableLogLevel.debug; + config.enableEmbeddedMessaging = true; + config.inAppHandler = () => IterableInAppShowResponse.show; if ( diff --git a/example/src/types/navigation.ts b/example/src/types/navigation.ts index 5b5ad8a50..e3038d088 100644 --- a/example/src/types/navigation.ts +++ b/example/src/types/navigation.ts @@ -10,6 +10,7 @@ import { Route } from '../constants/routes'; export type MainScreenParamList = { [Route.Commerce]: undefined; [Route.Inbox]: undefined; + [Route.Embedded]: undefined; [Route.User]: undefined; }; diff --git a/src/__mocks__/MockRNIterableAPI.ts b/src/__mocks__/MockRNIterableAPI.ts index 4094afcb0..b79eebce9 100644 --- a/src/__mocks__/MockRNIterableAPI.ts +++ b/src/__mocks__/MockRNIterableAPI.ts @@ -141,6 +141,49 @@ export class MockRNIterableAPI { static updateVisibleRows = jest.fn(); + static startEmbeddedSession = jest.fn(); + + static endEmbeddedSession = jest.fn(); + + static getEmbeddedPlacementIds = jest + .fn() + .mockResolvedValue([1, 2, 3] as number[]); + + static syncEmbeddedMessages = jest.fn().mockResolvedValue(undefined); + + static getEmbeddedMessages = jest.fn().mockResolvedValue([ + { + metadata: { + messageId: 'msg-1', + campaignId: 123, + placementId: 1, + }, + elements: { + title: 'Test Message 1', + body: 'Test body 1', + }, + payload: { customKey: 'customValue' }, + }, + { + metadata: { + messageId: 'msg-2', + campaignId: 456, + placementId: 2, + }, + elements: { + title: 'Test Message 2', + body: 'Test body 2', + }, + payload: null, + }, + ]); + + static startEmbeddedImpression = jest.fn(); + + 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 391fadbb7..ed1590528 100644 --- a/src/api/NativeRNIterableAPI.ts +++ b/src/api/NativeRNIterableAPI.ts @@ -1,6 +1,33 @@ import type { TurboModule } from 'react-native'; import { TurboModuleRegistry } from 'react-native'; +// NOTE: No types can be imported because of the way new arch works, so we have +// to re-define the types here. +interface EmbeddedMessage { + metadata: { + messageId: string; + placementId: number; + campaignId?: number | null; + isProof?: boolean; + }; + elements: { + buttons?: + | { + id: string; + title?: string | null; + action: { type: string; data?: string } | null; + }[] + | null; + body?: string | null; + mediaUrl?: string | null; + mediaUrlCaption?: string | null; + defaultAction?: { type: string; data?: string } | null; + text?: { id: string; text?: string | null; label?: string | null }[] | null; + title?: string | null; + } | null; + payload?: { [key: string]: string | number | boolean | null } | null; +} + export interface Spec extends TurboModule { // Initialization initializeWithApiKey( @@ -118,6 +145,22 @@ export interface Spec extends TurboModule { passAlongAuthToken(authToken?: string | null): void; pauseAuthRetries(pauseRetry: boolean): void; + // Embedded Messaging + syncEmbeddedMessages(): void; + startEmbeddedSession(): void; + endEmbeddedSession(): void; + startEmbeddedImpression(messageId: string, placementId: number): void; + pauseEmbeddedImpression(messageId: string): void; + getEmbeddedPlacementIds(): Promise; + 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.test.ts b/src/core/classes/Iterable.test.ts index bfe4c26f6..b0a789f21 100644 --- a/src/core/classes/Iterable.test.ts +++ b/src/core/classes/Iterable.test.ts @@ -300,37 +300,39 @@ describe('Iterable', () => { // WHEN config is initialized const config = new IterableConfig(); // THEN config has default values - expect(config.pushIntegrationName).toBe(undefined); + expect(config.allowedProtocols).toEqual([]); + expect(config.androidSdkUseInMemoryStorageForInApps).toBe(false); + expect(config.authHandler).toBe(undefined); expect(config.autoPushRegistration).toBe(true); expect(config.checkForDeferredDeeplink).toBe(false); - expect(config.inAppDisplayInterval).toBe(30.0); - expect(config.urlHandler).toBe(undefined); expect(config.customActionHandler).toBe(undefined); + expect(config.dataRegion).toBe(IterableDataRegion.US); + expect(config.enableEmbeddedMessaging).toBe(false); + expect(config.encryptionEnforced).toBe(false); + expect(config.expiringAuthTokenRefreshPeriod).toBe(60.0); + expect(config.inAppDisplayInterval).toBe(30.0); expect(config.inAppHandler).toBe(undefined); - expect(config.authHandler).toBe(undefined); expect(config.logLevel).toBe(IterableLogLevel.debug); expect(config.logReactNativeSdkCalls).toBe(true); - expect(config.expiringAuthTokenRefreshPeriod).toBe(60.0); - expect(config.allowedProtocols).toEqual([]); - expect(config.androidSdkUseInMemoryStorageForInApps).toBe(false); + expect(config.pushIntegrationName).toBe(undefined); + expect(config.urlHandler).toBe(undefined); expect(config.useInMemoryStorageForInApps).toBe(false); - expect(config.dataRegion).toBe(IterableDataRegion.US); - expect(config.encryptionEnforced).toBe(false); const configDict = config.toDict(); - expect(configDict.pushIntegrationName).toBe(undefined); + expect(configDict.allowedProtocols).toEqual([]); + expect(configDict.androidSdkUseInMemoryStorageForInApps).toBe(false); + expect(configDict.authHandlerPresent).toBe(false); expect(configDict.autoPushRegistration).toBe(true); - expect(configDict.inAppDisplayInterval).toBe(30.0); - expect(configDict.urlHandlerPresent).toBe(false); expect(configDict.customActionHandlerPresent).toBe(false); + expect(configDict.dataRegion).toBe(IterableDataRegion.US); + expect(configDict.enableEmbeddedMessaging).toBe(false); + expect(configDict.encryptionEnforced).toBe(false); + expect(configDict.expiringAuthTokenRefreshPeriod).toBe(60.0); + expect(configDict.inAppDisplayInterval).toBe(30.0); expect(configDict.inAppHandlerPresent).toBe(false); - expect(configDict.authHandlerPresent).toBe(false); expect(configDict.logLevel).toBe(IterableLogLevel.debug); - expect(configDict.expiringAuthTokenRefreshPeriod).toBe(60.0); - expect(configDict.allowedProtocols).toEqual([]); - expect(configDict.androidSdkUseInMemoryStorageForInApps).toBe(false); + expect(configDict.pushIntegrationName).toBe(undefined); + expect(configDict.urlHandlerPresent).toBe(false); expect(configDict.useInMemoryStorageForInApps).toBe(false); - expect(configDict.dataRegion).toBe(IterableDataRegion.US); - expect(configDict.encryptionEnforced).toBe(false); }); }); @@ -1212,4 +1214,19 @@ describe('Iterable', () => { }); }); }); + + describe('embeddedManager', () => { + it('should be disabled by default', () => { + const config = new IterableConfig(); + expect(config.enableEmbeddedMessaging).toBe(false); + expect(Iterable.embeddedManager.isEnabled).toBe(false); + }); + + it('should enable embeddedManager when config is set', async () => { + const config = new IterableConfig(); + config.enableEmbeddedMessaging = true; + await Iterable.initialize('test-key', config); + expect(Iterable.embeddedManager.isEnabled).toBe(true); + }); + }); }); diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index 6b9431fc6..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'; @@ -23,6 +25,8 @@ import { IterableLogger } from './IterableLogger'; const RNEventEmitter = new NativeEventEmitter(RNIterableAPI); +const defaultConfig = new IterableConfig(); + /** * Checks if the response is an IterableAuthResponse */ @@ -62,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. @@ -96,6 +100,28 @@ export class Iterable { */ static authManager: IterableAuthManager = new IterableAuthManager(); + /** + * Embedded message manager for the current user. + * + * This property provides access to embedded message functionality including + * retrieving messages, displaying messages, removing messages, and more. + * + * **Documentation** + * - [Embedded Messaging Overview](https://support.iterable.com/hc/en-us/articles/23060529977364-Embedded-Messaging-Overview) + * - [Android Embedded Messaging](https://support.iterable.com/hc/en-us/articles/23061877893652-Embedded-Messages-with-Iterable-s-Android-SDK) + * - [iOS Embedded Messaging](https://support.iterable.com/hc/en-us/articles/23061840746900-Embedded-Messages-with-Iterable-s-iOS-SDK) + * + * @example + * ```typescript + * Iterable.embeddedManager.getMessages().then(messages => { + * console.log('Messages:', messages); + * }); + * ``` + */ + static embeddedManager: IterableEmbeddedManager = new IterableEmbeddedManager( + defaultConfig + ); + /** * Initializes the Iterable React Native SDK in your app's Javascript or Typescript code. * @@ -172,6 +198,8 @@ export class Iterable { IterableLogger.setLoggingEnabled(config.logReactNativeSdkCalls ?? true); IterableLogger.setLogLevel(config.logLevel); + + Iterable.embeddedManager = new IterableEmbeddedManager(config); } this.setupEventHandlers(); @@ -957,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); } }); } @@ -1055,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 fe2b446a3..09d0dc44f 100644 --- a/src/core/classes/IterableApi.ts +++ b/src/core/classes/IterableApi.ts @@ -12,6 +12,7 @@ import { IterableAttributionInfo } from './IterableAttributionInfo'; import type { IterableCommerceItem } from './IterableCommerceItem'; import { IterableConfig } from './IterableConfig'; import { IterableLogger } from './IterableLogger'; +import type { IterableEmbeddedMessage } from '../../embedded/types/IterableEmbeddedMessage'; /** * Contains functions that directly interact with the native layer. @@ -507,7 +508,85 @@ export class IterableApi { // ---- End IN-APP ---- // // ====================================================== // - // ======================= MOSC ======================= // + // ======================= EMBEDDED ===================== // + // ====================================================== // + + /** + * Syncs embedded local cache with the server. + */ + static syncEmbeddedMessages() { + IterableLogger.log('syncEmbeddedMessages'); + return RNIterableAPI.syncEmbeddedMessages(); + } + + /** + * Starts an embedded session. + */ + static startEmbeddedSession() { + IterableLogger.log('startEmbeddedSession'); + return RNIterableAPI.startEmbeddedSession(); + } + + /** + * Ends an embedded session. + */ + static endEmbeddedSession() { + IterableLogger.log('endEmbeddedSession'); + return RNIterableAPI.endEmbeddedSession(); + } + + /** + * Starts an embedded impression. + */ + static startEmbeddedImpression(messageId: string, placementId: number) { + IterableLogger.log('startEmbeddedImpression: ', messageId, placementId); + return RNIterableAPI.startEmbeddedImpression(messageId, placementId); + } + + /** + * Pauses an embedded impression. + */ + static pauseEmbeddedImpression(messageId: string) { + IterableLogger.log('pauseEmbeddedImpression: ', messageId); + return RNIterableAPI.pauseEmbeddedImpression(messageId); + } + + /** + * Get the embedded placement IDs. + */ + static getEmbeddedPlacementIds() { + IterableLogger.log('getEmbeddedPlacementIds'); + return RNIterableAPI.getEmbeddedPlacementIds(); + } + + /** + * Get the embedded messages. + * + * @returns A Promise that resolves to an array of embedded messages. + */ + static getEmbeddedMessages( + placementIds: number[] | null + ): Promise { + IterableLogger.log('getEmbeddedMessages: ', placementIds); + 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 ---- // + + // ====================================================== // + // ======================= MISCELLANEOUS ================ // // ====================================================== // /** diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index 664e08f1f..aeebfb91e 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -329,6 +329,16 @@ export class IterableConfig { */ encryptionEnforced = false; + /** + * Should the SDK enable and use embedded messaging? + * + * **Documentation** + * - [Embedded Messaging Overview](https://support.iterable.com/hc/en-us/articles/23060529977364-Embedded-Messaging-Overview) + * - [Android Embedded Messaging](https://support.iterable.com/hc/en-us/articles/23061877893652-Embedded-Messages-with-Iterable-s-Android-SDK) + * - [iOS Embedded Messaging](https://support.iterable.com/hc/en-us/articles/23061840746900-Embedded-Messages-with-Iterable-s-iOS-SDK) + */ + enableEmbeddedMessaging = false; + /** * Converts the IterableConfig instance to a dictionary object. * @@ -378,6 +388,7 @@ export class IterableConfig { pushPlatform: this.pushPlatform, encryptionEnforced: this.encryptionEnforced, retryPolicy: this.retryPolicy, + enableEmbeddedMessaging: this.enableEmbeddedMessaging, }; } } 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 new file mode 100644 index 000000000..a00a2e448 --- /dev/null +++ b/src/embedded/classes/IterableEmbeddedManager.test.ts @@ -0,0 +1,677 @@ +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 +jest.mock('../../api', () => ({ + __esModule: true, + 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(() => { + config = new IterableConfig(); + embeddedManager = new IterableEmbeddedManager(config); + jest.clearAllMocks(); + }); + + describe('isEnabled', () => { + it('should be false by default', () => { + expect(embeddedManager.isEnabled).toBe(false); + }); + + it('should return true after being enabled', () => { + embeddedManager.setEnabled(true); + expect(embeddedManager.isEnabled).toBe(true); + }); + + it('should return false after being disabled', () => { + embeddedManager.setEnabled(false); + expect(embeddedManager.isEnabled).toBe(false); + }); + }); + + describe('setEnabled', () => { + it('should enable the embedded manager', () => { + embeddedManager.setEnabled(true); + expect(embeddedManager.isEnabled).toBe(true); + }); + + it('should disable the embedded manager', () => { + embeddedManager.setEnabled(false); + expect(embeddedManager.isEnabled).toBe(false); + }); + + it('should toggle enabled state multiple times', () => { + embeddedManager.setEnabled(true); + expect(embeddedManager.isEnabled).toBe(true); + + embeddedManager.setEnabled(false); + expect(embeddedManager.isEnabled).toBe(false); + + embeddedManager.setEnabled(true); + expect(embeddedManager.isEnabled).toBe(true); + }); + + it('should handle setting the same state multiple times', () => { + embeddedManager.setEnabled(true); + embeddedManager.setEnabled(true); + expect(embeddedManager.isEnabled).toBe(true); + + embeddedManager.setEnabled(false); + embeddedManager.setEnabled(false); + expect(embeddedManager.isEnabled).toBe(false); + }); + }); + + describe('syncMessages', () => { + it('should call IterableApi.syncEmbeddedMessages', async () => { + // WHEN syncMessages is called + const result = await embeddedManager.syncMessages(); + + // THEN IterableApi.syncEmbeddedMessages is called + expect(MockRNIterableAPI.syncEmbeddedMessages).toHaveBeenCalledTimes(1); + + // AND the result is returned + expect(result).toBeUndefined(); + }); + }); + + describe('getPlacementIds', () => { + it('should call IterableApi.getEmbeddedPlacementIds', async () => { + // WHEN getPlacementIds is called + const result = await embeddedManager.getPlacementIds(); + + // THEN IterableApi.getEmbeddedPlacementIds is called + expect(MockRNIterableAPI.getEmbeddedPlacementIds).toHaveBeenCalledTimes( + 1 + ); + + // AND the result is returned + expect(result).toEqual([1, 2, 3]); + }); + }); + + describe('getMessages', () => { + it('should call IterableApi.getEmbeddedMessages with placement IDs', async () => { + // GIVEN placement IDs + const placementIds = [1, 2]; + + // WHEN getMessages is called + const result = await embeddedManager.getMessages(placementIds); + + // THEN IterableApi.getEmbeddedMessages is called with placement IDs + expect(MockRNIterableAPI.getEmbeddedMessages).toHaveBeenCalledTimes(1); + expect(MockRNIterableAPI.getEmbeddedMessages).toHaveBeenCalledWith( + placementIds + ); + + // AND the result contains embedded messages + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + metadata: { + messageId: 'msg-1', + campaignId: 123, + placementId: 1, + }, + elements: { + title: 'Test Message 1', + body: 'Test body 1', + }, + payload: { customKey: 'customValue' }, + }); + expect(result[1]).toEqual({ + metadata: { + messageId: 'msg-2', + campaignId: 456, + placementId: 2, + }, + elements: { + title: 'Test Message 2', + body: 'Test body 2', + }, + payload: null, + }); + }); + + it('should call IterableApi.getEmbeddedMessages with null placement IDs', async () => { + // WHEN getMessages is called with null + const result = await embeddedManager.getMessages(null); + + // THEN IterableApi.getEmbeddedMessages is called with null + expect(MockRNIterableAPI.getEmbeddedMessages).toHaveBeenCalledTimes(1); + expect(MockRNIterableAPI.getEmbeddedMessages).toHaveBeenCalledWith(null); + + // AND the result is returned + expect(result).toBeDefined(); + }); + }); + + describe('startSession', () => { + it('should call IterableApi.startEmbeddedSession', () => { + // WHEN startSession is called + embeddedManager.startSession(); + + // THEN IterableApi.startEmbeddedSession is called + expect(MockRNIterableAPI.startEmbeddedSession).toHaveBeenCalledTimes(1); + }); + }); + + describe('endSession', () => { + it('should call IterableApi.endEmbeddedSession', () => { + // WHEN endSession is called + embeddedManager.endSession(); + + // THEN IterableApi.endEmbeddedSession is called + expect(MockRNIterableAPI.endEmbeddedSession).toHaveBeenCalledTimes(1); + }); + }); + + describe('startImpression', () => { + it('should call IterableApi.startEmbeddedImpression with messageId and placementId', () => { + // GIVEN a message ID and placement ID + const messageId = 'message-123'; + const placementId = 456; + + // WHEN startImpression is called + embeddedManager.startImpression(messageId, placementId); + + // THEN IterableApi.startEmbeddedImpression is called with the correct parameters + expect(MockRNIterableAPI.startEmbeddedImpression).toHaveBeenCalledTimes( + 1 + ); + expect(MockRNIterableAPI.startEmbeddedImpression).toHaveBeenCalledWith( + messageId, + placementId + ); + }); + + it('should handle multiple impression starts', () => { + // GIVEN multiple messages + const messageId1 = 'message-1'; + const placementId1 = 100; + const messageId2 = 'message-2'; + const placementId2 = 200; + + // WHEN startImpression is called multiple times + embeddedManager.startImpression(messageId1, placementId1); + embeddedManager.startImpression(messageId2, placementId2); + + // THEN IterableApi.startEmbeddedImpression is called twice + expect(MockRNIterableAPI.startEmbeddedImpression).toHaveBeenCalledTimes( + 2 + ); + expect(MockRNIterableAPI.startEmbeddedImpression).toHaveBeenNthCalledWith( + 1, + messageId1, + placementId1 + ); + expect(MockRNIterableAPI.startEmbeddedImpression).toHaveBeenNthCalledWith( + 2, + messageId2, + placementId2 + ); + }); + }); + + describe('pauseImpression', () => { + it('should call IterableApi.pauseEmbeddedImpression with messageId', () => { + // GIVEN a message ID + const messageId = 'message-123'; + + // WHEN pauseImpression is called + embeddedManager.pauseImpression(messageId); + + // THEN IterableApi.pauseEmbeddedImpression is called with the correct parameter + expect(MockRNIterableAPI.pauseEmbeddedImpression).toHaveBeenCalledTimes( + 1 + ); + expect(MockRNIterableAPI.pauseEmbeddedImpression).toHaveBeenCalledWith( + messageId + ); + }); + + it('should handle multiple impression pauses', () => { + // GIVEN multiple message IDs + const messageId1 = 'message-1'; + const messageId2 = 'message-2'; + + // WHEN pauseImpression is called multiple times + embeddedManager.pauseImpression(messageId1); + embeddedManager.pauseImpression(messageId2); + + // THEN IterableApi.pauseEmbeddedImpression is called twice + expect(MockRNIterableAPI.pauseEmbeddedImpression).toHaveBeenCalledTimes( + 2 + ); + expect(MockRNIterableAPI.pauseEmbeddedImpression).toHaveBeenNthCalledWith( + 1, + messageId1 + ); + expect(MockRNIterableAPI.pauseEmbeddedImpression).toHaveBeenNthCalledWith( + 2, + messageId2 + ); + }); + }); + + 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 new file mode 100644 index 000000000..277fcf819 --- /dev/null +++ b/src/embedded/classes/IterableEmbeddedManager.ts @@ -0,0 +1,276 @@ +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'; + +/** + * Manages embedded messages from Iterable. + * + * Provides embedded message functionality including retrieving messages, + * displaying messages, removing messages, and more. + * + * **Documentation** + * - [Embedded Messaging Overview](https://support.iterable.com/hc/en-us/articles/23060529977364-Embedded-Messaging-Overview) + * - [Android Embedded Messaging](https://support.iterable.com/hc/en-us/articles/23061877893652-Embedded-Messages-with-Iterable-s-Android-SDK) + * - [iOS Embedded Messaging](https://support.iterable.com/hc/en-us/articles/23061840746900-Embedded-Messages-with-Iterable-s-iOS-SDK) + */ +export class IterableEmbeddedManager { + /** + * Whether the embedded manager is enabled. + * + * This is set through the `enableEmbeddedMessaging` flag in the + * `IterableConfig` class. + */ + private _isEnabled = false; + + /** + * Gets whether the embedded manager is enabled. + */ + get isEnabled(): boolean { + return this._isEnabled; + } + + /** + * Sets whether the embedded manager is enabled. + * + * @internal This method is for internal SDK use only and should not be called + * by SDK consumers, as it is meant to be called at initialization time. + * + * @param enabled - Whether the embedded manager is enabled. + */ + setEnabled(enabled: boolean) { + 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. + * + * When your app first launches, and each time it comes to the foreground, + * Iterable's Native SDKs automatically refresh a local, on-device cache of + * embedded messages for the signed-in user. These are the messages the + * signed-in user is eligible to see. + * + * At key points during your app's lifecycle, you may want to manually refresh + * your app's local cache of embedded messages. For example, as users navigate + * around, on pull-to-refresh, etc. + * + * However, do not poll for new embedded messages at a regular interval. + * + * @example + * ```typescript + * IterableEmbeddedManager.syncMessages(); + * ``` + */ + syncMessages() { + return IterableApi.syncEmbeddedMessages(); + } + + /** + * Retrieves a list of placement IDs for the embedded manager. + * + * [Placement Documentation](https://support.iterable.com/hc/en-us/articles/23060529977364-Embedded-Messaging-Overview#placements-and-prioritization) + * + * @example + * ```typescript + * Iterable.embeddedManager.getPlacementIds().then(placementIds => { + * console.log('Placement IDs:', placementIds); + * }); + * ``` + */ + getPlacementIds() { + return IterableApi.getEmbeddedPlacementIds(); + } + + /** + * Retrieves a list of embedded messages the user is eligible to see. + * + * @param placementIds - The placement IDs to retrieve messages for. + * @returns A Promise that resolves to an array of embedded messages. + * + * @example + * ```typescript + * Iterable.embeddedManager.getMessages([1, 2, 3]).then(messages => { + * console.log('Messages:', messages); + * }); + * ``` + */ + getMessages( + placementIds: number[] | null + ): Promise { + return IterableApi.getEmbeddedMessages(placementIds); + } + + /** + * Starts a session. + * + * A session is a period of time when a user is on a screen or page that can + * display embedded messages. + * + * When a user comes to a screen or page in your app where embedded messages + * are displayed (in one or more placements), a session should be started. + * + * @example + * ```typescript + * Iterable.embeddedManager.startSession(); + * ``` + */ + startSession() { + return IterableApi.startEmbeddedSession(); + } + + /** + * Ends a session. + * + * When a user leaves a screen in your app where embedded messages are + * displayed, the session should be ended. This causes the SDK to send + * session an impression data back to the server. + * + * A session is tracked when it is ended, so you should be able to find + * tracking data after this method is called. + * + * @example + * ```typescript + * Iterable.embeddedManager.endSession(); + * ``` + */ + endSession() { + return IterableApi.endEmbeddedSession(); + } + /** + * Starts an embedded impression. + * + * An impression represents the on-screen appearances of a given embedded message, + * in context of a session. + * + * Each impression tracks: + * - The total number of times a message appears during a session. + * - The total amount of time that message was visible, across all its + * appearances in the session. + * + * Be sure to start and pause impressions when your app goes to and from the + * background, too. + * + * @example + * ```typescript + * Iterable.embeddedManager.startImpression(messageId, placementId); + * ``` + */ + startImpression(messageId: string, placementId: number) { + return IterableApi.startEmbeddedImpression(messageId, placementId); + } + + /** + * Pauses an embedded impression. + * + * An impression represents the on-screen appearances of a given embedded message, + * in context of a session. + * + * And impression should be paused when the message is no longer visible, + * including when your app goes to the background. + * + * @example + * ```typescript + * Iterable.embeddedManager.pauseImpression(messageId); + * ``` + */ + 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); + } + } +} diff --git a/src/embedded/classes/index.ts b/src/embedded/classes/index.ts new file mode 100644 index 000000000..be2af76f0 --- /dev/null +++ b/src/embedded/classes/index.ts @@ -0,0 +1 @@ +export * from './IterableEmbeddedManager'; diff --git a/src/embedded/index.ts b/src/embedded/index.ts new file mode 100644 index 000000000..15eb796c9 --- /dev/null +++ b/src/embedded/index.ts @@ -0,0 +1,2 @@ +export * from './classes'; +export * from './types'; diff --git a/src/embedded/types/IterableEmbeddedMessage.ts b/src/embedded/types/IterableEmbeddedMessage.ts new file mode 100644 index 000000000..2f0778305 --- /dev/null +++ b/src/embedded/types/IterableEmbeddedMessage.ts @@ -0,0 +1,14 @@ +import type { IterableEmbeddedMessageElements } from './IterableEmbeddedMessageElements'; +import type { IterableEmbeddedMessageMetadata } from './IterableEmbeddedMessageMetadata'; + +/** + * An embedded message. + */ +export interface IterableEmbeddedMessage { + /** Identifying information about the campaign. */ + metadata: IterableEmbeddedMessageMetadata; + /** What to display, and how to handle interaction. */ + elements?: IterableEmbeddedMessageElements | null; + /** Custom JSON data included with the campaign. */ + payload?: Record | null; +} diff --git a/src/embedded/types/IterableEmbeddedMessageElements.ts b/src/embedded/types/IterableEmbeddedMessageElements.ts new file mode 100644 index 000000000..7eb41056f --- /dev/null +++ b/src/embedded/types/IterableEmbeddedMessageElements.ts @@ -0,0 +1,25 @@ +import type { IterableAction } from '../../core/classes/IterableAction'; +import type { IterableEmbeddedMessageElementsButton } from './IterableEmbeddedMessageElementsButton'; +import type { IterableEmbeddedMessageElementsText } from './IterableEmbeddedMessageElementsText'; + +/** + * The elements of an embedded message. + * + * Includes what to display, and how to handle interaction. + */ +export interface IterableEmbeddedMessageElements { + /** The message's title text. */ + title?: string | null; + /** The message's body text. */ + body?: string | null; + /** The URL of an image associated with the message. */ + mediaUrl?: string | null; + /** Text description of the image. */ + mediaUrlCaption?: string | null; + /** What to do when a user clicks on the message (outside of its buttons). */ + defaultAction?: IterableAction | null; + /** Buttons to display. */ + buttons?: IterableEmbeddedMessageElementsButton[] | null; + /** Extra data fields. Not for display. */ + text?: IterableEmbeddedMessageElementsText[] | null; +} diff --git a/src/embedded/types/IterableEmbeddedMessageElementsButton.ts b/src/embedded/types/IterableEmbeddedMessageElementsButton.ts new file mode 100644 index 000000000..785d5932d --- /dev/null +++ b/src/embedded/types/IterableEmbeddedMessageElementsButton.ts @@ -0,0 +1,10 @@ +import type { IterableAction } from '../../core/classes/IterableAction'; + +export interface IterableEmbeddedMessageElementsButton { + /** The ID. */ + id: string; + /** The title. */ + title?: string | null; + /** The action. */ + action?: IterableAction | null; +} diff --git a/src/embedded/types/IterableEmbeddedMessageElementsText.ts b/src/embedded/types/IterableEmbeddedMessageElementsText.ts new file mode 100644 index 000000000..00b6a8449 --- /dev/null +++ b/src/embedded/types/IterableEmbeddedMessageElementsText.ts @@ -0,0 +1,8 @@ +export interface IterableEmbeddedMessageElementsText { + /** The ID. */ + id: string; + /** The text. */ + text?: string | null; + /** The label. */ + label?: string | null; +} diff --git a/src/embedded/types/IterableEmbeddedMessageMetadata.ts b/src/embedded/types/IterableEmbeddedMessageMetadata.ts new file mode 100644 index 000000000..a4b5ff953 --- /dev/null +++ b/src/embedded/types/IterableEmbeddedMessageMetadata.ts @@ -0,0 +1,19 @@ +/** + * Metadata for an embedded message. + * + * Consists of identifying information about the campaign. + */ +export interface IterableEmbeddedMessageMetadata { + /** The ID of the message. */ + messageId: string; + /** The ID of the placement associated with the message. */ + placementId: number; + /** The ID of the campaign associated with the message. */ + campaignId?: number | null; + /** + * Whether the message is a proof/test message. + * + * EG: Sent directly from a template or campaign edit page. + */ + isProof?: boolean; +} diff --git a/src/embedded/types/index.ts b/src/embedded/types/index.ts new file mode 100644 index 000000000..29b809ebf --- /dev/null +++ b/src/embedded/types/index.ts @@ -0,0 +1,5 @@ +export * from './IterableEmbeddedMessage'; +export * from './IterableEmbeddedMessageElements'; +export * from './IterableEmbeddedMessageElementsButton'; +export * from './IterableEmbeddedMessageElementsText'; +export * from './IterableEmbeddedMessageMetadata'; diff --git a/src/index.tsx b/src/index.tsx index 240ac51f5..75c8489ec 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -59,3 +59,7 @@ export { type IterableInboxProps, type IterableInboxRowViewModel, } from './inbox'; +export { + IterableEmbeddedManager, + type IterableEmbeddedMessage, +} from './embedded';