From 95e8d92311aa76108bfac582979714684f904b3d Mon Sep 17 00:00:00 2001 From: Loren Posen Date: Thu, 12 Feb 2026 14:10:59 -0800 Subject: [PATCH 1/8] feat: add IterableEmbeddedNotification component for displaying notifications --- .../IterableEmbeddedNotification.tsx | 2 +- src/embedded/components/IterableEmbeddedNotification/index.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) rename src/embedded/components/{ => IterableEmbeddedNotification}/IterableEmbeddedNotification.tsx (84%) create mode 100644 src/embedded/components/IterableEmbeddedNotification/index.ts diff --git a/src/embedded/components/IterableEmbeddedNotification.tsx b/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.tsx similarity index 84% rename from src/embedded/components/IterableEmbeddedNotification.tsx rename to src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.tsx index 686ea01e6..4ecbc67c7 100644 --- a/src/embedded/components/IterableEmbeddedNotification.tsx +++ b/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.tsx @@ -1,6 +1,6 @@ import { View, Text } from 'react-native'; -import type { IterableEmbeddedComponentProps } from '../types/IterableEmbeddedComponentProps'; +import type { IterableEmbeddedComponentProps } from '../../types/IterableEmbeddedComponentProps'; export const IterableEmbeddedNotification = ({ config, diff --git a/src/embedded/components/IterableEmbeddedNotification/index.ts b/src/embedded/components/IterableEmbeddedNotification/index.ts new file mode 100644 index 000000000..3a25fd8ee --- /dev/null +++ b/src/embedded/components/IterableEmbeddedNotification/index.ts @@ -0,0 +1,2 @@ +export * from './IterableEmbeddedNotification'; +export { IterableEmbeddedNotification as default } from './IterableEmbeddedNotification'; From ef7cf1de4960d93fc076ad9fd5459111ad6269d5 Mon Sep 17 00:00:00 2001 From: Loren Posen Date: Thu, 12 Feb 2026 14:29:00 -0800 Subject: [PATCH 2/8] feat: add utility functions and styles for embedded views --- .../IterableEmbeddedNotification.styles.ts | 0 src/embedded/contants/embeddedViewDefaults.ts | 85 +++++++++++++++++++ src/embedded/contants/index.ts | 1 + src/embedded/utils/getMedia.ts | 29 +++++++ src/embedded/utils/getStyles.ts | 81 ++++++++++++++++++ src/embedded/utils/index.ts | 2 + 6 files changed, 198 insertions(+) create mode 100644 src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.styles.ts create mode 100644 src/embedded/contants/embeddedViewDefaults.ts create mode 100644 src/embedded/contants/index.ts create mode 100644 src/embedded/utils/getMedia.ts create mode 100644 src/embedded/utils/getStyles.ts create mode 100644 src/embedded/utils/index.ts diff --git a/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.styles.ts b/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.styles.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/embedded/contants/embeddedViewDefaults.ts b/src/embedded/contants/embeddedViewDefaults.ts new file mode 100644 index 000000000..bae1c8799 --- /dev/null +++ b/src/embedded/contants/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/contants/index.ts b/src/embedded/contants/index.ts new file mode 100644 index 000000000..4324689be --- /dev/null +++ b/src/embedded/contants/index.ts @@ -0,0 +1 @@ +export * from './embeddedViewDefaults'; diff --git a/src/embedded/utils/getMedia.ts b/src/embedded/utils/getMedia.ts new file mode 100644 index 000000000..a9c1325c2 --- /dev/null +++ b/src/embedded/utils/getMedia.ts @@ -0,0 +1,29 @@ +import type { IterableEmbeddedMessage } from '../types/IterableEmbeddedMessage'; +import { IterableEmbeddedViewType } from '../enums'; + +/** + * This function is used to get the media to render for a given embedded view + * type and message. + * + * @param viewType - The type of view to render. + * @param message - The message to render. + * @returns The media to render. + * + * @example + * const media = getMedia(IterableEmbeddedViewType.Notification, message); + * console.log(media); + */ +export const getMedia = ( + /** The type of view to render. */ + viewType: IterableEmbeddedViewType, + /** The message to render. */ + 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..e233ce96b --- /dev/null +++ b/src/embedded/utils/getStyles.ts @@ -0,0 +1,81 @@ +import type { IterableEmbeddedViewConfig } from '../types/IterableEmbeddedViewConfig'; +import { embeddedStyles } from '../contants/embeddedViewDefaults'; +import { IterableEmbeddedViewType } from '../enums'; + +/** + * Get the default style for the embedded view type. + * + * @param viewType - The type of view to render. + * @param colors - The colors to use for the default style. + * @returns The default style. + */ +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; + } +}; + +/** + * Get the style for the embedded view type. + * + * If a style is provided in the config, it will take precedence over the default style. + * + * @param viewType - The type of view to render. + * @param c - The config to use for the styles. + * @returns The styles. + * + * @example + * const styles = getStyles(IterableEmbeddedViewType.Notification, { + * backgroundColor: '#000000', + * borderColor: '#000000', + * borderWidth: 1, + * borderCornerRadius: 10, + * primaryBtnBackgroundColor: '#000000', + * primaryBtnTextColor: '#000000', + * }); + */ +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/index.ts b/src/embedded/utils/index.ts new file mode 100644 index 000000000..d51bbbedf --- /dev/null +++ b/src/embedded/utils/index.ts @@ -0,0 +1,2 @@ +export * from './getMedia'; +export * from './getStyles'; From 980123dd72c666778ddc66708671e441b7cd76f7 Mon Sep 17 00:00:00 2001 From: Loren Posen Date: Thu, 12 Feb 2026 14:35:35 -0800 Subject: [PATCH 3/8] feat: implement useEmbeddedView hook for managing embedded view lifecycle --- src/embedded/hooks/index.ts | 1 + src/embedded/hooks/useEmbeddedView.ts | 74 +++++++++++++++++++ .../types/IterableEmbeddedComponentProps.ts | 5 ++ 3 files changed, 80 insertions(+) create mode 100644 src/embedded/hooks/index.ts create mode 100644 src/embedded/hooks/useEmbeddedView.ts diff --git a/src/embedded/hooks/index.ts b/src/embedded/hooks/index.ts new file mode 100644 index 000000000..cbca753d9 --- /dev/null +++ b/src/embedded/hooks/index.ts @@ -0,0 +1 @@ +export * from './useEmbeddedView'; diff --git a/src/embedded/hooks/useEmbeddedView.ts b/src/embedded/hooks/useEmbeddedView.ts new file mode 100644 index 000000000..c8effccbd --- /dev/null +++ b/src/embedded/hooks/useEmbeddedView.ts @@ -0,0 +1,74 @@ +import { useCallback, useMemo } from 'react'; +import { Iterable } from '../../core/classes/Iterable'; +import { IterableEmbeddedViewType } from '../enums'; +import type { IterableEmbeddedComponentProps } from '../types/IterableEmbeddedComponentProps'; +import type { IterableEmbeddedMessageElementsButton } from '../types/IterableEmbeddedMessageElementsButton'; +import { getMedia } from '../utils/getMedia'; +import { getStyles } from '../utils/getStyles'; + +/** + * This hook is used to manage the lifecycle of an embedded view. + * + * @param viewType - The type of view to render. + * @param props - The props for the embedded view. + * @returns The embedded view. + * + * @example + * const { handleButtonClick, handleMessageClick, media, parsedStyles } = useEmbeddedView(IterableEmbeddedViewType.Notification, { + * message, + * config, + * onButtonClick, + * onMessageClick, + * }); + * + * return ( + * + * {media.url} + * {media.caption} + * {parsedStyles.backgroundColor} + * + * ); + */ +export const useEmbeddedView = ( + /** The type of view to render. */ + viewType: IterableEmbeddedViewType, + /** The props for the embedded view. */ + { + message, + config, + onButtonClick = () => {}, + onMessageClick = () => {}, + }: IterableEmbeddedComponentProps +) => { + const parsedStyles = useMemo(() => { + return getStyles(viewType, config); + }, [viewType, config]); + const media = useMemo(() => { + return getMedia(viewType, message); + }, [viewType, message]); + + + const handleButtonClick = useCallback( + (button: IterableEmbeddedMessageElementsButton) => { + onButtonClick(button); + Iterable.embeddedManager.handleClick(message, button.id, button.action); + }, + [onButtonClick, message] + ); + + const handleMessageClick = useCallback(() => { + onMessageClick(); + Iterable.embeddedManager.handleClick( + message, + null, + message.elements?.defaultAction + ); + }, [message, onMessageClick]); + + return { + handleButtonClick, + handleMessageClick, + media, + parsedStyles, + }; +}; diff --git a/src/embedded/types/IterableEmbeddedComponentProps.ts b/src/embedded/types/IterableEmbeddedComponentProps.ts index 9f2b17670..f59e2772e 100644 --- a/src/embedded/types/IterableEmbeddedComponentProps.ts +++ b/src/embedded/types/IterableEmbeddedComponentProps.ts @@ -3,7 +3,12 @@ import type { IterableEmbeddedMessageElementsButton } from './IterableEmbeddedMe import type { IterableEmbeddedViewConfig } from './IterableEmbeddedViewConfig'; export interface IterableEmbeddedComponentProps { + /** The message to render. */ message: IterableEmbeddedMessage; + /** The config for the embedded view. */ config?: IterableEmbeddedViewConfig | null; + /** The function to call when a button is clicked. */ onButtonClick?: (button: IterableEmbeddedMessageElementsButton) => void; + /** The function to call when the message is clicked. */ + onMessageClick?: () => void; } From 917835b6cc58ed8f16c3c224ba6f61c06846242a Mon Sep 17 00:00:00 2001 From: Loren Posen Date: Thu, 12 Feb 2026 14:37:30 -0800 Subject: [PATCH 4/8] feat: enhance IterableEmbeddedNotification with styles and button functionality --- .../IterableEmbeddedNotification.styles.ts | 54 +++++++++++ .../IterableEmbeddedNotification.tsx | 94 +++++++++++++++++-- 2 files changed, 138 insertions(+), 10 deletions(-) diff --git a/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.styles.ts b/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.styles.ts index e69de29bb..923df66fc 100644 --- a/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.styles.ts +++ 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 index 4ecbc67c7..f0909cfc5 100644 --- a/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.tsx +++ b/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.tsx @@ -1,22 +1,96 @@ -import { View, Text } from 'react-native'; +import { + Text, + TouchableOpacity, + View, + type TextStyle, + type ViewStyle, + Pressable, +} from 'react-native'; +import { IterableEmbeddedViewType } from '../../enums/IterableEmbeddedViewType'; +import { useEmbeddedView } from '../../hooks/useEmbeddedView'; import type { IterableEmbeddedComponentProps } from '../../types/IterableEmbeddedComponentProps'; +import { styles } from './IterableEmbeddedNotification.styles'; export const IterableEmbeddedNotification = ({ config, message, onButtonClick = () => {}, + onMessageClick = () => {}, }: IterableEmbeddedComponentProps) => { - console.log(`🚀 > IterableEmbeddedNotification > config:`, config); - console.log(`🚀 > IterableEmbeddedNotification > message:`, message); - console.log( - `🚀 > IterableEmbeddedNotification > onButtonClick:`, - onButtonClick - ); + const { parsedStyles, handleButtonClick, handleMessageClick } = + useEmbeddedView(IterableEmbeddedViewType.Notification, { + message, + config, + onButtonClick, + onMessageClick, + }); + + const buttons = message.elements?.buttons ?? []; return ( - - IterableEmbeddedNotification - + handleMessageClick()}> + + {} + + + {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} + + + ); + })} + + )} + + ); }; From 4a47ef20e31cb883070c7a522cfdd317870b2f51 Mon Sep 17 00:00:00 2001 From: Loren Posen Date: Thu, 12 Feb 2026 15:02:11 -0800 Subject: [PATCH 5/8] feat: add hooks for managing embedded view styles and media --- src/embedded/contants/index.ts | 1 - .../useEmbeddedView}/embeddedViewDefaults.ts | 18 +++++++++--------- .../useEmbeddedView}/getMedia.ts | 4 ++-- .../useEmbeddedView}/getStyles.ts | 18 +++++++++--------- src/embedded/hooks/useEmbeddedView/index.ts | 2 ++ .../{ => useEmbeddedView}/useEmbeddedView.ts | 12 ++++++------ src/embedded/index.ts | 2 ++ src/embedded/utils/index.ts | 2 -- 8 files changed, 30 insertions(+), 29 deletions(-) delete mode 100644 src/embedded/contants/index.ts rename src/embedded/{contants => hooks/useEmbeddedView}/embeddedViewDefaults.ts (84%) rename src/embedded/{utils => hooks/useEmbeddedView}/getMedia.ts (85%) rename src/embedded/{utils => hooks/useEmbeddedView}/getStyles.ts (81%) create mode 100644 src/embedded/hooks/useEmbeddedView/index.ts rename src/embedded/hooks/{ => useEmbeddedView}/useEmbeddedView.ts (80%) delete mode 100644 src/embedded/utils/index.ts diff --git a/src/embedded/contants/index.ts b/src/embedded/contants/index.ts deleted file mode 100644 index 4324689be..000000000 --- a/src/embedded/contants/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './embeddedViewDefaults'; diff --git a/src/embedded/contants/embeddedViewDefaults.ts b/src/embedded/hooks/useEmbeddedView/embeddedViewDefaults.ts similarity index 84% rename from src/embedded/contants/embeddedViewDefaults.ts rename to src/embedded/hooks/useEmbeddedView/embeddedViewDefaults.ts index bae1c8799..f20879388 100644 --- a/src/embedded/contants/embeddedViewDefaults.ts +++ b/src/embedded/hooks/useEmbeddedView/embeddedViewDefaults.ts @@ -71,15 +71,15 @@ export const embeddedMediaImageBackgroundColors = { }; export const embeddedStyles = { - background: embeddedBackgroundColors, - border: embeddedBorderColors, - primaryBtnBackground: embeddedPrimaryBtnBackgroundColors, - primaryBtnText: embeddedPrimaryBtnTextColors, - secondaryBtnBackground: embeddedSecondaryBtnBackgroundColors, - secondaryBtnText: embeddedSecondaryBtnTextColors, - titleText: embeddedTitleTextColors, + backgroundColor: embeddedBackgroundColors, bodyText: embeddedBodyTextColors, - mediaImageBorder: embeddedMediaImageBorderColors, - borderRadius: embeddedBorderRadius, + borderColor: embeddedBorderColors, + borderCornerRadius: embeddedBorderRadius, borderWidth: embeddedBorderWidth, + mediaImageBorder: embeddedMediaImageBorderColors, + primaryBtnBackgroundColor: embeddedPrimaryBtnBackgroundColors, + primaryBtnTextColor: embeddedPrimaryBtnTextColors, + secondaryBtnBackground: embeddedSecondaryBtnBackgroundColors, + secondaryBtnTextColor: embeddedSecondaryBtnTextColors, + titleText: embeddedTitleTextColors, }; diff --git a/src/embedded/utils/getMedia.ts b/src/embedded/hooks/useEmbeddedView/getMedia.ts similarity index 85% rename from src/embedded/utils/getMedia.ts rename to src/embedded/hooks/useEmbeddedView/getMedia.ts index a9c1325c2..de0dcb666 100644 --- a/src/embedded/utils/getMedia.ts +++ b/src/embedded/hooks/useEmbeddedView/getMedia.ts @@ -1,5 +1,5 @@ -import type { IterableEmbeddedMessage } from '../types/IterableEmbeddedMessage'; -import { IterableEmbeddedViewType } from '../enums'; +import type { IterableEmbeddedMessage } from '../../types/IterableEmbeddedMessage'; +import { IterableEmbeddedViewType } from '../../enums'; /** * This function is used to get the media to render for a given embedded view diff --git a/src/embedded/utils/getStyles.ts b/src/embedded/hooks/useEmbeddedView/getStyles.ts similarity index 81% rename from src/embedded/utils/getStyles.ts rename to src/embedded/hooks/useEmbeddedView/getStyles.ts index e233ce96b..102367de5 100644 --- a/src/embedded/utils/getStyles.ts +++ b/src/embedded/hooks/useEmbeddedView/getStyles.ts @@ -1,6 +1,6 @@ -import type { IterableEmbeddedViewConfig } from '../types/IterableEmbeddedViewConfig'; -import { embeddedStyles } from '../contants/embeddedViewDefaults'; -import { IterableEmbeddedViewType } from '../enums'; +import type { IterableEmbeddedViewConfig } from '../../types/IterableEmbeddedViewConfig'; +import { embeddedStyles } from './embeddedViewDefaults'; +import { IterableEmbeddedViewType } from '../../enums'; /** * Get the default style for the embedded view type. @@ -53,26 +53,26 @@ export const getStyles = ( return { backgroundColor: c?.backgroundColor ?? - getDefaultStyle(viewType, embeddedStyles.background), + getDefaultStyle(viewType, embeddedStyles.backgroundColor), borderColor: - c?.borderColor ?? getDefaultStyle(viewType, embeddedStyles.border), + c?.borderColor ?? getDefaultStyle(viewType, embeddedStyles.borderColor), borderWidth: c?.borderWidth ?? getDefaultStyle(viewType, embeddedStyles.borderWidth), borderCornerRadius: c?.borderCornerRadius ?? - getDefaultStyle(viewType, embeddedStyles.borderRadius), + getDefaultStyle(viewType, embeddedStyles.borderCornerRadius), primaryBtnBackgroundColor: c?.primaryBtnBackgroundColor ?? - getDefaultStyle(viewType, embeddedStyles.primaryBtnBackground), + getDefaultStyle(viewType, embeddedStyles.primaryBtnBackgroundColor), primaryBtnTextColor: c?.primaryBtnTextColor ?? - getDefaultStyle(viewType, embeddedStyles.primaryBtnText), + getDefaultStyle(viewType, embeddedStyles.primaryBtnTextColor), secondaryBtnBackgroundColor: c?.secondaryBtnBackgroundColor ?? getDefaultStyle(viewType, embeddedStyles.secondaryBtnBackground), secondaryBtnTextColor: c?.secondaryBtnTextColor ?? - getDefaultStyle(viewType, embeddedStyles.secondaryBtnText), + getDefaultStyle(viewType, embeddedStyles.secondaryBtnTextColor), titleTextColor: c?.titleTextColor ?? getDefaultStyle(viewType, embeddedStyles.titleText), bodyTextColor: diff --git a/src/embedded/hooks/useEmbeddedView/index.ts b/src/embedded/hooks/useEmbeddedView/index.ts new file mode 100644 index 000000000..bf1a77d44 --- /dev/null +++ b/src/embedded/hooks/useEmbeddedView/index.ts @@ -0,0 +1,2 @@ +export * from './useEmbeddedView'; +export { useEmbeddedView as default } from './useEmbeddedView'; diff --git a/src/embedded/hooks/useEmbeddedView.ts b/src/embedded/hooks/useEmbeddedView/useEmbeddedView.ts similarity index 80% rename from src/embedded/hooks/useEmbeddedView.ts rename to src/embedded/hooks/useEmbeddedView/useEmbeddedView.ts index c8effccbd..cfbd9fc4f 100644 --- a/src/embedded/hooks/useEmbeddedView.ts +++ b/src/embedded/hooks/useEmbeddedView/useEmbeddedView.ts @@ -1,10 +1,10 @@ import { useCallback, useMemo } from 'react'; -import { Iterable } from '../../core/classes/Iterable'; -import { IterableEmbeddedViewType } from '../enums'; -import type { IterableEmbeddedComponentProps } from '../types/IterableEmbeddedComponentProps'; -import type { IterableEmbeddedMessageElementsButton } from '../types/IterableEmbeddedMessageElementsButton'; -import { getMedia } from '../utils/getMedia'; -import { getStyles } from '../utils/getStyles'; +import { Iterable } from '../../../core/classes/Iterable'; +import { IterableEmbeddedViewType } from '../../enums'; +import type { IterableEmbeddedComponentProps } from '../../types/IterableEmbeddedComponentProps'; +import type { IterableEmbeddedMessageElementsButton } from '../../types/IterableEmbeddedMessageElementsButton'; +import { getMedia } from './getMedia'; +import { getStyles } from './getStyles'; /** * This hook is used to manage the lifecycle of an embedded view. diff --git a/src/embedded/index.ts b/src/embedded/index.ts index 967e49dbe..107bb59fe 100644 --- a/src/embedded/index.ts +++ b/src/embedded/index.ts @@ -1,4 +1,6 @@ export * from './classes'; export * from './components'; export * from './enums'; +export * from './hooks'; export * from './types'; + diff --git a/src/embedded/utils/index.ts b/src/embedded/utils/index.ts deleted file mode 100644 index d51bbbedf..000000000 --- a/src/embedded/utils/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './getMedia'; -export * from './getStyles'; From 8df31313c38bedf0edb28cdcc484aacd9441d7b7 Mon Sep 17 00:00:00 2001 From: Loren Posen Date: Mon, 23 Feb 2026 18:10:49 -0800 Subject: [PATCH 6/8] test: add unit tests for IterableEmbeddedNotification component --- .../IterableEmbeddedNotification.test.tsx | 347 ++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.test.tsx diff --git a/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.test.tsx b/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.test.tsx new file mode 100644 index 000000000..0ea49c10d --- /dev/null +++ b/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.test.tsx @@ -0,0 +1,347 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { fireEvent, render } from '@testing-library/react-native'; + +import { IterableEmbeddedViewType } from '../../enums/IterableEmbeddedViewType'; +import { useEmbeddedView } from '../../hooks/useEmbeddedView'; +import type { IterableEmbeddedMessage } from '../../types/IterableEmbeddedMessage'; +import type { IterableEmbeddedMessageElementsButton } from '../../types/IterableEmbeddedMessageElementsButton'; +import { IterableEmbeddedNotification } from './IterableEmbeddedNotification'; + +const mockHandleButtonClick = jest.fn(); +const mockHandleMessageClick = jest.fn(); + +jest.mock('../../hooks/useEmbeddedView', () => ({ + useEmbeddedView: jest.fn(), +})); + +const mockUseEmbeddedView = useEmbeddedView as jest.MockedFunction< + typeof useEmbeddedView +>; + +const defaultParsedStyles = { + backgroundColor: '#ffffff', + borderColor: '#E0DEDF', + borderCornerRadius: 8, + borderWidth: 1, + primaryBtnBackgroundColor: '#6A266D', + primaryBtnTextColor: '#ffffff', + secondaryBtnBackgroundColor: 'transparent', + secondaryBtnTextColor: '#79347F', + titleTextColor: '#3D3A3B', + bodyTextColor: '#787174', +}; + +function mockUseEmbeddedViewReturn(overrides: Partial> = {}) { + mockUseEmbeddedView.mockReturnValue({ + parsedStyles: defaultParsedStyles, + handleButtonClick: mockHandleButtonClick, + handleMessageClick: mockHandleMessageClick, + media: { url: null, caption: null, shouldShow: false }, + ...overrides, + }); +} + +describe('IterableEmbeddedNotification', () => { + const baseMessage: IterableEmbeddedMessage = { + metadata: { + messageId: 'msg-1', + campaignId: 1, + placementId: 1, + }, + elements: { + title: 'Notification Title', + body: 'Notification body text.', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseEmbeddedViewReturn(); + }); + + describe('Rendering', () => { + it('should render without crashing', () => { + const { getByText } = render( + + ); + expect(getByText('Notification Title')).toBeTruthy(); + expect(getByText('Notification body text.')).toBeTruthy(); + }); + + it('should render title and body from message.elements', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + title: 'Custom Title', + body: 'Custom body content.', + }, + }; + const { getByText } = render( + + ); + expect(getByText('Custom Title')).toBeTruthy(); + expect(getByText('Custom body content.')).toBeTruthy(); + }); + + it('should apply parsedStyles to container and text', () => { + const customStyles = { + ...defaultParsedStyles, + backgroundColor: '#000000', + titleTextColor: '#ff0000', + bodyTextColor: '#00ff00', + }; + mockUseEmbeddedViewReturn({ parsedStyles: customStyles }); + + const { getByText, UNSAFE_getAllByType } = render( + + ); + + const views = UNSAFE_getAllByType('View' as any); + const styleArray = (s: any) => (Array.isArray(s) ? s : [s]); + const container = views.find( + (v: any) => + v.props.style && + styleArray(v.props.style).some( + (sty: any) => sty && sty.backgroundColor === '#000000' + ) + ); + expect(container).toBeTruthy(); + expect(styleArray(container!.props.style)).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ + backgroundColor: '#000000', + borderColor: customStyles.borderColor, + borderRadius: customStyles.borderCornerRadius, + borderWidth: customStyles.borderWidth, + }), + ]) + ); + + const title = getByText('Notification Title'); + const body = getByText('Notification body text.'); + expect(title.props.style).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ color: '#ff0000' }), + ]) + ); + expect(body.props.style).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ color: '#00ff00' }), + ]) + ); + }); + + it('should not render button container when message has no buttons', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { ...baseMessage.elements, buttons: undefined }, + }; + const { queryByText } = render( + + ); + expect(queryByText('CTA')).toBeNull(); + }); + + it('should not render button container when buttons array is empty', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { ...baseMessage.elements, buttons: [] }, + }; + const { queryByText } = render( + + ); + expect(queryByText('Primary')).toBeNull(); + }); + }); + + describe('Buttons', () => { + const primaryButton: IterableEmbeddedMessageElementsButton = { + id: 'btn-primary', + title: 'Primary', + action: { type: 'openUrl', data: 'https://example.com' }, + }; + const secondaryButton: IterableEmbeddedMessageElementsButton = { + id: 'btn-secondary', + title: 'Secondary', + }; + + it('should render buttons when message has buttons', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + ...baseMessage.elements, + buttons: [primaryButton, secondaryButton], + }, + }; + const { getByText } = render( + + ); + expect(getByText('Primary')).toBeTruthy(); + expect(getByText('Secondary')).toBeTruthy(); + }); + + it('should apply primary and secondary button text colors from parsedStyles', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + ...baseMessage.elements, + buttons: [primaryButton, secondaryButton], + }, + }; + const { getByText } = render( + + ); + + const primaryText = getByText('Primary'); + const secondaryText = getByText('Secondary'); + expect(primaryText.props.style).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ + color: defaultParsedStyles.primaryBtnTextColor, + }), + ]) + ); + expect(secondaryText.props.style).toEqual( + expect.arrayContaining([ + expect.any(Object), + expect.objectContaining({ + color: defaultParsedStyles.secondaryBtnTextColor, + }), + ]) + ); + }); + + it('should call handleButtonClick with correct button when button is pressed', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + ...baseMessage.elements, + buttons: [primaryButton, secondaryButton], + }, + }; + const { getByText } = render( + + ); + + fireEvent.press(getByText('Primary')); + expect(mockHandleButtonClick).toHaveBeenCalledTimes(1); + expect(mockHandleButtonClick).toHaveBeenCalledWith(primaryButton); + + fireEvent.press(getByText('Secondary')); + expect(mockHandleButtonClick).toHaveBeenCalledTimes(2); + expect(mockHandleButtonClick).toHaveBeenLastCalledWith(secondaryButton); + }); + }); + + describe('Message click', () => { + it('should call handleMessageClick when message area is pressed', () => { + const { getByText } = render( + + ); + + fireEvent.press(getByText('Notification Title')); + expect(mockHandleMessageClick).toHaveBeenCalledTimes(1); + + mockHandleMessageClick.mockClear(); + fireEvent.press(getByText('Notification body text.')); + expect(mockHandleMessageClick).toHaveBeenCalledTimes(1); + }); + }); + + describe('useEmbeddedView integration', () => { + it('should call useEmbeddedView with Notification viewType and props', () => { + const config = { backgroundColor: '#abc' } as any; + const onButtonClick = jest.fn(); + const onMessageClick = jest.fn(); + + render( + + ); + + expect(mockUseEmbeddedView).toHaveBeenCalledTimes(1); + expect(mockUseEmbeddedView).toHaveBeenCalledWith( + IterableEmbeddedViewType.Notification, + { + message: baseMessage, + config, + onButtonClick, + onMessageClick, + } + ); + }); + + it('should call useEmbeddedView with default callbacks when not provided', () => { + render(); + + expect(mockUseEmbeddedView).toHaveBeenCalledWith( + IterableEmbeddedViewType.Notification, + expect.objectContaining({ + message: baseMessage, + onButtonClick: expect.any(Function), + onMessageClick: expect.any(Function), + }) + ); + }); + }); + + describe('Edge cases', () => { + it('should handle message with missing elements', () => { + const message: IterableEmbeddedMessage = { + metadata: baseMessage.metadata, + elements: undefined, + }; + const { queryByText } = render( + + ); + expect(queryByText('Notification Title')).toBeNull(); + expect(queryByText('Notification body text.')).toBeNull(); + }); + + it('should handle message with empty title and body without throwing', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { title: '', body: '' }, + }; + const { getAllByText } = render( + + ); + const emptyTextNodes = getAllByText(''); + expect(emptyTextNodes.length).toBeGreaterThanOrEqual(1); + }); + + it('should render multiple buttons and call handleButtonClick with correct button for each', () => { + const message: IterableEmbeddedMessage = { + ...baseMessage, + elements: { + ...baseMessage.elements, + buttons: [ + { id: 'unique-id-1', title: 'First' }, + { id: 'unique-id-2', title: 'Second' }, + ], + }, + }; + const { getByText } = render( + + ); + expect(getByText('First')).toBeTruthy(); + expect(getByText('Second')).toBeTruthy(); + fireEvent.press(getByText('First')); + expect(mockHandleButtonClick).toHaveBeenCalledWith( + expect.objectContaining({ id: 'unique-id-1', title: 'First' }) + ); + fireEvent.press(getByText('Second')); + expect(mockHandleButtonClick).toHaveBeenLastCalledWith( + expect.objectContaining({ id: 'unique-id-2', title: 'Second' }) + ); + }); + }); +}); From 47a283d117ad6366b3f816aad336bf442c2e5c94 Mon Sep 17 00:00:00 2001 From: Loren Posen Date: Mon, 23 Feb 2026 18:13:49 -0800 Subject: [PATCH 7/8] refactor: remove media display logic --- .../components/IterableEmbeddedView.tsx | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/embedded/components/IterableEmbeddedView.tsx b/src/embedded/components/IterableEmbeddedView.tsx index 365d6029d..86844f15f 100644 --- a/src/embedded/components/IterableEmbeddedView.tsx +++ b/src/embedded/components/IterableEmbeddedView.tsx @@ -1,13 +1,10 @@ import { useMemo } from 'react'; -import { View, Text, Image } from 'react-native'; import { IterableEmbeddedViewType } from '../enums/IterableEmbeddedViewType'; - +import type { IterableEmbeddedComponentProps } from '../types/IterableEmbeddedComponentProps'; import { IterableEmbeddedBanner } from './IterableEmbeddedBanner'; import { IterableEmbeddedCard } from './IterableEmbeddedCard'; import { IterableEmbeddedNotification } from './IterableEmbeddedNotification'; -import type { IterableEmbeddedComponentProps } from '../types/IterableEmbeddedComponentProps'; -import { useEmbeddedView } from '../hooks/useEmbeddedView/useEmbeddedView'; /** * The props for the IterableEmbeddedView component. @@ -45,15 +42,5 @@ export const IterableEmbeddedView = ({ } }, [viewType]); - const { media } = useEmbeddedView(viewType, props); - - return Cmp ? ( - - media.url: {media.url} - media.caption: {media.caption} - media.shouldShow: {media.shouldShow ? 'true' : 'false'} - {media.url ? : null} - - - ) : null; + return Cmp ? : null; }; From 7278e0cf58dfdb775c0bda55e393a02dfb22b8d9 Mon Sep 17 00:00:00 2001 From: Loren Posen Date: Tue, 24 Feb 2026 13:50:53 -0800 Subject: [PATCH 8/8] test: add unit tests for useEmbeddedView hook functionality --- .../useEmbeddedView/useEmbeddedView.test.ts | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 src/embedded/hooks/useEmbeddedView/useEmbeddedView.test.ts diff --git a/src/embedded/hooks/useEmbeddedView/useEmbeddedView.test.ts b/src/embedded/hooks/useEmbeddedView/useEmbeddedView.test.ts new file mode 100644 index 000000000..4fd06e6c2 --- /dev/null +++ b/src/embedded/hooks/useEmbeddedView/useEmbeddedView.test.ts @@ -0,0 +1,234 @@ +import { renderHook, act } from '@testing-library/react-native'; + +import { Iterable } from '../../../core/classes/Iterable'; +import { IterableAction } from '../../../core/classes/IterableAction'; +import { IterableEmbeddedViewType } from '../../enums'; +import type { IterableEmbeddedMessage } from '../../types/IterableEmbeddedMessage'; +import type { IterableEmbeddedMessageElementsButton } from '../../types/IterableEmbeddedMessageElementsButton'; +import { useEmbeddedView } from './useEmbeddedView'; +import { getMedia } from './getMedia'; +import { getStyles } from './getStyles'; + +jest.mock('./getMedia'); +jest.mock('./getStyles'); + +const mockGetMedia = getMedia as jest.MockedFunction; +const mockGetStyles = getStyles as jest.MockedFunction; + +const minimalMessage: IterableEmbeddedMessage = { + metadata: { messageId: 'msg-1', placementId: 1 }, +}; + +const defaultMedia = { url: null, caption: null, shouldShow: false }; +const defaultStyles = { + backgroundColor: '#ffffff', + borderColor: '#E0DEDF', + borderWidth: 1, + borderCornerRadius: 10, + primaryBtnBackgroundColor: '#6A266D', + primaryBtnTextColor: '#ffffff', + secondaryBtnBackgroundColor: 'transparent', + secondaryBtnTextColor: '#ffffff', + titleTextColor: '#000000', + bodyTextColor: '#000000', +}; + +describe('useEmbeddedView', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetMedia.mockReturnValue(defaultMedia); + mockGetStyles.mockReturnValue(defaultStyles); + jest.spyOn(Iterable.embeddedManager, 'handleClick').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('return shape', () => { + it('returns handleButtonClick, handleMessageClick, media, and parsedStyles', () => { + const { result } = renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Notification, { message: minimalMessage }) + ); + + expect(result.current).toHaveProperty('handleButtonClick'); + expect(result.current).toHaveProperty('handleMessageClick'); + expect(result.current).toHaveProperty('media'); + expect(result.current).toHaveProperty('parsedStyles'); + expect(typeof result.current.handleButtonClick).toBe('function'); + expect(typeof result.current.handleMessageClick).toBe('function'); + }); + }); + + describe('getMedia / getStyles delegation', () => { + it('calls getMedia with viewType and message', () => { + renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Card, { message: minimalMessage }) + ); + + expect(mockGetMedia).toHaveBeenCalledWith(IterableEmbeddedViewType.Card, minimalMessage); + }); + + it('calls getStyles with viewType and config', () => { + const config = { backgroundColor: '#000000' }; + renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Notification, { + message: minimalMessage, + config, + }) + ); + + expect(mockGetStyles).toHaveBeenCalledWith(IterableEmbeddedViewType.Notification, config); + }); + + it('returns media from getMedia and parsedStyles from getStyles', () => { + const customMedia = { url: 'https://example.com/img.png', caption: 'Cap', shouldShow: true }; + const customStyles = { ...defaultStyles, backgroundColor: '#111111' }; + mockGetMedia.mockReturnValue(customMedia); + mockGetStyles.mockReturnValue(customStyles); + + const { result } = renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Banner, { message: minimalMessage }) + ); + + expect(result.current.media).toEqual(customMedia); + expect(result.current.parsedStyles).toEqual(customStyles); + }); + }); + + describe('handleButtonClick', () => { + it('calls onButtonClick with the button', () => { + const onButtonClick = jest.fn(); + const button: IterableEmbeddedMessageElementsButton = { + id: 'btn-1', + title: 'Click me', + action: null, + }; + + const { result } = renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Notification, { + message: minimalMessage, + onButtonClick, + }) + ); + + act(() => { + result.current.handleButtonClick(button); + }); + + expect(onButtonClick).toHaveBeenCalledTimes(1); + expect(onButtonClick).toHaveBeenCalledWith(button); + }); + + it('calls Iterable.embeddedManager.handleClick with message, button.id, and button.action', () => { + const action = new IterableAction('openUrl', 'https://example.com'); + const button: IterableEmbeddedMessageElementsButton = { + id: 'btn-2', + title: 'Link', + action, + }; + + const { result } = renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Notification, { + message: minimalMessage, + }) + ); + + act(() => { + result.current.handleButtonClick(button); + }); + + expect(Iterable.embeddedManager.handleClick).toHaveBeenCalledWith( + minimalMessage, + 'btn-2', + action + ); + }); + }); + + describe('handleMessageClick', () => { + it('calls onMessageClick', () => { + const onMessageClick = jest.fn(); + + const { result } = renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Notification, { + message: minimalMessage, + onMessageClick, + }) + ); + + act(() => { + result.current.handleMessageClick(); + }); + + expect(onMessageClick).toHaveBeenCalledTimes(1); + }); + + it('calls Iterable.embeddedManager.handleClick with message, null, and message.elements?.defaultAction', () => { + const defaultAction = new IterableAction('openUrl', 'https://iterable.com'); + const message: IterableEmbeddedMessage = { + ...minimalMessage, + elements: { defaultAction }, + }; + + const { result } = renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Notification, { message }) + ); + + act(() => { + result.current.handleMessageClick(); + }); + + expect(Iterable.embeddedManager.handleClick).toHaveBeenCalledWith( + message, + null, + defaultAction + ); + }); + + it('calls embeddedManager.handleClick with undefined defaultAction when message has no elements', () => { + const { result } = renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Notification, { message: minimalMessage }) + ); + + act(() => { + result.current.handleMessageClick(); + }); + + expect(Iterable.embeddedManager.handleClick).toHaveBeenCalledWith( + minimalMessage, + null, + undefined + ); + }); + }); + + describe('default callbacks', () => { + it('does not throw when handleButtonClick is invoked without provided onButtonClick', () => { + const button: IterableEmbeddedMessageElementsButton = { id: 'b', title: null, action: null }; + const { result } = renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Notification, { message: minimalMessage }) + ); + + expect(() => { + act(() => { + result.current.handleButtonClick(button); + }); + }).not.toThrow(); + }); + + it('does not throw when handleMessageClick is invoked without provided onMessageClick', () => { + const { result } = renderHook(() => + useEmbeddedView(IterableEmbeddedViewType.Notification, { message: minimalMessage }) + ); + + expect(() => { + act(() => { + result.current.handleMessageClick(); + }); + }).not.toThrow(); + }); + }); + + // memoization behavior (useMemo) is indirectly exercised above via getMedia/getStyles calls +});