diff --git a/.eslintrc.js b/.eslintrc.js index e0f808c23..4440e6c67 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -8,6 +8,7 @@ module.exports = { ], rules: { 'react/react-in-jsx-scope': 'off', + 'no-bitwise': 'off', }, overrides: [ { diff --git a/TODO.md b/TODO.md new file mode 100644 index 000000000..04efbfc6d --- /dev/null +++ b/TODO.md @@ -0,0 +1,37 @@ +# Embedded + +## TODO +- [ ] Track session start and stop +- [ ] Track pause and start impression +- [ ] Align styles with OOTB view notes + - https://support.iterable.com/hc/en-us/articles/23230946708244-Out-of-the-Box-Views-for-Embedded-Messages +- [ ] Go through [Evans + google doc](https://docs.google.com/document/d/15GNyo2x5QwYBPUliB4JZvXLkb04ZkW96jcbUJ96YrAM/edit?tab=t.0) + and [Slab doc](https://iterable.slab.com/posts/embedded-messaging-rn-sdk-urwffrhx#h8if0-public-methods) + and see if there is anything else to do +- [ ] Add the ability to switch between views in the example app. And the + ability to configure your own JSON. + + +## Resources +- [RN SDK - In-app review](https://iterable.slab.com/posts/rn-sdk-in-app-review-bl2vp1ds) +- [Google doc](https://docs.google.com/document/d/15GNyo2x5QwYBPUliB4JZvXLkb04ZkW96jcbUJ96YrAM/edit?tab=t.0) +- [Embedded Messaging - RN SDK](https://iterable.slab.com/posts/embedded-messaging-rn-sdk-urwffrhx#h8if0-public-methods) +- [New arch customers](https://docs.google.com/spreadsheets/d/1FzoAH5CAcNy92Km5DLqr8yYvPDBPLTuBXa0_CXZEUCA/edit?gid=60700846#gid=60700846) +- [RN Epic](https://iterable.atlassian.net/browse/MOB-7052) +- [Figma](https://www.figma.com/design/rbDozNjEF9MjwbvSqzrVTv/Flex-Messaging?node-id=3804-186809&p=f) +- [Embedded Messaging: Timeline for a Successful GA Release](https://iterable.slab.com/posts/embedded-messaging-timeline-for-a-successful-ga-release-7f762g1c) +- [Embedded Task Prioritization](https://tables.area120.google.com/u/0/workspace/av3wJDN6_I94tIbdapKOzu/table/9jRcY5gDv2OaTnkceKbOA5) +- [Acceptance Criteria](https://iterable.slab.com/posts/embedded-messaging-acceptance-criteria-80gfn857) +- [Bug Bash Test Cases](https://docs.google.com/spreadsheets/d/1ZrM8vMoMjhK4x18uoqtcOyUqDhFqlwTfrUWAM7csibQ/edit?gid=1805677430#gid=1805677430) +- [Stories for original](https://docs.google.com/spreadsheets/d/1ZrM8vMoMjhK4x18uoqtcOyUqDhFqlwTfrUWAM7csibQ/edit?gid=1805677430#gid=1805677430) +- [Datadog story](https://iterable.atlassian.net/browse/MOB-6926) +- [Yellow Brick Road: Embedded Messaging Mobile SDK](https://iterable.slab.com/posts/yellow-brick-road-embedded-messaging-mobile-sdk-4v032ww9?shr=4v032ww9#h69qk-2024-01-19-ootb-style-layout-conversation) +- [Android SDK Embedded OOTB Constraint + finalization](https://iterable.atlassian.net/browse/MOB-7678) +- [iOS SDK Embedded OOTB Constraint finalization](https://iterable.atlassian.net/browse/MOB-7679) +- [OOTB view bugs review](https://iterable.slab.com/posts/ootb-view-bugs-review-7u978hhy?shr=7u978hhy) +- [Non-RN Embedded Epic](https://iterable.atlassian.net/browse/MOB-5235) +- [OOTB Style/Layout Questions](https://iterable.atlassian.net/browse/MOB-5235) +- [Evan PR](https://github.com/Iterable/react-native-sdk/pull/732) +- [My PR](https://github.com/Iterable/react-native-sdk/pull/730) diff --git a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java index 3207bb5dc..d74ee9d09 100644 --- a/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java +++ b/android/src/main/java/com/iterable/reactnative/RNIterableAPIModuleImpl.java @@ -15,18 +15,24 @@ 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; import com.iterable.iterableapi.AuthFailure; +import com.iterable.iterableapi.EmbeddedMessageElementsButton; import com.iterable.iterableapi.InboxSessionManager; 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.IterableConfig; import com.iterable.iterableapi.IterableCustomActionHandler; -import com.iterable.iterableapi.IterableAttributionInfo; +import com.iterable.iterableapi.IterableEmbeddedManager; +import com.iterable.iterableapi.IterableEmbeddedMessage; +import com.iterable.iterableapi.IterableEmbeddedSession; +import com.iterable.iterableapi.IterableEmbeddedUpdateHandler; import com.iterable.iterableapi.IterableHelper; import com.iterable.iterableapi.IterableInAppCloseAction; import com.iterable.iterableapi.IterableInAppHandler; @@ -45,10 +51,12 @@ 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; -public class RNIterableAPIModuleImpl implements IterableUrlHandler, IterableCustomActionHandler, IterableInAppHandler, IterableAuthHandler, IterableInAppManager.Listener { + +public class RNIterableAPIModuleImpl implements IterableUrlHandler, IterableCustomActionHandler, IterableInAppHandler, IterableAuthHandler, IterableInAppManager.Listener, IterableEmbeddedUpdateHandler { public static final String NAME = "RNIterableAPI"; private static String TAG = "RNIterableAPIModule"; @@ -88,10 +96,15 @@ public void initializeWithApiKey(String apiKey, ReadableMap configReadableMap, S configBuilder.setAuthHandler(this); } + if (configReadableMap.hasKey("enableEmbeddedMessaging")) { + configBuilder.setEnableEmbeddedMessaging(configReadableMap.getBoolean("enableEmbeddedMessaging")); + } + IterableApi.initialize(reactContext, apiKey, configBuilder.build()); 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 @@ -118,6 +131,10 @@ public void initialize2WithApiKey(String apiKey, ReadableMap configReadableMap, configBuilder.setAuthHandler(this); } + if (configReadableMap.hasKey("enableEmbeddedMessaging")) { + configBuilder.setEnableEmbeddedMessaging(configReadableMap.getBoolean("enableEmbeddedMessaging")); + } + // NOTE: There does not seem to be a way to set the API endpoint // override in the Android SDK. Check with @Ayyanchira and @evantk91 to // see what the best approach is. @@ -126,6 +143,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 @@ -629,14 +647,128 @@ public void sendEvent(@NonNull String eventName, @Nullable Object eventData) { public void onInboxUpdated() { sendEvent(EventName.receivedIterableInboxChanged.name(), null); } + + + // --------------------------------------------------------------------------------------- + // endregion + + // --------------------------------------------------------------------------------------- + // region Embedded messaging + + + 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 syncEmbeddedMessages() { + IterableLogger.d(TAG, "syncEmbeddedMessages"); + IterableApi.getInstance().getEmbeddedManager().syncMessages(); + } + + 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 startEmbeddedSession() { + IterableApi.getInstance().getEmbeddedManager().getEmbeddedSessionManager().startSession(); + } + + public void endEmbeddedSession() { + IterableApi.getInstance().getEmbeddedManager().getEmbeddedSessionManager().endSession(); + } + + public void startEmbeddedImpression(String messageId, int placementId) { + IterableApi.getInstance().getEmbeddedManager().getEmbeddedSessionManager().startImpression(messageId, placementId); + } + + public void pauseEmbeddedImpression(String messageId) { + IterableApi.getInstance().getEmbeddedManager().getEmbeddedSessionManager().pauseImpression(messageId); + } + + 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"); + } + } + + @Override + public void onMessagesUpdated() { + IterableLogger.d(TAG, "onMessagesUpdated"); + sendEvent(EventName.receivedIterableEmbeddedMessagesChanged.name(), null); + } + + @Override + public void onEmbeddedMessagingDisabled() { + IterableLogger.d(TAG, "onEmbeddedMessagingDisabled"); + sendEvent(EventName.receivedIterableEmbeddedMessagesChanged.name(), null); + } + + private JSONObject createTestPlacement(int placementId) throws JSONException { + JSONObject placement = new JSONObject(); + placement.put("placementId", placementId); + return placement; + } + + + // --------------------------------------------------------------------------------------- + // 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..61adbd9e6 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,27 @@ 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; + } + + 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 +240,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..d17ef2d6a 100644 --- a/android/src/newarch/java/com/RNIterableAPIModule.java +++ b/android/src/newarch/java/com/RNIterableAPIModule.java @@ -8,7 +8,12 @@ import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; import com.iterable.iterableapi.AuthFailure; +import com.iterable.iterableapi.IterableEmbeddedMessage; import com.iterable.iterableapi.IterableLogger; +import com.iterable.iterableapi.IterableEmbeddedManager; + +import com.iterable.iterableapi.IterableEmbeddedUpdateHandler; + public class RNIterableAPIModule extends NativeRNIterableAPISpec { private final ReactApplicationContext reactContext; @@ -231,4 +236,42 @@ public void sendEvent(@NonNull String eventName, @Nullable Object eventData) { public void onInboxUpdated() { moduleImpl.onInboxUpdated(); } + + @Override + public void getEmbeddedMessages(@Nullable ReadableArray placementIds, Promise promise) { + moduleImpl.getEmbeddedMessages(placementIds, promise); + } + + public void syncEmbeddedMessages() { + moduleImpl.syncEmbeddedMessages(); + } + + public void getEmbeddedPlacementIds(Promise promise) { + moduleImpl.getEmbeddedPlacementIds(promise); + } + + @Override + public void startEmbeddedImpression(String messageId, int placementId) { + moduleImpl.startEmbeddedImpression(messageId, placementId); + } + + @Override + public void pauseEmbeddedImpression(String messageId) { + moduleImpl.pauseEmbeddedImpression(messageId); + } + + @Override + public void trackEmbeddedClick(ReadableMap message, String buttonId, String clickedUrl) { + moduleImpl.trackEmbeddedClick(message, buttonId, clickedUrl); + } + + @Override + public void startEmbeddedSession() { + moduleImpl.startEmbeddedSession(); + } + + @Override + public void endEmbeddedSession() { + moduleImpl.endEmbeddedSession(); + } } diff --git a/android/src/oldarch/java/com/RNIterableAPIModule.java b/android/src/oldarch/java/com/RNIterableAPIModule.java index c3a72339b..9e0aebc80 100644 --- a/android/src/oldarch/java/com/RNIterableAPIModule.java +++ b/android/src/oldarch/java/com/RNIterableAPIModule.java @@ -9,6 +9,10 @@ import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; +import com.iterable.iterableapi.IterableEmbeddedMessage; +import com.iterable.iterableapi.IterableEmbeddedManager; +import com.iterable.iterableapi.IterableEmbeddedUpdateHandler; + public class RNIterableAPIModule extends ReactContextBaseJavaModule { private final ReactApplicationContext reactContext; @@ -228,6 +232,45 @@ public void pauseAuthRetries(boolean pauseRetry) { moduleImpl.pauseAuthRetries(pauseRetry); } + @ReactMethod + public void getEmbeddedMessages(@Nullable ReadableArray placementIds, Promise promise) { + moduleImpl.getEmbeddedMessages(placementIds, promise); + } + + @ReactMethod + public void syncEmbeddedMessages() { + moduleImpl.syncEmbeddedMessages(); + } + + @ReactMethod + public void getEmbeddedPlacementIds(Promise promise) { + moduleImpl.getEmbeddedPlacementIds(promise); + } + + @ReactMethod + public void startEmbeddedSession() { + moduleImpl.startEmbeddedSession(); + } + + @ReactMethod + public void endEmbeddedSession() { + moduleImpl.endEmbeddedSession(); + } + + @ReactMethod + public void startEmbeddedImpression(String messageId, int placementId) { + moduleImpl.startEmbeddedImpression(messageId, placementId); + } + + @ReactMethod + public void pauseEmbeddedImpression(String messageId) { + moduleImpl.pauseEmbeddedImpression(messageId); + } + + @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/android/gradle.properties b/example/android/gradle.properties index c26b81501..136b86960 100644 --- a/example/android/gradle.properties +++ b/example/android/gradle.properties @@ -32,7 +32,7 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 # your application. You should enable this flag either if you want # to write custom TurboModules/Fabric components OR use libraries that # are providing them. -newArchEnabled=true +newArchEnabled=false # Use this property to enable or disable the Hermes JS engine. # If set to false, you will be using JSC instead. @@ -40,4 +40,4 @@ hermesEnabled=true # Needed for react-native-webview # See: https://github.com/react-native-webview/react-native-webview/blob/HEAD/docs/Getting-Started.md -android.enableJetifier=true \ No newline at end of file +android.enableJetifier=true diff --git a/example/src/components/App/App.constants.ts b/example/src/components/App/App.constants.ts index f84c390cb..4710a6ba9 100644 --- a/example/src/components/App/App.constants.ts +++ b/example/src/components/App/App.constants.ts @@ -2,6 +2,7 @@ import { Route } from '../../constants'; export const routeIcon = { [Route.Commerce]: 'cash-outline', + [Route.Embedded]: 'chatbubble-outline', [Route.Inbox]: 'mail-outline', [Route.User]: 'person-outline', }; 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 55b0d74e2..244bfb1c2 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), + })} + /> { + const { isLoggedIn } = useIterableApp(); + // const isFocused = useIsFocused(); + const [hasSession] = useState(false); + const [placementIds, setPlacementIds] = useState([]); + const [messages, setMessages] = useState([]); + + const getPlacementIds = useCallback(() => { + Iterable.embeddedManager.getPlacementIds().then((ids: unknown) => { + console.log(ids); + setPlacementIds(ids as number[]); + }); + }, []); + + const getEmbeddedMessages = useCallback(() => { + Iterable.embeddedManager.getMessages(placementIds).then((messageList) => { + console.log(messageList); + setMessages(messageList as IterableEmbeddedMessage[]); + }); + }, [placementIds]); + + const sync = useCallback(() => { + Iterable.embeddedManager.syncMessages(); + }, []); + + useEffect(() => { + if (isLoggedIn) { + getPlacementIds(); + } + }, [isLoggedIn, getPlacementIds]); + + // useEffect(() => { + // if (isFocused) { + // Iterable.embeddedManager.startSession(); + // Iterable.embeddedManager.syncMessages(); + // Iterable.embeddedManager.getPlacementIds().then((ids: unknown) => { + // console.log(ids); + // setPlacementIds(ids as number[]); + // Iterable.embeddedManager + // .getMessages(placementIds) + // .then((messageList) => { + // console.log(messageList); + // setMessages(messageList as IterableEmbeddedMessage[]); + // setHasSession(true); + // }); + // }); + // } else { + // if (hasSession) { + // Iterable.embeddedManager.endSession(); + // setHasSession(false); + // } + // } + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [isFocused]); + + return ( + + + Has session: {hasSession.toString()} + + Placement ids: [{placementIds.join(', ')}] + + + Get embedded messages + + + Get placement ids + + + Sync + + + + + + {messages.map((message) => { + return ( + + ); + })} + + + + ); +}; + +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..908767962 --- /dev/null +++ b/example/src/components/Embedded/index.ts @@ -0,0 +1,2 @@ +export * from './Embedded'; +export { default } from './Embedded'; diff --git a/example/src/components/User/User.tsx b/example/src/components/User/User.tsx index 23f8361a5..78105cca9 100644 --- a/example/src/components/User/User.tsx +++ b/example/src/components/User/User.tsx @@ -1,5 +1,6 @@ import { Iterable } from '@iterable/react-native-sdk'; -import { useEffect, useState } from 'react'; +import { useIsFocused } from '@react-navigation/native'; +import { useCallback, useEffect, useState } from 'react'; import { Text, TouchableOpacity, View } from 'react-native'; import { useIterableApp } from '../../hooks'; @@ -7,23 +8,66 @@ import styles from './User.styles'; export const User = () => { const { logout, isLoggedIn } = useIterableApp(); + const isFocused = useIsFocused(); const [loggedInAs, setLoggedInAs] = useState(''); + const [hasSession] = useState(false); + const [placementIds, setPlacementIds] = useState([]); + + useEffect(() => { + // if (isFocused) { + // Iterable.embeddedManager.startSession(); + // setHasSession(true); + // } else { + // if (hasSession) { + // Iterable.embeddedManager.endSession(); + // setHasSession(false); + // } + // } + }, [isFocused]); useEffect(() => { if (isLoggedIn) { Iterable.getEmail().then((email) => setLoggedInAs(email || '')); + Iterable.embeddedManager.getPlacementIds().then((ids: unknown) => { + console.log(`🚀 > User > ids:`, ids); + setPlacementIds(ids as number[]); + }); } else { setLoggedInAs(''); } }, [isLoggedIn]); + const getEmbeddedMessages = useCallback(() => { + Iterable.embeddedManager + .getMessages(placementIds) + .then((messages: unknown) => { + console.log(messages); + }); + }, [placementIds]); + + const getPlacementIds = useCallback(() => { + Iterable.embeddedManager.getPlacementIds().then((ids: unknown) => { + console.log(ids); + }); + }, []); + return ( Welcome Iterator Logged in as {loggedInAs} + Has session: {hasSession.toString()} + + Placement ids: [{placementIds.join(', ')}] + Logout + + Get embedded messages + + + Get placement ids + ); }; 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/hooks/useIterableApp.tsx b/example/src/hooks/useIterableApp.tsx index d648dd25c..7141cb950 100644 --- a/example/src/hooks/useIterableApp.tsx +++ b/example/src/hooks/useIterableApp.tsx @@ -128,6 +128,8 @@ export const IterableAppProvider: FunctionComponent< config.inAppDisplayInterval = 1.0; // Min gap between in-apps. No need to set this in production. + config.enableEmbeddedMessaging = true; + config.retryPolicy = { maxRetry: 5, retryInterval: 10, @@ -149,6 +151,7 @@ export const IterableAppProvider: FunctionComponent< }; config.urlHandler = (url: string) => { + console.log('urlHandler', url); const routeNames = [Route.Commerce, Route.Inbox, Route.User]; for (const route of routeNames) { if (url.includes(route.toLowerCase())) { 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/api/NativeRNIterableAPI.ts b/src/api/NativeRNIterableAPI.ts index 391fadbb7..a034123b8 100644 --- a/src/api/NativeRNIterableAPI.ts +++ b/src/api/NativeRNIterableAPI.ts @@ -1,6 +1,36 @@ import type { TurboModule } from 'react-native'; import { TurboModuleRegistry } from 'react-native'; +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 EmbeddedUpdateListener { + onMessagesUpdated: () => void; + onEmbeddedMessagingDisabled: () => void; +} + export interface Spec extends TurboModule { // Initialization initializeWithApiKey( @@ -118,6 +148,37 @@ export interface Spec extends TurboModule { passAlongAuthToken(authToken?: string | null): void; pauseAuthRetries(pauseRetry: boolean): void; + // Embedded messaging + getEmbeddedMessages( + placementIds: number[] | null + ): Promise; + + syncEmbeddedMessages(): void; + + getEmbeddedPlacementIds(): Promise; + + startEmbeddedSession(): void; + + endEmbeddedSession(): void; + + startEmbeddedImpression(messageId: string, placementId: number): void; + + pauseEmbeddedImpression(messageId: string): void; + + trackEmbeddedClick( + message: EmbeddedMessage, + buttonId: string | null, + clickedUrl: string | null + ): void; + + trackEmbeddedSession(session: { + [key: string]: string | number | boolean; + }): void; + + trackEmbeddedMessageReceived(message: { + [key: string]: string | number | boolean; + }): void; + // Wake app -- android only wakeApp(): void; diff --git a/src/core/classes/Iterable.ts b/src/core/classes/Iterable.ts index a669c60a0..b2b561770 100644 --- a/src/core/classes/Iterable.ts +++ b/src/core/classes/Iterable.ts @@ -3,34 +3,43 @@ import { Linking, NativeEventEmitter, Platform } from 'react-native'; import { buildInfo } from '../../itblBuildInfo'; import { RNIterableAPI } from '../../api'; -// TODO: Organize these so that there are no circular dependencies -// See https://github.com/expo/expo/issues/35100 + +import { IterableInAppManager } from '../../inApp/classes/IterableInAppManager'; import { IterableInAppMessage } from '../../inApp/classes/IterableInAppMessage'; import { IterableInAppCloseSource } from '../../inApp/enums/IterableInAppCloseSource'; import { IterableInAppDeleteSource } from '../../inApp/enums/IterableInAppDeleteSource'; import { IterableInAppLocation } from '../../inApp/enums/IterableInAppLocation'; +import { + defaultAuthManager, + defaultConfig, + defaultEmbeddedManager, + defaultInAppManager, + defaultLogger, +} from '../constants/defaults'; import { IterableAuthResponseResult } from '../enums/IterableAuthResponseResult'; import { IterableEventName } from '../enums/IterableEventName'; - -// Add this type-only import to avoid circular dependency -import { IterableInAppManager } from '../../inApp/classes/IterableInAppManager'; - +import type { IterableAuthFailure } from '../types/IterableAuthFailure'; +import { + trackEmbeddedClick, + trackEmbeddedMessageReceived, + trackEmbeddedSession, + trackEvent, + trackInAppClick, + trackInAppClose, + trackInAppOpen, + trackPurchase, + trackPushOpenWithCampaignId, +} from '../utils'; import { IterableAction } from './IterableAction'; import { IterableActionContext } from './IterableActionContext'; +import { IterableApi } from './IterableApi'; import { IterableAttributionInfo } from './IterableAttributionInfo'; +import { IterableAuthManager } from './IterableAuthManager'; import { IterableAuthResponse } from './IterableAuthResponse'; import type { IterableCommerceItem } from './IterableCommerceItem'; import { IterableConfig } from './IterableConfig'; import { IterableLogger } from './IterableLogger'; -import type { IterableAuthFailure } from '../types/IterableAuthFailure'; -import { - defaultAuthManager, - defaultConfig, - defaultInAppManager, - defaultLogger, -} from '../constants/defaults'; -import { IterableApi } from './IterableApi'; -import { IterableAuthManager } from './IterableAuthManager'; +import { IterableEmbeddedManager } from '../../embedded/classes/IterableEmbeddedManager'; const RNEventEmitter = new NativeEventEmitter(RNIterableAPI); @@ -96,6 +105,30 @@ export class Iterable { */ static authManager: IterableAuthManager = defaultAuthManager; + static embeddedManager: IterableEmbeddedManager = defaultEmbeddedManager; + + /** + * Tracking manager for the current user. + * + * This property provides access to tracking functionality including + * tracking purchases, in-app messages, and more. + * + * @example + * ```typescript + * Iterable.tracker.trackPurchase(100, [new IterableCommerceItem('item1', 'Item 1', 10.0, 1)], { key: 'value' }); + * ``` + */ + static tracker = { + trackEmbeddedClick, + trackEmbeddedMessageReceived, + trackEmbeddedSession, + trackEvent, + trackInAppClick, + trackInAppClose, + trackInAppOpen, + trackPurchase, + trackPushOpenWithCampaignId, + }; /** * Initializes the Iterable React Native SDK in your app's Javascript or Typescript code. * @@ -174,6 +207,7 @@ export class Iterable { Iterable.logger = logger; Iterable.inAppManager = new IterableInAppManager(logger); Iterable.authManager = new IterableAuthManager(logger); + Iterable.embeddedManager = new IterableEmbeddedManager(logger, config); IterableApi.setLogger(logger); this.setupEventHandlers(); @@ -391,6 +425,9 @@ export class Iterable { } /** + * @deprecated -- This method is deprecated and may be removed in a future + * release. Use `Iterable.tracker.trackPushOpenWithCampaignId` instead. + * * Create a `pushOpen` event on the current user's Iterable profile, populating * it with data provided to the method call. * @@ -480,6 +517,9 @@ export class Iterable { } /** + * @deprecated -- This method is deprecated and may be removed in a future + * release. Use `Iterable.tracker.trackPurchase` instead. + * * Create a purchase event on the current user's Iterable profile. * * Represent each item in the purchase event with an {@link IterableCommerceItem} object. @@ -508,10 +548,13 @@ export class Iterable { items: IterableCommerceItem[], dataFields?: unknown ) { - return IterableApi.trackPurchase(total, items, dataFields); + return Iterable.tracker.trackPurchase(total, items, dataFields); } /** + * @deprecated -- This method is deprecated and may be removed in a future + * release. Use `Iterable.tracker.trackInAppOpen` instead. + * * Create an `inAppOpen` event for the specified message on the current user's profile * for manual tracking purposes. Iterable's SDK automatically tracks in-app message opens when you use the * SDK's default rendering. @@ -534,10 +577,13 @@ export class Iterable { message: IterableInAppMessage, location: IterableInAppLocation ) { - return IterableApi.trackInAppOpen(message, location); + return Iterable.tracker.trackInAppOpen(message, location); } /** + * @deprecated -- This method is deprecated and may be removed in a future + * release. Use `Iterable.tracker.trackInAppClick` instead. + * * Create an `inAppClick` event for the specified message on the current user's profile * for manual tracking purposes. Iterable's SDK automatically tracks in-app message clicks when you use the * SDK's default rendering. Click events refer to click events within the in-app message to distinguish @@ -563,10 +609,13 @@ export class Iterable { location: IterableInAppLocation, clickedUrl: string ) { - return IterableApi.trackInAppClick(message, location, clickedUrl); + return Iterable.tracker.trackInAppClick(message, location, clickedUrl); } /** + * @deprecated -- This method is deprecated and may be removed in a future + * release. Use `Iterable.tracker.trackInAppClose` instead. + * * Create an `inAppClose` event for the specified message on the current * user's profile for manual tracking purposes. Iterable's SDK automatically * tracks in-app message close events when you use the SDK's default @@ -594,7 +643,12 @@ export class Iterable { source: IterableInAppCloseSource, clickedUrl?: string ) { - return IterableApi.trackInAppClose(message, location, source, clickedUrl); + return Iterable.tracker.trackInAppClose( + message, + location, + source, + clickedUrl + ); } /** @@ -642,6 +696,9 @@ export class Iterable { } /** + * @deprecated -- This method is deprecated and may be removed in a future + * release. Use `Iterable.tracker.trackEvent` instead. + * * Create a custom event to the current user's Iterable profile. * * Pass in the name of the event stored in eventName key and the data associated with the event. @@ -662,7 +719,7 @@ export class Iterable { * ``` */ static trackEvent(name: string, dataFields?: unknown) { - return IterableApi.trackEvent(name, dataFields); + return Iterable.tracker.trackEvent(name, dataFields); } /** @@ -866,6 +923,12 @@ export class Iterable { ); } + static getEmbeddedMessages(placementIds?: number[] | null): Promise { + return IterableApi.getEmbeddedMessages( + placementIds ?? null + ) as unknown as Promise; + } + /** * Sets up event handlers for various Iterable events. * diff --git a/src/core/classes/IterableApi.ts b/src/core/classes/IterableApi.ts index 2c698e85e..1b3f6813a 100644 --- a/src/core/classes/IterableApi.ts +++ b/src/core/classes/IterableApi.ts @@ -1,18 +1,20 @@ import { Platform } from 'react-native'; import RNIterableAPI from '../../api'; -import { IterableConfig } from './IterableConfig'; -import type { IterableLogger } from './IterableLogger'; -import { defaultLogger } from '../constants/defaults'; -import { IterableAttributionInfo } from './IterableAttributionInfo'; -import type { IterableCommerceItem } from './IterableCommerceItem'; +import type { IterableEmbeddedMessage } from '../../embedded/types/IterableEmbeddedMessage'; +import type { IterableEmbeddedSession } from '../../embedded/classes/IterableEmbeddedSession'; +import type { IterableHtmlInAppContent } from '../../inApp/classes/IterableHtmlInAppContent'; import type { IterableInAppMessage } from '../../inApp/classes/IterableInAppMessage'; -import type { IterableInAppLocation } from '../../inApp/enums/IterableInAppLocation'; import type { IterableInAppCloseSource } from '../../inApp/enums/IterableInAppCloseSource'; import type { IterableInAppDeleteSource } from '../../inApp/enums/IterableInAppDeleteSource'; -import type { IterableHtmlInAppContent } from '../../inApp/classes/IterableHtmlInAppContent'; +import type { IterableInAppLocation } from '../../inApp/enums/IterableInAppLocation'; import type { IterableInAppShowResponse } from '../../inApp/enums/IterableInAppShowResponse'; import type { IterableInboxImpressionRowInfo } from '../../inbox/types/IterableInboxImpressionRowInfo'; +import { defaultLogger } from '../constants/defaults'; +import { IterableAttributionInfo } from './IterableAttributionInfo'; +import type { IterableCommerceItem } from './IterableCommerceItem'; +import { IterableConfig } from './IterableConfig'; +import type { IterableLogger } from './IterableLogger'; export class IterableApi { static logger: IterableLogger = defaultLogger; @@ -105,8 +107,8 @@ export class IterableApi { /** * Associate the current user with the passed in `userId` parameter. * - * WARNING: specify a user by calling `Iterable.setEmail` or - * `Iterable.setUserId`, but **NOT** both. + * WARNING: specify a user by calling `IterableApi.setEmail` or + * `IterableApi.setUserId`, but **NOT** both. * * @param userId - User ID to associate with the current user * @param authToken - Valid, pre-fetched JWT the SDK @@ -298,6 +300,55 @@ export class IterableApi { return RNIterableAPI.trackEvent(name, dataFields); } + /** + * Track an embedded session. + * + * @param session - The session to track + */ + static trackEmbeddedSession(session: IterableEmbeddedSession) { + IterableApi.logger.log('trackEmbeddedSession: ', session); + + if (session == null) { + IterableApi.logger.log('trackEmbeddedSession: session is null'); + return; + } + + if (!session.start || !session.end) { + IterableApi.logger.log( + 'trackEmbeddedSession: sessionStartTime and sessionEndTime must be set', + session + ); + return; + } + + return RNIterableAPI.trackEmbeddedSession(session); + } + + static trackEmbeddedClick( + message: IterableEmbeddedMessage, + buttonId: string | null, + clickedUrl: string | null + ) { + IterableApi.logger.log( + 'trackEmbeddedClick: ', + message, + buttonId, + clickedUrl + ); + return RNIterableAPI.trackEmbeddedClick(message, buttonId, clickedUrl); + } + + static trackEmbeddedMessageReceived(message: IterableEmbeddedMessage) { + IterableApi.logger.log('trackEmbeddedMessageReceived: ', message); + + if (message == null) { + IterableApi.logger.log('trackEmbeddedMessageReceived: message is null'); + return; + } + + return RNIterableAPI.trackEmbeddedMessageReceived(message); + } + // ---- End TRACKING ---- // // ====================================================== // @@ -479,6 +530,54 @@ export class IterableApi { // ---- End IN-APP ---- // + // ====================================================== // + // ======================= EMBEDDED ======================= // + // ====================================================== // + + /** + * Get the embedded messages. + * + * @returns A Promise that resolves to an array of embedded messages. + */ + static getEmbeddedMessages( + placementIds: number[] | null + ): Promise { + IterableApi.logger.log('getEmbeddedMessages: ', placementIds); + return RNIterableAPI.getEmbeddedMessages(placementIds); + } + + static syncEmbeddedMessages() { + IterableApi.logger.log('syncEmbeddedMessages'); + return RNIterableAPI.syncEmbeddedMessages(); + } + + static getEmbeddedPlacementIds() { + IterableApi.logger.log('getEmbeddedPlacementIds'); + return RNIterableAPI.getEmbeddedPlacementIds(); + } + + static startEmbeddedSession() { + IterableApi.logger.log('startEmbeddedSession'); + return RNIterableAPI.startEmbeddedSession(); + } + + static endEmbeddedSession() { + IterableApi.logger.log('endEmbeddedSession'); + return RNIterableAPI.endEmbeddedSession(); + } + + static startEmbeddedImpression(messageId: string, placementId: number) { + IterableApi.logger.log('startEmbeddedImpression: ', messageId, placementId); + return RNIterableAPI.startEmbeddedImpression(messageId, placementId); + } + + static pauseEmbeddedImpression(messageId: string) { + IterableApi.logger.log('pauseEmbeddedImpression: ', messageId); + return RNIterableAPI.pauseEmbeddedImpression(messageId); + } + + // ---- End EMBEDDED ---- // + // ====================================================== // // ======================= MOSC ======================= // // ====================================================== // diff --git a/src/core/classes/IterableConfig.ts b/src/core/classes/IterableConfig.ts index c8ee67400..94b4ba705 100644 --- a/src/core/classes/IterableConfig.ts +++ b/src/core/classes/IterableConfig.ts @@ -319,6 +319,11 @@ export class IterableConfig { */ encryptionEnforced = false; + /** + * This specifies whether the SDK should enable embedded messaging. + */ + enableEmbeddedMessaging = false; + /** * Converts the IterableConfig instance to a dictionary object. * @@ -368,6 +373,7 @@ export class IterableConfig { pushPlatform: this.pushPlatform, encryptionEnforced: this.encryptionEnforced, retryPolicy: this.retryPolicy, + enableEmbeddedMessaging: this.enableEmbeddedMessaging, }; } } diff --git a/src/core/classes/IterableUtil.ts b/src/core/classes/IterableUtil.ts index 6c2e39d01..b503c572f 100644 --- a/src/core/classes/IterableUtil.ts +++ b/src/core/classes/IterableUtil.ts @@ -18,4 +18,34 @@ export class IterableUtil { return false; } } + + static generateUUID(): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g: any = typeof global !== 'undefined' ? (global as any) : undefined; + + if (g?.crypto?.getRandomValues) { + const bytes = new Uint8Array(16); + g.crypto.getRandomValues(bytes); + + // RFC 4122 compliance + bytes[6] = (bytes[6] ?? 0 & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] ?? 0 & 0x3f) | 0x80; // variant 10 + + const hex = Array.from(bytes, (b) => + b.toString(16).padStart(2, '0') + ).join(''); + return `${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}`; + } + + // Fallback using Math.random (not cryptographically strong) + const hexDigits = '0123456789abcdef'; + const s: string[] = Array(36); + for (let i = 0; i < 36; i++) { + s[i] = hexDigits.charAt(Math.floor(Math.random() * 16)); + } + s[14] = '4'; + s[19] = hexDigits.charAt((parseInt(s[19] ?? '0', 16) & 0x3) | 0x8); + s[8] = s[13] = s[18] = s[23] = '-'; + return s.join(''); + } } diff --git a/src/core/constants/defaults.ts b/src/core/constants/defaults.ts index 0351fdc9f..c72a60c23 100644 --- a/src/core/constants/defaults.ts +++ b/src/core/constants/defaults.ts @@ -1,3 +1,4 @@ +import { IterableEmbeddedManager } from '../../embedded/classes/IterableEmbeddedManager'; import { IterableInAppManager } from '../../inApp/classes/IterableInAppManager'; import { IterableAuthManager } from '../classes/IterableAuthManager'; import { IterableConfig } from '../classes/IterableConfig'; @@ -7,3 +8,7 @@ export const defaultConfig = new IterableConfig(); export const defaultLogger = new IterableLogger(defaultConfig); export const defaultInAppManager = new IterableInAppManager(defaultLogger); export const defaultAuthManager = new IterableAuthManager(defaultLogger); +export const defaultEmbeddedManager = new IterableEmbeddedManager( + defaultLogger, + defaultConfig +); 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/IterableActionType.ts b/src/core/enums/IterableActionType.ts new file mode 100644 index 000000000..ee6f60aa7 --- /dev/null +++ b/src/core/enums/IterableActionType.ts @@ -0,0 +1,4 @@ +export enum IterableActionType { + openUrl = 'openUrl', + action = 'action', +} diff --git a/src/core/enums/IterableCustomActionPrefix.ts b/src/core/enums/IterableCustomActionPrefix.ts new file mode 100644 index 000000000..2e989334f --- /dev/null +++ b/src/core/enums/IterableCustomActionPrefix.ts @@ -0,0 +1,4 @@ +export enum IterableCustomActionPrefix { + Action = 'action://', + Itbl = 'itbl://', +} diff --git a/src/core/enums/IterableEventName.ts b/src/core/enums/IterableEventName.ts index 4a44cbb40..dfa2bcc11 100644 --- a/src/core/enums/IterableEventName.ts +++ b/src/core/enums/IterableEventName.ts @@ -15,6 +15,8 @@ export enum IterableEventName { handleAuthCalled = 'handleAuthCalled', /** Event that fires when the Iterable inbox is updated */ receivedIterableInboxChanged = 'receivedIterableInboxChanged', + /** Event that fires when embedded messages are updated */ + receivedIterableEmbeddedMessagesChanged = 'receivedIterableEmbeddedMessagesChanged', /** Event that fires when authentication with Iterable succeeds */ handleAuthSuccessCalled = 'handleAuthSuccessCalled', /** Event that fires when authentication with Iterable fails */ diff --git a/src/core/enums/IterableRetryBackoff.ts b/src/core/enums/IterableRetryBackoff.ts index 4afcf9046..e37dd472d 100644 --- a/src/core/enums/IterableRetryBackoff.ts +++ b/src/core/enums/IterableRetryBackoff.ts @@ -1,3 +1,4 @@ +/* eslint-disable tsdoc/syntax */ /** * The type of backoff to use when retrying a request. */ diff --git a/src/core/enums/index.ts b/src/core/enums/index.ts index 52f4eb20d..77725bbc9 100644 --- a/src/core/enums/index.ts +++ b/src/core/enums/index.ts @@ -1,4 +1,5 @@ export * from './IterableActionSource'; +export * from './IterableActionType'; export * from './IterableAuthFailureReason'; export * from './IterableAuthResponseResult'; export * from './IterableDataRegion'; diff --git a/src/core/hooks/useComponentVisibility.ts b/src/core/hooks/useComponentVisibility.ts new file mode 100644 index 000000000..fbe117cd7 --- /dev/null +++ b/src/core/hooks/useComponentVisibility.ts @@ -0,0 +1,156 @@ +import { + View, + Dimensions, + AppState, + type LayoutChangeEvent, +} from 'react-native'; +import { useRef, useState, useCallback, useEffect } from 'react'; + +interface UseVisibilityOptions { + threshold?: number; // Percentage of component that must be visible (0-1) + checkOnAppState?: boolean; // Whether to check app state (active/background) + checkInterval?: number; // How often to check visibility in ms (0 = only on layout changes) + enablePeriodicCheck?: boolean; // Whether to enable periodic checking for navigation changes +} + +interface LayoutInfo { + x: number; + y: number; + width: number; + height: number; +} + +export const useComponentVisibility = (options: UseVisibilityOptions = {}) => { + const { + threshold = 0.1, + checkOnAppState = true, + checkInterval = 0, // Default to only check on layout changes + enablePeriodicCheck = true, // Enable periodic checking by default for navigation + } = options; + + const [isVisible, setIsVisible] = useState(false); + const [appState, setAppState] = useState(AppState.currentState); + const componentRef = useRef(null); + const [layout, setLayout] = useState({ + x: 0, + y: 0, + width: 0, + height: 0, + }); + const intervalRef = useRef(null); + + // Handle layout changes + const handleLayout = useCallback((event: LayoutChangeEvent) => { + const { x, y, width, height } = event.nativeEvent.layout; + setLayout({ x, y, width, height }); + }, []); + + // Check if component is visible on screen using measure + const checkVisibility = useCallback((): Promise => { + if (!componentRef.current || layout.width === 0 || layout.height === 0) { + return Promise.resolve(false); + } + + return new Promise((resolve) => { + componentRef.current?.measure((_x, _y, width, height, pageX, pageY) => { + const screenHeight = Dimensions.get('window').height; + const screenWidth = Dimensions.get('window').width; + + // Calculate visible area using page coordinates + const visibleTop = Math.max(0, pageY); + const visibleBottom = Math.min(screenHeight, pageY + height); + const visibleLeft = Math.max(0, pageX); + const visibleRight = Math.min(screenWidth, pageX + width); + + const visibleHeight = Math.max(0, visibleBottom - visibleTop); + const visibleWidth = Math.max(0, visibleRight - visibleLeft); + + const visibleArea = visibleHeight * visibleWidth; + const totalArea = height * width; + const visibilityRatio = totalArea > 0 ? visibleArea / totalArea : 0; + + resolve(visibilityRatio >= threshold); + }); + }).catch(() => { + // Fallback to layout-based calculation if measure fails + const screenHeight = Dimensions.get('window').height; + const screenWidth = Dimensions.get('window').width; + + const visibleTop = Math.max(0, layout.y); + const visibleBottom = Math.min(screenHeight, layout.y + layout.height); + const visibleLeft = Math.max(0, layout.x); + const visibleRight = Math.min(screenWidth, layout.x + layout.width); + + const visibleHeight = Math.max(0, visibleBottom - visibleTop); + const visibleWidth = Math.max(0, visibleRight - visibleLeft); + + const visibleArea = visibleHeight * visibleWidth; + const totalArea = layout.height * layout.width; + const visibilityRatio = totalArea > 0 ? visibleArea / totalArea : 0; + + return visibilityRatio >= threshold; + }); + }, [layout, threshold]); + + // Update visibility state + const updateVisibility = useCallback(async () => { + const isComponentVisible = await checkVisibility(); + const isAppActive = !checkOnAppState || appState === 'active'; + const newVisibility = isComponentVisible && isAppActive; + + setIsVisible(newVisibility); + }, [checkVisibility, appState, checkOnAppState]); + + // Update visibility when layout or app state changes + useEffect(() => { + updateVisibility(); + }, [updateVisibility]); + + // Set up periodic checking for navigation changes + useEffect(() => { + const interval = + checkInterval > 0 ? checkInterval : enablePeriodicCheck ? 500 : 0; + + if (interval > 0) { + intervalRef.current = setInterval(updateVisibility, interval); + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + } + return undefined; + }, [checkInterval, enablePeriodicCheck, updateVisibility]); + + // Listen to app state changes + useEffect(() => { + if (!checkOnAppState) return; + + const handleAppStateChange = (nextAppState: string) => { + setAppState(nextAppState as typeof AppState.currentState); + }; + + const subscription = AppState.addEventListener( + 'change', + handleAppStateChange + ); + return () => subscription?.remove(); + }, [checkOnAppState]); + + // Clean up interval on unmount + useEffect(() => { + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, []); + + return { + isVisible, + componentRef, + handleLayout, + appState, + layout, + }; +}; diff --git a/src/core/images/logo-grey.png b/src/core/images/logo-grey.png new file mode 100644 index 000000000..5c0d56a92 Binary files /dev/null and b/src/core/images/logo-grey.png differ diff --git a/src/core/utils/generateUUID.ts b/src/core/utils/generateUUID.ts new file mode 100644 index 000000000..6d3f653dc --- /dev/null +++ b/src/core/utils/generateUUID.ts @@ -0,0 +1,29 @@ +export const generateUUID = () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const g: any = typeof global !== 'undefined' ? (global as any) : undefined; + + if (g?.crypto?.getRandomValues) { + const bytes = new Uint8Array(16); + g.crypto.getRandomValues(bytes); + + // RFC 4122 compliance + bytes[6] = (bytes[6] ?? 0 & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] ?? 0 & 0x3f) | 0x80; // variant 10 + + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join( + '' + ); + return `${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}`; + } + + // Fallback using Math.random (not cryptographically strong) + const hexDigits = '0123456789abcdef'; + const s: string[] = Array(36); + for (let i = 0; i < 36; i++) { + s[i] = hexDigits.charAt(Math.floor(Math.random() * 16)); + } + s[14] = '4'; + s[19] = hexDigits.charAt((parseInt(s[19] ?? '0', 16) & 0x3) | 0x8); + s[8] = s[13] = s[18] = s[23] = '-'; + return s.join(''); +}; diff --git a/src/core/utils/getActionPrefix.ts b/src/core/utils/getActionPrefix.ts new file mode 100644 index 000000000..664064da9 --- /dev/null +++ b/src/core/utils/getActionPrefix.ts @@ -0,0 +1,14 @@ +import { IterableCustomActionPrefix } from '../enums/IterableCustomActionPrefix'; + +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..84aea443b --- /dev/null +++ b/src/core/utils/index.ts @@ -0,0 +1,3 @@ +export * from './trackingUtils'; +export * from './isIterableAction'; +export * from './generateUUID'; diff --git a/src/core/utils/isIterableAction.ts b/src/core/utils/isIterableAction.ts new file mode 100644 index 000000000..ea1ccfcd5 --- /dev/null +++ b/src/core/utils/isIterableAction.ts @@ -0,0 +1,5 @@ +import { IterableCustomActionPrefix } from '../enums/IterableCustomActionPrefix'; + +export const isIterableAction = (str: string = '') => + str.startsWith(IterableCustomActionPrefix.Action) || + str.startsWith(IterableCustomActionPrefix.Itbl); diff --git a/src/core/utils/trackingUtils.ts b/src/core/utils/trackingUtils.ts new file mode 100644 index 000000000..1f000d809 --- /dev/null +++ b/src/core/utils/trackingUtils.ts @@ -0,0 +1,228 @@ +import { IterableApi } from '../classes/IterableApi'; +import type { IterableEmbeddedSession } from '../../embedded/classes/IterableEmbeddedSession'; +import type { IterableCommerceItem } from '../classes/IterableCommerceItem'; +import type { IterableInAppMessage } from '../../inApp/classes/IterableInAppMessage'; +import type { IterableInAppLocation } from '../../inApp/enums/IterableInAppLocation'; +import type { IterableInAppCloseSource } from '../../inApp/enums/IterableInAppCloseSource'; +import type { IterableEmbeddedMessage } from '../../embedded/types/IterableEmbeddedMessage'; + +/** + * Create a `pushOpen` event on the current user's Iterable profile, populating + * it with data provided to the method call. + * + * **NOTE**: Iterable's SDK automatically tracks push notification opens. + * However, it's also possible to manually track these events by calling this + * method. + * + * @param campaignId - The ID of the campaign to associate with the push open + * @param templateId - The ID of the template to associate with the push open + * @param messageId - The ID of the message to associate with the push open + * @param appAlreadyRunning - Whether or not the app was already running when + * the push notification arrived + * @param dataFields - Information to store with the push open event + * + * @example + * ```typescript + * const CAMPAIGN_ID = 12345; + * const TEMPLATE_ID = 67890; + * const MESSAGE_ID = '0fc6657517c64014868ea2d15f23082b'; + * const APP_ALREADY_RUNNING = false; + * const DATA_FIELDS = { + * "discount": 0.99, + * "product": "cappuccino", + * }; + * + * trackPushOpen(CAMPAIGN_ID, TEMPLATE_ID, MESSAGE_ID, APP_ALREADY_RUNNING, DATA_FIELDS); + * ``` + */ +export const trackPushOpenWithCampaignId = ( + campaignId: number, + templateId: number, + messageId: string | undefined, + appAlreadyRunning: boolean, + dataFields?: unknown +) => { + return IterableApi.trackPushOpenWithCampaignId( + campaignId, + templateId, + messageId, + appAlreadyRunning, + dataFields + ); +}; + +/** + * Create a purchase event on the current user's Iterable profile. + * + * Represent each item in the purchase event with an {@link IterableCommerceItem} object. + * + * @see {@link IterableCommerceItem} + * + * **NOTE**: `total` is a parameter that is passed in. Iterable does not sum the `price` fields of the various items in the purchase event. + * + * @param total - The total cost of the purchase + * @param items - The items included in the purchase + * @param dataFields - Descriptive data to store on the purchase event + * + * @example + * ```typescript + * const items = [ + * new IterableCommerceItem('item1', 'Item 1', 10.0, 1), + * new IterableCommerceItem('item2', 'Item 2', 20.0, 2), + * ]; + * const dataFields = { 'key1': 'value1', }; + * + * trackPurchase(30.0, items, dataFields); + * ``` + */ +export const trackPurchase = ( + total: number, + items: IterableCommerceItem[], + dataFields?: unknown +) => { + return IterableApi.trackPurchase(total, items, dataFields); +}; + +/** + * Create an `inAppOpen` event for the specified message on the current user's profile + * for manual tracking purposes. Iterable's SDK automatically tracks in-app message opens when you use the + * SDK's default rendering. + * + * @param message - The in-app message (an {@link IterableInAppMessage} object) + * @param location - The location of the in-app message (an IterableInAppLocation enum) + * + * @example + * ```typescript + * const message = new IterableInAppMessage(1234, 4567, IterableInAppTrigger.auto, new Date(), new Date(), false, undefined, undefined, false, 0); + * trackInAppOpen(message, IterableInAppLocation.inApp); + * ``` + * + * @remarks + * Iterable's SDK automatically tracks in-app message opens when you use the + * SDK's default rendering. However, it's also possible to manually track + * these events by calling this method. + */ +export const trackInAppOpen = ( + message: IterableInAppMessage, + location: IterableInAppLocation +) => { + return IterableApi.trackInAppOpen(message, location); +}; + +/** + * Create an `inAppClick` event for the specified message on the current user's profile + * for manual tracking purposes. Iterable's SDK automatically tracks in-app message clicks when you use the + * SDK's default rendering. Click events refer to click events within the in-app message to distinguish + * from `inAppOpen` events. + * + * @param message - The in-app message. + * @param location - The location of the in-app message. + * @param clickedUrl - The URL clicked by the user. + * + * @example + * ```typescript + * const message = new IterableInAppMessage(1234, 4567, IterableInAppTrigger.auto, new Date(), new Date(), false, undefined, undefined, false, 0); + * trackInAppClick(message, IterableInAppLocation.inApp, 'https://www.example.com'); + * ``` + * + * @remarks + * Iterable's SDK automatically tracks in-app message clicks when you use the + * SDK's default rendering. However, you can also manually track these events + * by calling this method. + */ +export const trackInAppClick = ( + message: IterableInAppMessage, + location: IterableInAppLocation, + clickedUrl: string +) => { + return IterableApi.trackInAppClick(message, location, clickedUrl); +}; + +/** + * Create an `inAppClose` event for the specified message on the current + * user's profile for manual tracking purposes. Iterable's SDK automatically + * tracks in-app message close events when you use the SDK's default + * rendering. + * + * @param message - The in-app message. + * @param location - The location of the in-app message. Useful for determining if the messages is in a mobile inbox. + * @param source - The way the in-app was closed. + * @param clickedUrl - The URL clicked by the user. + * + * @example + * ```typescript + * const message = new IterableInAppMessage(1234, 4567, IterableInAppTrigger.auto, new Date(), new Date(), false, undefined, undefined, false, 0); + * trackInAppClose(message, IterableInAppLocation.inApp, IterableInAppCloseSource.back, 'https://www.example.com'); + * ``` + * + * @remarks + * Iterable's SDK automatically tracks in-app message close events when you + * use the SDK's default rendering. However, it's also possible to manually + * track these events by calling this method. + */ +export const trackInAppClose = ( + message: IterableInAppMessage, + location: IterableInAppLocation, + source: IterableInAppCloseSource, + clickedUrl?: string +) => { + return IterableApi.trackInAppClose(message, location, source, clickedUrl); +}; + +/** + * Create a custom event to the current user's Iterable profile. + * + * Pass in the name of the event stored in eventName key and the data associated with the event. + * The eventType is set to "customEvent". + * + * @param name - The event name of the custom event + * @param dataFields - Descriptive data to store on the custom event + * + * @example + * ```typescript + * trackEvent("completedOnboarding", + * { + * "includedProfilePhoto": true, + * "favoriteColor": "red", + * "favoriteFlavor": "cinnamon", + * } + * ); + * ``` + */ +export const trackEvent = (name: string, dataFields?: unknown) => { + return IterableApi.trackEvent(name, dataFields); +}; + +/** + * Track an embedded session. + * + * @param session - The session to track + * + * @example + * ```typescript + * const session = new IterableEmbeddedSession({ + * start: new Date(), + * end: new Date(), + * impressions: [], + * }); + * + * trackEmbeddedSession(session); + * ``` + */ +export const trackEmbeddedSession = (session: IterableEmbeddedSession) => { + return IterableApi.trackEmbeddedSession(session); +}; + +export const trackEmbeddedClick = ( + message: IterableEmbeddedMessage, + buttonId: string, + clickedUrl: string +) => { + return IterableApi.trackEmbeddedClick(message, buttonId, clickedUrl); +}; + +export const trackEmbeddedMessageReceived = ( + message: IterableEmbeddedMessage +) => { + return IterableApi.trackEmbeddedMessageReceived(message); +}; diff --git a/src/embedded/classes/IterableEmbeddedImpression.ts b/src/embedded/classes/IterableEmbeddedImpression.ts new file mode 100644 index 000000000..eb45c49fd --- /dev/null +++ b/src/embedded/classes/IterableEmbeddedImpression.ts @@ -0,0 +1,33 @@ +export interface IterableEmbeddedImpressionDict { + /** The message ID. */ + messageId: string; + /** The placement ID. */ + placementId: number; + /** The display count. */ + displayCount: number; + /** The duration. */ + duration: number; +} + +/** + * Represents an embedded impression. + */ +export class IterableEmbeddedImpression { + public messageId: IterableEmbeddedImpressionDict['messageId']; + public placementId: IterableEmbeddedImpressionDict['placementId']; + public displayCount: IterableEmbeddedImpressionDict['displayCount']; + public duration: IterableEmbeddedImpressionDict['duration']; + + constructor(options: Partial = {}) { + const { + messageId = '', + placementId = 0, + displayCount = 0, + duration = 0, + } = options; + this.messageId = messageId; + this.placementId = placementId; + this.displayCount = displayCount; + this.duration = duration; + } +} diff --git a/src/embedded/classes/IterableEmbeddedImpressionData.ts b/src/embedded/classes/IterableEmbeddedImpressionData.ts new file mode 100644 index 000000000..50f7a12ab --- /dev/null +++ b/src/embedded/classes/IterableEmbeddedImpressionData.ts @@ -0,0 +1,40 @@ +export interface IterableEmbeddedImpressionDataDict { + /** The message ID. */ + messageId: string; + /** The placement ID. */ + placementId: number; + /** The display count. */ + displayCount?: number; + /** The duration. */ + duration?: number; + /** The start date. */ + start?: Date | null; +} + +/** + * Represents the impression data for an embedded message. + */ +export class IterableEmbeddedImpressionData { + public messageId?: IterableEmbeddedImpressionDataDict['messageId']; + public placementId?: IterableEmbeddedImpressionDataDict['placementId']; + public displayCount?: IterableEmbeddedImpressionDataDict['displayCount'] = 0; + public duration?: IterableEmbeddedImpressionDataDict['duration'] = 0.0; + public start?: IterableEmbeddedImpressionDataDict['start']; + + constructor( + messageId: string, + placementId: number, + options: Pick< + IterableEmbeddedImpressionDataDict, + 'displayCount' | 'duration' | 'start' + > = {} + ) { + this.messageId = messageId; + this.placementId = placementId; + + const { displayCount = 0, duration = 0.0, start = null } = options; + this.displayCount = displayCount; + this.duration = duration; + this.start = start; + } +} diff --git a/src/embedded/classes/IterableEmbeddedManager.ts b/src/embedded/classes/IterableEmbeddedManager.ts new file mode 100644 index 000000000..a055d633c --- /dev/null +++ b/src/embedded/classes/IterableEmbeddedManager.ts @@ -0,0 +1,123 @@ +import { IterableAction } from '../../core/classes/IterableAction'; +import { IterableActionContext } from '../../core/classes/IterableActionContext'; +import { IterableApi } from '../../core/classes/IterableApi'; +import type { IterableConfig } from '../../core/classes/IterableConfig'; +import type { IterableLogger } from '../../core/classes/IterableLogger'; +import { defaultConfig, defaultLogger } from '../../core/constants/defaults'; +import { IterableActionSource } from '../../core/enums/IterableActionSource'; +import { getActionPrefix } from '../../core/utils/getActionPrefix'; +import type { IterableEmbeddedMessage } from '../types/IterableEmbeddedMessage'; +import { IterableEmbeddedSessionManager } from './IterableEmbeddedSessionManager'; + +export class IterableEmbeddedManager { + logger: IterableLogger = defaultLogger; + config: IterableConfig = defaultConfig; + + sessionManager: IterableEmbeddedSessionManager = + new IterableEmbeddedSessionManager(defaultLogger); + + constructor(logger: IterableLogger, config: IterableConfig) { + this.logger = logger; + this.config = config; + this.sessionManager = new IterableEmbeddedSessionManager(logger); + } + + syncMessages() { + this.logger.log('IterableEmbeddedManager.syncMessages'); + return IterableApi.syncEmbeddedMessages(); + } + + getMessages(placementIds?: number[] | null) { + this.logger.log('IterableEmbeddedManager.getMessages', placementIds); + return IterableApi.getEmbeddedMessages(placementIds ?? null); + } + + getPlacementIds() { + this.logger.log('IterableEmbeddedManager.getPlacementIds'); + return IterableApi.getEmbeddedPlacementIds(); + } + + startSession() { + this.logger.log('IterableEmbeddedManager.startSession'); + // TODO: Track session start + IterableApi.startEmbeddedSession(); + } + + endSession() { + this.logger.log('IterableEmbeddedManager.endSession'); + // TODO: Track session end + IterableApi.endEmbeddedSession(); + } + + startImpression(messageId: string, placementId: number) { + this.logger.log( + 'IterableEmbeddedManager.startImpression', + messageId, + placementId + ); + // TODO: Track impression start + IterableApi.startEmbeddedImpression(messageId, placementId); + } + + pauseImpression(messageId: string) { + this.logger.log('IterableEmbeddedManager.pauseImpression', messageId); + // TODO: Track impression pause + IterableApi.pauseEmbeddedImpression(messageId); + } + + handleClick( + message: IterableEmbeddedMessage, + buttonId: string | null, + clickedUrl: string | null + ) { + this.logger.log( + 'IterableEmbeddedManager.handleClick', + message, + buttonId, + clickedUrl + ); + + if (!clickedUrl) { + this.logger.log( + 'IterableEmbeddedManager.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 action = new IterableAction(actionName, '', ''); + const context = new IterableActionContext(action, source); + if (this.config.customActionHandler) { + this.config.customActionHandler(action, context); + } + } else { + const action = new IterableAction('openUrl', clickedUrl, ''); + const context = new IterableActionContext(action, source); + if (this.config.urlHandler) { + this.config.urlHandler(clickedUrl, context); + } + } + } + + trackClick( + message: IterableEmbeddedMessage, + buttonId: string | null, + clickedUrl: string | null + ) { + this.logger.log( + 'IterableEmbeddedManager.trackClick', + message, + buttonId, + clickedUrl + ); + IterableApi.trackEmbeddedClick(message, buttonId, clickedUrl); + } +} diff --git a/src/embedded/classes/IterableEmbeddedSession.ts b/src/embedded/classes/IterableEmbeddedSession.ts new file mode 100644 index 000000000..7cf920dfa --- /dev/null +++ b/src/embedded/classes/IterableEmbeddedSession.ts @@ -0,0 +1,28 @@ +import { generateUUID } from '../../core/utils/generateUUID'; +import type { IterableEmbeddedImpressionData } from './IterableEmbeddedImpressionData'; + +export interface IterableEmbeddedSessionDict { + /** The ID of the session. */ + id: string; + /** The start date of the session. */ + start: Date | null; + /** The end date of the session. */ + end: Date | null; + /** The impressions of the session. */ + impressions: IterableEmbeddedImpressionData[]; +} + +export class IterableEmbeddedSession { + public id: IterableEmbeddedSessionDict['id'] = ''; + public start: IterableEmbeddedSessionDict['start'] = null; + public end: IterableEmbeddedSessionDict['end'] = null; + public impressions: IterableEmbeddedSessionDict['impressions'] = []; + + constructor(options: Partial = {}) { + const { start = null, end = null, impressions = [] } = options; + this.id = generateUUID(); + this.start = start; + this.end = end; + this.impressions = impressions; + } +} diff --git a/src/embedded/classes/IterableEmbeddedSessionManager.ts b/src/embedded/classes/IterableEmbeddedSessionManager.ts new file mode 100644 index 000000000..66a1d91bb --- /dev/null +++ b/src/embedded/classes/IterableEmbeddedSessionManager.ts @@ -0,0 +1,131 @@ +import { IterableConfig } from '../../core/classes/IterableConfig'; +import { IterableLogger } from '../../core/classes/IterableLogger'; +import { trackEmbeddedSession } from '../../core/utils/trackingUtils'; +import { IterableEmbeddedImpression } from './IterableEmbeddedImpression'; +import { IterableEmbeddedImpressionData } from './IterableEmbeddedImpressionData'; +import { IterableEmbeddedSession } from './IterableEmbeddedSession'; + +/** + * Manages the embedded session for the current user. + * TODO: figure out if we need this + */ +export class IterableEmbeddedSessionManager { + private logger: IterableLogger = new IterableLogger(new IterableConfig()); + private impressions: Record = {}; + public session: IterableEmbeddedSession = new IterableEmbeddedSession({ + start: null, + end: null, + impressions: [], + }); + + constructor(logger: IterableLogger) { + this.logger = logger; + this.impressions = {}; + } + + public isTracking(): boolean { + return !!this.session?.start; + } + + public startSession() { + if (this.isTracking()) { + this.logger.log('Embedded session started twice'); + return; + } + + this.session = new IterableEmbeddedSession({ + start: new Date(), + end: null, + impressions: [], + }); + } + + public endSession() { + if (!this.isTracking()) { + this.logger.log('Embedded session ended without start'); + return; + } + + if (Object.keys(this.impressions).length > 0) { + this.endAllImpressions(); + + const sessionToTrack = new IterableEmbeddedSession({ + start: this.session.start, + end: new Date(), + impressions: this.getImpressionList(), + }); + + trackEmbeddedSession(sessionToTrack); + + //reset session for next session start + this.session = new IterableEmbeddedSession({ + start: null, + end: null, + impressions: [], + }); + + this.impressions = {}; + } + } + + public startImpression(messageId: string, placementId: number) { + let impressionData: IterableEmbeddedImpressionData | undefined = + this.impressions[messageId]; + + if (!impressionData) { + impressionData = new IterableEmbeddedImpressionData( + messageId, + placementId + ); + this.impressions[messageId] = impressionData; + } + + impressionData.start = new Date(); + } + + public pauseImpression(messageId: string) { + const impressionData = this.impressions[messageId]; + + if (!impressionData) { + this.logger.log('onMessageImpressionEnded: impressionData not found'); + return; + } + + if (!impressionData.start) { + this.logger.log('onMessageImpressionEnded: impressionStarted is null'); + return; + } + + this.updateDisplayCountAndDuration(impressionData); + } + + private endAllImpressions() { + Object.values(this.impressions).forEach((impressionData) => { + this.updateDisplayCountAndDuration(impressionData); + }); + } + + private getImpressionList(): IterableEmbeddedImpression[] { + return Object.values(this.impressions).map((impression) => { + return new IterableEmbeddedImpression({ + messageId: impression.messageId || '', + placementId: impression.placementId || 0, + displayCount: impression.displayCount || 0, + duration: impression.duration || 0, + }); + }); + } + + private updateDisplayCountAndDuration( + impressionData: IterableEmbeddedImpressionData + ): IterableEmbeddedImpressionData { + if (impressionData.start) { + impressionData.displayCount = (impressionData.displayCount || 0) + 1; + impressionData.duration = + (impressionData.duration || 0) + + (new Date().getTime() - impressionData.start.getTime()) / 1000.0; + impressionData.start = null; + } + return impressionData; + } +} diff --git a/src/embedded/classes/index.ts b/src/embedded/classes/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.styles.tsx b/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.styles.tsx new file mode 100644 index 000000000..551f50068 --- /dev/null +++ b/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.styles.tsx @@ -0,0 +1,87 @@ +import { StyleSheet } from 'react-native'; +import { + embeddedMediaImageBackgroundColors, + embeddedMediaImageBorderColors, +} from '../../constants/embeddedViewDefaults'; + +export const IMAGE_HEIGHT = 70; +export const IMAGE_WIDTH = 70; + +export const styles = StyleSheet.create({ + body: { + alignSelf: 'stretch', + fontSize: 14, + fontWeight: '400', + lineHeight: 20, + }, + bodyContainer: { + alignItems: 'center', + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'row', + paddingTop: 4, + }, + button: { + borderRadius: 32, + gap: 8, + }, + buttonContainer: { + alignItems: 'flex-start', + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'row', + gap: 12, + width: '100%', + }, + buttonText: { + fontSize: 14, + fontWeight: '400', + lineHeight: 20, + paddingHorizontal: 12, + paddingVertical: 8, + }, + container: { + alignItems: 'flex-start', + borderStyle: 'solid', + boxShadow: + '0 1px 1px 0 rgba(0, 0, 0, 0.06), 0 0 2px 0 rgba(0, 0, 0, 0.06), 0 0 1px 0 rgba(0, 0, 0, 0.08)', + display: 'flex', + flexDirection: 'column', + gap: 16, + justifyContent: 'center', + padding: 16, + width: '100%', + }, + mediaContainer: { + alignItems: 'flex-start', + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'row', + }, + mediaImage: { + backgroundColor: embeddedMediaImageBackgroundColors.banner, + borderColor: embeddedMediaImageBorderColors.banner, + borderRadius: 6, + borderStyle: 'solid', + borderWidth: 1, + height: IMAGE_HEIGHT, + paddingHorizontal: 0, + paddingVertical: 0, + width: IMAGE_WIDTH, + }, + textContainer: { + alignSelf: 'center', + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + flexShrink: 1, + gap: 4, + width: '100%', + }, + title: { + fontSize: 16, + fontWeight: '700', + lineHeight: 16, + paddingBottom: 4, + }, +}); diff --git a/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.tsx b/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.tsx new file mode 100644 index 000000000..d9628da15 --- /dev/null +++ b/src/embedded/components/IterableEmbeddedBanner/IterableEmbeddedBanner.tsx @@ -0,0 +1,111 @@ +import { + Image, + Text, + TouchableOpacity, + View, + type TextStyle, + type ViewStyle, + PixelRatio, +} from 'react-native'; + +import { IterableEmbeddedViewType } from '../../enums'; +import { useEmbeddedView } from '../../hooks/useEmbeddedView'; +import type { IterableEmbeddedComponentProps } from '../../types/IterableEmbeddedViewProps'; +import { + styles, + IMAGE_HEIGHT, + IMAGE_WIDTH, +} from './IterableEmbeddedBanner.styles'; + +/** + * TODO: figure out how default action works. + */ + +export const IterableEmbeddedBanner = ({ + config, + message, + onButtonClick = () => {}, +}: IterableEmbeddedComponentProps) => { + const { parsedStyles, media, handleButtonClick } = useEmbeddedView( + IterableEmbeddedViewType.Banner, + { message, config, onButtonClick } + ); + + const buttons = message.elements?.buttons ?? []; + + return ( + + {/* eslint-disable-next-line react-native/no-inline-styles */} + + + + {message.elements?.title} + + + {message.elements?.body} + + + {media.shouldShow && ( + + {media.caption + + )} + + {buttons.length > 0 && ( + + {buttons.map((button, index) => { + const backgroundColor = + index === 0 + ? parsedStyles.primaryBtnBackgroundColor + : parsedStyles.secondaryBtnBackgroundColor; + const textColor = + index === 0 + ? parsedStyles.primaryBtnTextColor + : parsedStyles.secondaryBtnTextColor; + return ( + handleButtonClick(button)} + key={button.id} + > + + {button.title} + + + ); + })} + + )} + + ); +}; diff --git a/src/embedded/components/IterableEmbeddedBanner/index.ts b/src/embedded/components/IterableEmbeddedBanner/index.ts new file mode 100644 index 000000000..bd574a288 --- /dev/null +++ b/src/embedded/components/IterableEmbeddedBanner/index.ts @@ -0,0 +1 @@ +export * from './IterableEmbeddedBanner'; diff --git a/src/embedded/components/IterableEmbeddedCard/IterableEmbeddedCard.constants.ts b/src/embedded/components/IterableEmbeddedCard/IterableEmbeddedCard.constants.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/embedded/components/IterableEmbeddedCard/IterableEmbeddedCard.styles.ts b/src/embedded/components/IterableEmbeddedCard/IterableEmbeddedCard.styles.ts new file mode 100644 index 000000000..1a728c657 --- /dev/null +++ b/src/embedded/components/IterableEmbeddedCard/IterableEmbeddedCard.styles.ts @@ -0,0 +1,88 @@ +import { StyleSheet } from 'react-native'; +import { embeddedMediaImageBackgroundColors } from '../../constants/embeddedViewDefaults'; + +export const IMAGE_HEIGHT = 230; +export const PLACEHOLDER_IMAGE_HEIGHT = 56; +export const PLACEHOLDER_IMAGE_WIDTH = 56; + +export const styles = StyleSheet.create({ + body: { + alignSelf: 'stretch', + fontSize: 14, + fontWeight: '400', + lineHeight: 20, + }, + bodyContainer: { + alignItems: 'flex-start', + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'column', + gap: 24, + paddingBottom: 16, + paddingHorizontal: 16, + paddingTop: 12, + }, + button: { + borderRadius: 32, + gap: 8, + }, + buttonContainer: { + alignItems: 'flex-start', + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'row', + gap: 12, + width: '100%', + }, + buttonText: { + fontSize: 14, + fontWeight: '700', + lineHeight: 20, + }, + container: { + alignItems: 'center', + borderStyle: 'solid', + boxShadow: + '0 1px 1px 0 rgba(0, 0, 0, 0.06), 0 0 2px 0 rgba(0, 0, 0, 0.06), 0 0 1px 0 rgba(0, 0, 0, 0.08)', + display: 'flex', + flexDirection: 'column', + gap: 16, + justifyContent: 'center', + overflow: 'hidden', + width: '100%', + }, + mediaContainer: { + alignItems: 'flex-start', + alignSelf: 'stretch', + backgroundColor: embeddedMediaImageBackgroundColors.card, + display: 'flex', + flexDirection: 'row', + height: IMAGE_HEIGHT, + }, + mediaContainerNoImage: { + alignItems: 'center', + justifyContent: 'center', + }, + mediaImage: { + height: IMAGE_HEIGHT, + paddingHorizontal: 0, + paddingVertical: 0, + width: '100%', + }, + mediaImagePlaceholder: { + height: PLACEHOLDER_IMAGE_HEIGHT, + opacity: 0.25, + width: PLACEHOLDER_IMAGE_WIDTH, + }, + textContainer: { + alignItems: 'flex-start', + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'column', + gap: 8, + }, + title: { + fontSize: 18, + fontWeight: '700', + }, +}); diff --git a/src/embedded/components/IterableEmbeddedCard/IterableEmbeddedCard.tsx b/src/embedded/components/IterableEmbeddedCard/IterableEmbeddedCard.tsx new file mode 100644 index 000000000..17cafac30 --- /dev/null +++ b/src/embedded/components/IterableEmbeddedCard/IterableEmbeddedCard.tsx @@ -0,0 +1,123 @@ +import { + Image, + PixelRatio, + Text, + TouchableOpacity, + View, + type TextStyle, + type ViewStyle, +} from 'react-native'; + +import { IterableEmbeddedViewType } from '../../enums'; +import { useEmbeddedView } from '../../hooks/useEmbeddedView'; +import type { IterableEmbeddedComponentProps } from '../../types/IterableEmbeddedViewProps'; +import { IMAGE_HEIGHT, styles } from './IterableEmbeddedCard.styles'; + +/** + * TODO: Add default action click handler. See IterableEmbeddedView for functionality. + */ + +export const IterableEmbeddedCard = ({ + config, + message, + onButtonClick = () => {}, +}: IterableEmbeddedComponentProps) => { + const { parsedStyles, media, handleButtonClick, componentRef, handleLayout } = + useEmbeddedView(IterableEmbeddedViewType.Card, { + message, + config, + onButtonClick, + }); + const buttons = message?.elements?.buttons ?? []; + + return ( + + + {media.caption + + + + + {message.elements?.title} + + + {message.elements?.body} + + + {buttons.length > 0 && ( + + {buttons.map((button, index) => { + const backgroundColor = + index === 0 + ? parsedStyles.primaryBtnBackgroundColor + : parsedStyles.secondaryBtnBackgroundColor; + const textColor = + index === 0 + ? parsedStyles.primaryBtnTextColor + : parsedStyles.secondaryBtnTextColor; + return ( + handleButtonClick(button)} + key={button.id} + > + + {button.title} + + + ); + })} + + )} + + + ); +}; diff --git a/src/embedded/components/IterableEmbeddedCard/index.ts b/src/embedded/components/IterableEmbeddedCard/index.ts new file mode 100644 index 000000000..748f2064f --- /dev/null +++ b/src/embedded/components/IterableEmbeddedCard/index.ts @@ -0,0 +1 @@ +export * from './IterableEmbeddedCard'; diff --git a/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.styles.ts b/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.styles.ts new file mode 100644 index 000000000..923df66fc --- /dev/null +++ b/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.styles.ts @@ -0,0 +1,54 @@ +import { StyleSheet } from 'react-native'; + +export const styles = StyleSheet.create({ + body: { + alignSelf: 'stretch', + fontSize: 14, + fontWeight: '400', + lineHeight: 20, + }, + bodyContainer: { + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + flexShrink: 1, + gap: 4, + width: '100%', + }, + button: { + borderRadius: 32, + gap: 8, + paddingHorizontal: 12, + paddingVertical: 8, + }, + buttonContainer: { + alignItems: 'flex-start', + alignSelf: 'stretch', + display: 'flex', + flexDirection: 'row', + gap: 12, + width: '100%', + }, + buttonText: { + fontSize: 14, + fontWeight: '700', + lineHeight: 20, + }, + container: { + alignItems: 'flex-start', + borderStyle: 'solid', + boxShadow: + '0 1px 1px 0 rgba(0, 0, 0, 0.06), 0 0 2px 0 rgba(0, 0, 0, 0.06), 0 0 1px 0 rgba(0, 0, 0, 0.08)', + display: 'flex', + flexDirection: 'column', + gap: 8, + justifyContent: 'center', + padding: 16, + width: '100%', + }, + title: { + fontSize: 16, + fontWeight: '700', + lineHeight: 24, + }, +}); diff --git a/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.tsx b/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.tsx new file mode 100644 index 000000000..dc41f300f --- /dev/null +++ b/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.tsx @@ -0,0 +1,86 @@ +import { + Text, + TouchableOpacity, + View, + type TextStyle, + type ViewStyle, +} from 'react-native'; + +import { IterableEmbeddedViewType } from '../../enums/IterableEmbeddedViewType'; +import { useEmbeddedView } from '../../hooks/useEmbeddedView'; +import type { IterableEmbeddedComponentProps } from '../../types/IterableEmbeddedViewProps'; +import { styles } from './IterableEmbeddedNotification.styles'; + +export const IterableEmbeddedNotification = ({ + config, + message, + onButtonClick = () => {}, +}: IterableEmbeddedComponentProps) => { + const { parsedStyles, handleButtonClick } = useEmbeddedView( + IterableEmbeddedViewType.Notification, + { message, config, onButtonClick } + ); + + const buttons = message.elements?.buttons ?? []; + + return ( + + {} + + + {message.elements?.title} + + + {message.elements?.body} + + + {buttons.length > 0 && ( + + {buttons.map((button, index) => { + const backgroundColor = + index === 0 + ? parsedStyles.primaryBtnBackgroundColor + : parsedStyles.secondaryBtnBackgroundColor; + const textColor = + index === 0 + ? parsedStyles.primaryBtnTextColor + : parsedStyles.secondaryBtnTextColor; + return ( + handleButtonClick(button)} + key={button.id} + > + + {button.title} + + + ); + })} + + )} + + ); +}; diff --git a/src/embedded/components/IterableEmbeddedNotification/index.ts b/src/embedded/components/IterableEmbeddedNotification/index.ts new file mode 100644 index 000000000..23c458a11 --- /dev/null +++ b/src/embedded/components/IterableEmbeddedNotification/index.ts @@ -0,0 +1 @@ +export * from './IterableEmbeddedNotification'; diff --git a/src/embedded/components/IterableEmbeddedView/IterableEmbeddedView.styles.ts b/src/embedded/components/IterableEmbeddedView/IterableEmbeddedView.styles.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/embedded/components/IterableEmbeddedView/IterableEmbeddedView.tsx b/src/embedded/components/IterableEmbeddedView/IterableEmbeddedView.tsx new file mode 100644 index 000000000..b45e2259b --- /dev/null +++ b/src/embedded/components/IterableEmbeddedView/IterableEmbeddedView.tsx @@ -0,0 +1,34 @@ +import { useMemo } from 'react'; + +import { IterableEmbeddedViewType } from '../../enums'; + +import { IterableEmbeddedBanner } from '../IterableEmbeddedBanner'; +import { IterableEmbeddedCard } from '../IterableEmbeddedCard'; +import { IterableEmbeddedNotification } from '../IterableEmbeddedNotification'; +import type { IterableEmbeddedComponentProps } from '../../types/IterableEmbeddedViewProps'; + +interface IterableEmbeddedViewProps extends IterableEmbeddedComponentProps { + viewType: IterableEmbeddedViewType; +} + +export const IterableEmbeddedView = ({ + viewType, + ...props +}: IterableEmbeddedViewProps) => { + console.log(`🚀 > IterableEmbeddedView > viewType:`, viewType); + + const Cmp = useMemo(() => { + switch (viewType) { + case IterableEmbeddedViewType.Card: + return IterableEmbeddedCard; + case IterableEmbeddedViewType.Notification: + return IterableEmbeddedNotification; + case IterableEmbeddedViewType.Banner: + return IterableEmbeddedBanner; + default: + return null; + } + }, [viewType]); + + return Cmp ? : null; +}; diff --git a/src/embedded/components/IterableEmbeddedView/index.ts b/src/embedded/components/IterableEmbeddedView/index.ts new file mode 100644 index 000000000..e8c383bfb --- /dev/null +++ b/src/embedded/components/IterableEmbeddedView/index.ts @@ -0,0 +1 @@ +export * from './IterableEmbeddedView'; diff --git a/src/embedded/components/index.ts b/src/embedded/components/index.ts new file mode 100644 index 000000000..59c1b84f4 --- /dev/null +++ b/src/embedded/components/index.ts @@ -0,0 +1 @@ +export * from './IterableEmbeddedView/IterableEmbeddedView'; diff --git a/src/embedded/constants/embeddedViewDefaults.ts b/src/embedded/constants/embeddedViewDefaults.ts new file mode 100644 index 000000000..bae1c8799 --- /dev/null +++ b/src/embedded/constants/embeddedViewDefaults.ts @@ -0,0 +1,85 @@ +export const embeddedBackgroundColors = { + notification: '#ffffff', + card: '#ffffff', + banner: '#ffffff', +}; + +export const embeddedBorderColors = { + notification: '#E0DEDF', + card: '#E0DEDF', + banner: '#E0DEDF', +}; + +export const embeddedPrimaryBtnBackgroundColors = { + notification: '#6A266D', + card: 'transparent', + banner: '#6A266D', +}; + +export const embeddedPrimaryBtnTextColors = { + notification: '#ffffff', + card: '#79347F', + banner: '#ffffff', +}; + +export const embeddedSecondaryBtnBackgroundColors = { + notification: 'transparent', + card: 'transparent', + banner: 'transparent', +}; + +export const embeddedSecondaryBtnTextColors = { + notification: '#79347F', + card: '#79347F', + banner: '#79347F', +}; + +export const embeddedTitleTextColors = { + notification: '#3D3A3B', + card: '#3D3A3B', + banner: '#3D3A3B', +}; + +export const embeddedBodyTextColors = { + notification: '#787174', + card: '#787174', + banner: '#787174', +}; + +export const embeddedBorderRadius = { + notification: 8, + card: 6, + banner: 8, +}; + +export const embeddedBorderWidth = { + notification: 1, + card: 1, + banner: 1, +}; + +export const embeddedMediaImageBorderColors = { + notification: '#E0DEDF', + card: '#E0DEDF', + banner: '#E0DEDF', +}; + +export const embeddedMediaImageBackgroundColors = { + notification: '#F5F4F4', + card: '#F5F4F4', + banner: '#F5F4F4', +}; + +export const embeddedStyles = { + background: embeddedBackgroundColors, + border: embeddedBorderColors, + primaryBtnBackground: embeddedPrimaryBtnBackgroundColors, + primaryBtnText: embeddedPrimaryBtnTextColors, + secondaryBtnBackground: embeddedSecondaryBtnBackgroundColors, + secondaryBtnText: embeddedSecondaryBtnTextColors, + titleText: embeddedTitleTextColors, + bodyText: embeddedBodyTextColors, + mediaImageBorder: embeddedMediaImageBorderColors, + borderRadius: embeddedBorderRadius, + borderWidth: embeddedBorderWidth, +}; diff --git a/src/embedded/enums/IterableEmbeddedViewType.ts b/src/embedded/enums/IterableEmbeddedViewType.ts new file mode 100644 index 000000000..90a0b5d7e --- /dev/null +++ b/src/embedded/enums/IterableEmbeddedViewType.ts @@ -0,0 +1,11 @@ +/** + * The view type for an embedded message. + */ +export enum IterableEmbeddedViewType { + /** The embedded view is a banner */ + Banner = 0, + /** The embedded view is a card */ + Card = 1, + /** The embedded view is a notification */ + Notification = 2, +} diff --git a/src/embedded/enums/index.ts b/src/embedded/enums/index.ts new file mode 100644 index 000000000..511ad021b --- /dev/null +++ b/src/embedded/enums/index.ts @@ -0,0 +1 @@ +export * from './IterableEmbeddedViewType'; diff --git a/src/embedded/hooks/useEmbeddedView.ts b/src/embedded/hooks/useEmbeddedView.ts new file mode 100644 index 000000000..36656c7db --- /dev/null +++ b/src/embedded/hooks/useEmbeddedView.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Iterable } from '../../core/classes/Iterable'; +import { useAppStateListener } from '../../core/hooks/useAppStateListener'; +import { useComponentVisibility } from '../../core/hooks/useComponentVisibility'; +import { IterableEmbeddedViewType } from '../enums'; +import type { IterableEmbeddedMessageElementsButton } from '../types/IterableEmbeddedMessageElementsButton'; +import type { IterableEmbeddedComponentProps } from '../types/IterableEmbeddedViewProps'; +import { getMedia } from '../utils/getMedia'; +import { getStyles } from '../utils/getStyles'; +import { getUrlFromButton } from '../utils/getUrlFromButton'; + +export const useEmbeddedView = ( + viewType: IterableEmbeddedViewType, + { message, config, onButtonClick = () => {} }: IterableEmbeddedComponentProps +) => { + const appVisibility = useAppStateListener(); + const { isVisible, componentRef, handleLayout } = useComponentVisibility({ + threshold: 0.1, // Component is considered visible if 10% is on screen + checkOnAppState: true, // Consider app state (active/background) + enablePeriodicCheck: true, // Enable periodic checking for navigation changes + checkInterval: 500, // Check every 500ms for navigation changes + }); + + const parsedStyles = useMemo(() => { + return getStyles(viewType, config); + }, [viewType, config]); + const media = useMemo(() => { + return getMedia(viewType, message); + }, [viewType, message]); + + const [lastState, setLastState] = useState('initial'); + + const handleButtonClick = useCallback( + (button: IterableEmbeddedMessageElementsButton) => { + onButtonClick(button); + Iterable.embeddedManager.handleClick( + message, + button.id, + getUrlFromButton(button) ?? null + ); + }, + [onButtonClick, message] + ); + + useEffect(() => { + if (appVisibility !== lastState) { + setLastState(appVisibility); + if (appVisibility === 'active') { + // App is active, start the session + // TODO: figure out how to only do this once, even if there are multiple embedded views + Iterable.embeddedManager.startSession(); + } else if ( + appVisibility === 'background' || + appVisibility === 'inactive' + ) { + // App is background or inactive, end the session + // TODO: figure out how to only do this once, even if there are multiple embedded views + Iterable.embeddedManager.endSession(); + } + } + }, [appVisibility, lastState]); + + useEffect(() => { + console.log('Card visibility changed:', isVisible); + if (isVisible) { + // TODO: Start impression here + } else { + // TODO: Pause impression here + } + }, [isVisible]); + + return { + parsedStyles, + media, + handleButtonClick, + componentRef, + handleLayout, + }; +}; diff --git a/src/embedded/index.ts b/src/embedded/index.ts new file mode 100644 index 000000000..07635cbbc --- /dev/null +++ b/src/embedded/index.ts @@ -0,0 +1 @@ +export * from './components'; diff --git a/src/embedded/types/IterableEmbeddedMessage.ts b/src/embedded/types/IterableEmbeddedMessage.ts new file mode 100644 index 000000000..35724f2b8 --- /dev/null +++ b/src/embedded/types/IterableEmbeddedMessage.ts @@ -0,0 +1,8 @@ +import type { IterableEmbeddedMessageElements } from './IterableEmbeddedMessageElements'; +import type { IterableEmbeddedMessageMetadata } from './IterableEmbeddedMessageMetadata'; + +export interface IterableEmbeddedMessage { + metadata: IterableEmbeddedMessageMetadata; + elements?: IterableEmbeddedMessageElements | null; + payload?: Record | null; +} diff --git a/src/embedded/types/IterableEmbeddedMessageElements.ts b/src/embedded/types/IterableEmbeddedMessageElements.ts new file mode 100644 index 000000000..ffacc9bff --- /dev/null +++ b/src/embedded/types/IterableEmbeddedMessageElements.ts @@ -0,0 +1,13 @@ +import type { IterableEmbeddedMessageElementsButton } from './IterableEmbeddedMessageElementsButton'; +import type { IterableEmbeddedMessageElementsDefaultAction } from './IterableEmbeddedMessageElementsDefaultAction'; +import type { IterableEmbeddedMessageElementsText } from './IterableEmbeddedMessageElementsText'; + +export interface IterableEmbeddedMessageElements { + title?: string | null; + body?: string | null; + mediaUrl?: string | null; + mediaUrlCaption?: string | null; + defaultAction?: IterableEmbeddedMessageElementsDefaultAction | null; + buttons?: IterableEmbeddedMessageElementsButton[] | null; + text?: IterableEmbeddedMessageElementsText[] | null; +} diff --git a/src/embedded/types/IterableEmbeddedMessageElementsButton.ts b/src/embedded/types/IterableEmbeddedMessageElementsButton.ts new file mode 100644 index 000000000..de287d691 --- /dev/null +++ b/src/embedded/types/IterableEmbeddedMessageElementsButton.ts @@ -0,0 +1,10 @@ +import type { IterableEmbeddedMessageElementsButtonAction } from './IterableEmbeddedMessageElementsButtonAction'; + +export interface IterableEmbeddedMessageElementsButton { + /** The ID. */ + id: string; + /** The title. */ + title?: string | null; + /** The action. */ + action?: IterableEmbeddedMessageElementsButtonAction | null; +} diff --git a/src/embedded/types/IterableEmbeddedMessageElementsButtonAction.ts b/src/embedded/types/IterableEmbeddedMessageElementsButtonAction.ts new file mode 100644 index 000000000..7b5204f4a --- /dev/null +++ b/src/embedded/types/IterableEmbeddedMessageElementsButtonAction.ts @@ -0,0 +1,6 @@ +export interface IterableEmbeddedMessageElementsButtonAction { + /** The type. */ + type: string; + /** The data. */ + data?: string; +} diff --git a/src/embedded/types/IterableEmbeddedMessageElementsDefaultAction.ts b/src/embedded/types/IterableEmbeddedMessageElementsDefaultAction.ts new file mode 100644 index 000000000..4e8a2835c --- /dev/null +++ b/src/embedded/types/IterableEmbeddedMessageElementsDefaultAction.ts @@ -0,0 +1,6 @@ +export interface IterableEmbeddedMessageElementsDefaultAction { + /** The type. */ + type: string; + /** The data. */ + data?: string; +} 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..c4e1350ad --- /dev/null +++ b/src/embedded/types/IterableEmbeddedMessageMetadata.ts @@ -0,0 +1,6 @@ +export interface IterableEmbeddedMessageMetadata { + messageId: string; + placementId: number; + campaignId?: number | null; + isProof?: boolean; +} diff --git a/src/embedded/types/IterableEmbeddedPlacement.ts b/src/embedded/types/IterableEmbeddedPlacement.ts new file mode 100644 index 000000000..8c0c48bae --- /dev/null +++ b/src/embedded/types/IterableEmbeddedPlacement.ts @@ -0,0 +1,6 @@ +import type { IterableEmbeddedMessage } from './IterableEmbeddedMessage'; + +export interface IterableEmbeddedPlacement { + placementId: number; + messages: IterableEmbeddedMessage[]; +} diff --git a/src/embedded/types/IterableEmbeddedViewButtonInfo.ts b/src/embedded/types/IterableEmbeddedViewButtonInfo.ts new file mode 100644 index 000000000..54326aea9 --- /dev/null +++ b/src/embedded/types/IterableEmbeddedViewButtonInfo.ts @@ -0,0 +1,5 @@ +export interface IterableEmbeddedViewButtonInfo { + id?: string | null; + title?: string | null; + clickedUrl?: string | null; +} diff --git a/src/embedded/types/IterableEmbeddedViewConfig.ts b/src/embedded/types/IterableEmbeddedViewConfig.ts new file mode 100644 index 000000000..6a41edd8a --- /dev/null +++ b/src/embedded/types/IterableEmbeddedViewConfig.ts @@ -0,0 +1,27 @@ +import type { ColorValue } from 'react-native'; + +/** + * Represents view-level styling configuration for an embedded view. + */ +export interface IterableEmbeddedViewConfig { + /** Background color hex (e.g., 0xFF0000) */ + backgroundColor?: ColorValue; + /** Border color hex */ + borderColor?: ColorValue; + /** Border width in pixels */ + borderWidth?: number; + /** Corner radius in points */ + borderCornerRadius?: number; + /** Primary button background color hex */ + primaryBtnBackgroundColor?: ColorValue; + /** Primary button text color hex */ + primaryBtnTextColor?: ColorValue; + /** Secondary button background color hex */ + secondaryBtnBackgroundColor?: ColorValue; + /** Secondary button text color hex */ + secondaryBtnTextColor?: ColorValue; + /** Title text color hex */ + titleTextColor?: ColorValue; + /** Body text color hex */ + bodyTextColor?: ColorValue; +} diff --git a/src/embedded/types/IterableEmbeddedViewProps.ts b/src/embedded/types/IterableEmbeddedViewProps.ts new file mode 100644 index 000000000..9f2b17670 --- /dev/null +++ b/src/embedded/types/IterableEmbeddedViewProps.ts @@ -0,0 +1,9 @@ +import type { IterableEmbeddedMessage } from './IterableEmbeddedMessage'; +import type { IterableEmbeddedMessageElementsButton } from './IterableEmbeddedMessageElementsButton'; +import type { IterableEmbeddedViewConfig } from './IterableEmbeddedViewConfig'; + +export interface IterableEmbeddedComponentProps { + message: IterableEmbeddedMessage; + config?: IterableEmbeddedViewConfig | null; + onButtonClick?: (button: IterableEmbeddedMessageElementsButton) => void; +} diff --git a/src/embedded/types/IterableEmbeddedViewStyles.ts b/src/embedded/types/IterableEmbeddedViewStyles.ts new file mode 100644 index 000000000..d92a54f15 --- /dev/null +++ b/src/embedded/types/IterableEmbeddedViewStyles.ts @@ -0,0 +1,12 @@ +export interface IterableEmbeddedViewStyles { + backgroundColor: number | string; + borderColor: number | string; + borderWidth: number; + borderCornerRadius: number; + primaryBtnBackgroundColor: number | string; + primaryBtnTextColor: number | string; + secondaryBtnBackgroundColor: number | string; + secondaryBtnTextColor: number | string; + titleTextColor: number | string; + bodyTextColor: number | string; +} diff --git a/src/embedded/types/index.ts b/src/embedded/types/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/embedded/utils/getButtons.ts b/src/embedded/utils/getButtons.ts new file mode 100644 index 000000000..e2eb52a44 --- /dev/null +++ b/src/embedded/utils/getButtons.ts @@ -0,0 +1,24 @@ +import type { IterableEmbeddedMessage } from '../types/IterableEmbeddedMessage'; +import type { IterableEmbeddedMessageElementsButton } from '../types/IterableEmbeddedMessageElementsButton'; +import type { IterableEmbeddedViewButtonInfo } from '../types/IterableEmbeddedViewButtonInfo'; + +export const getButtons = (message: IterableEmbeddedMessage) => { + const buttons = message.elements?.buttons ?? null; + if (!buttons || buttons.length === 0) return []; + + const mapOne = ( + b?: IterableEmbeddedMessageElementsButton | null + ): IterableEmbeddedViewButtonInfo => { + if (!b) return { id: null, title: null, clickedUrl: null }; + const clickedUrl = + (b.action?.data && b.action?.data?.length > 0 + ? b.action.data + : b.action?.type) ?? null; + return { id: b.id ?? null, title: b.title ?? null, clickedUrl }; + }; + + const first = mapOne(buttons[0] ?? null); + const second = mapOne(buttons.length > 1 ? buttons[1] : null); + + return [first, second].filter((bi) => bi.title && bi.title.length > 0); +}; diff --git a/src/embedded/utils/getDefaultActionUrl.ts b/src/embedded/utils/getDefaultActionUrl.ts new file mode 100644 index 000000000..be1d00dac --- /dev/null +++ b/src/embedded/utils/getDefaultActionUrl.ts @@ -0,0 +1,7 @@ +import type { IterableEmbeddedMessage } from '../types/IterableEmbeddedMessage'; + +export const getDefaultActionUrl = (message: IterableEmbeddedMessage) => { + const defaultAction = message.elements?.defaultAction ?? null; + if (!defaultAction) return null; + return defaultAction.data ?? defaultAction.type; +}; diff --git a/src/embedded/utils/getDefaultStyle.ts b/src/embedded/utils/getDefaultStyle.ts new file mode 100644 index 000000000..b31ab757e --- /dev/null +++ b/src/embedded/utils/getDefaultStyle.ts @@ -0,0 +1,19 @@ +import { IterableEmbeddedViewType } from '../enums'; + +export const getDefaultStyle = ( + viewType: IterableEmbeddedViewType, + colors: { + banner: number | string; + card: number | string; + notification: number | string; + } +) => { + switch (viewType) { + case IterableEmbeddedViewType.Notification: + return colors.notification; + case IterableEmbeddedViewType.Card: + return colors.card; + default: + return colors.banner; + } +}; diff --git a/src/embedded/utils/getMedia.ts b/src/embedded/utils/getMedia.ts new file mode 100644 index 000000000..5063d91f8 --- /dev/null +++ b/src/embedded/utils/getMedia.ts @@ -0,0 +1,15 @@ +import type { IterableEmbeddedMessage } from '../types/IterableEmbeddedMessage'; +import { IterableEmbeddedViewType } from '../enums'; + +export const getMedia = ( + viewType: IterableEmbeddedViewType, + message: IterableEmbeddedMessage +) => { + if (viewType === IterableEmbeddedViewType.Notification) { + return { url: null, caption: null, shouldShow: false }; + } + const url = message.elements?.mediaUrl ?? null; + const caption = message.elements?.mediaUrlCaption ?? null; + const shouldShow = !!url && url.length > 0; + return { url, caption, shouldShow }; +}; diff --git a/src/embedded/utils/getStyles.ts b/src/embedded/utils/getStyles.ts new file mode 100644 index 000000000..aa2212f81 --- /dev/null +++ b/src/embedded/utils/getStyles.ts @@ -0,0 +1,38 @@ +import type { IterableEmbeddedViewConfig } from '../types/IterableEmbeddedViewConfig'; +import { embeddedStyles } from '../constants/embeddedViewDefaults'; +import type { IterableEmbeddedViewType } from '../enums'; +import { getDefaultStyle } from './getDefaultStyle'; + +export const getStyles = ( + viewType: IterableEmbeddedViewType, + c?: IterableEmbeddedViewConfig | null +) => { + return { + backgroundColor: + c?.backgroundColor ?? + getDefaultStyle(viewType, embeddedStyles.background), + borderColor: + c?.borderColor ?? getDefaultStyle(viewType, embeddedStyles.border), + borderWidth: + c?.borderWidth ?? getDefaultStyle(viewType, embeddedStyles.borderWidth), + borderCornerRadius: + c?.borderCornerRadius ?? + getDefaultStyle(viewType, embeddedStyles.borderRadius), + primaryBtnBackgroundColor: + c?.primaryBtnBackgroundColor ?? + getDefaultStyle(viewType, embeddedStyles.primaryBtnBackground), + primaryBtnTextColor: + c?.primaryBtnTextColor ?? + getDefaultStyle(viewType, embeddedStyles.primaryBtnText), + secondaryBtnBackgroundColor: + c?.secondaryBtnBackgroundColor ?? + getDefaultStyle(viewType, embeddedStyles.secondaryBtnBackground), + secondaryBtnTextColor: + c?.secondaryBtnTextColor ?? + getDefaultStyle(viewType, embeddedStyles.secondaryBtnText), + titleTextColor: + c?.titleTextColor ?? getDefaultStyle(viewType, embeddedStyles.titleText), + bodyTextColor: + c?.bodyTextColor ?? getDefaultStyle(viewType, embeddedStyles.bodyText), + }; +}; diff --git a/src/embedded/utils/getUrlFromButton.ts b/src/embedded/utils/getUrlFromButton.ts new file mode 100644 index 000000000..90adb4d33 --- /dev/null +++ b/src/embedded/utils/getUrlFromButton.ts @@ -0,0 +1,8 @@ +import type { IterableEmbeddedMessageElementsButton } from '../types/IterableEmbeddedMessageElementsButton'; + +export const getUrlFromButton = ( + button: IterableEmbeddedMessageElementsButton +) => { + const { data, type: actionType } = button.action ?? {}; + return data && data?.length > 0 ? data : actionType; +}; diff --git a/src/inApp/classes/IterableInAppManager.ts b/src/inApp/classes/IterableInAppManager.ts index c14867c62..7785a4749 100644 --- a/src/inApp/classes/IterableInAppManager.ts +++ b/src/inApp/classes/IterableInAppManager.ts @@ -1,5 +1,5 @@ import { IterableApi } from '../../core/classes/IterableApi'; -import { IterableLogger } from '../../core/classes/IterableLogger'; +import type { IterableLogger } from '../../core/classes/IterableLogger'; import { defaultLogger } from '../../core/constants/defaults'; import type { IterableInAppDeleteSource } from '../enums/IterableInAppDeleteSource'; import type { IterableInAppLocation } from '../enums/IterableInAppLocation'; diff --git a/src/inApp/types/IterableEmbeddedUpdateHandler.ts b/src/inApp/types/IterableEmbeddedUpdateHandler.ts new file mode 100644 index 000000000..26dbd037e --- /dev/null +++ b/src/inApp/types/IterableEmbeddedUpdateHandler.ts @@ -0,0 +1,4 @@ +export interface IterableEmbeddedUpdateHandler { + onMessagesUpdated: () => void; + onEmbeddedMessagingDisabled: () => void; +} diff --git a/src/inbox/components/IterableInbox.tsx b/src/inbox/components/IterableInbox.tsx index 3cf44d829..0085020c2 100644 --- a/src/inbox/components/IterableInbox.tsx +++ b/src/inbox/components/IterableInbox.tsx @@ -32,7 +32,6 @@ import { type IterableInboxMessageListProps, } from './IterableInboxMessageList'; - const RNEventEmitter = new NativeEventEmitter(RNIterableAPI); const DEFAULT_HEADLINE_HEIGHT = 60; @@ -363,7 +362,7 @@ export const IterableInbox = ({ inboxDataModel.setMessageAsRead(id); setSelectedRowViewModelIdx(index); - Iterable.trackInAppOpen( + Iterable.tracker.trackInAppOpen( // MOB-10428: Have a safety check for models[index].inAppMessage // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore diff --git a/src/inbox/components/IterableInboxMessageDisplay.tsx b/src/inbox/components/IterableInboxMessageDisplay.tsx index 7e6798c73..1abadeb95 100644 --- a/src/inbox/components/IterableInboxMessageDisplay.tsx +++ b/src/inbox/components/IterableInboxMessageDisplay.tsx @@ -179,12 +179,12 @@ export const IterableInboxMessageDisplay = ({ const source = IterableActionSource.inApp; const context = new IterableActionContext(action, source); - Iterable.trackInAppClick( + Iterable.tracker.trackInAppClick( rowViewModel.inAppMessage, IterableInAppLocation.inbox, URL ); - Iterable.trackInAppClose( + Iterable.tracker.trackInAppClose( rowViewModel.inAppMessage, IterableInAppLocation.inbox, IterableInAppCloseSource.link, @@ -225,7 +225,7 @@ export const IterableInboxMessageDisplay = ({ { returnToInbox(); - Iterable.trackInAppClose( + Iterable.tracker.trackInAppClose( rowViewModel.inAppMessage, IterableInAppLocation.inbox, IterableInAppCloseSource.back diff --git a/src/index.tsx b/src/index.tsx index 240ac51f5..a260c6f7d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -14,6 +14,7 @@ export { } from './core/classes'; export { IterableActionSource, + IterableActionType, IterableAuthFailureReason, IterableAuthResponseResult, IterableDataRegion, @@ -59,3 +60,7 @@ export { type IterableInboxProps, type IterableInboxRowViewModel, } from './inbox'; +export { type IterableEmbeddedMessage } from './embedded/types/IterableEmbeddedMessage'; +export { IterableEmbeddedView } from './embedded/components'; +export { IterableEmbeddedViewType } from './embedded/enums'; +export type { IterableEmbeddedViewConfig } from './embedded/types/IterableEmbeddedViewConfig';