From 1e493476596d03a60a523e227251bdb03264b434 Mon Sep 17 00:00:00 2001 From: Loren Posen Date: Mon, 13 Oct 2025 17:15:45 -0700 Subject: [PATCH] feat: introduce IterableEmbeddedNotification component with styling and visibility management --- example/src/components/Embedded/Embedded.tsx | 2 +- src/core/hooks/index.ts | 1 + src/core/hooks/useComponentVisibility.ts | 156 ++++++++++++++++++ .../IterableEmbeddedNotification.tsx | 22 --- .../IterableEmbeddedNotification.styles.ts | 54 ++++++ .../IterableEmbeddedNotification.tsx | 96 +++++++++++ .../IterableEmbeddedNotification/index.ts | 1 + .../components/IterableEmbeddedView.tsx | 2 +- src/embedded/components/index.ts | 2 +- .../constants/embeddedViewDefaults.ts | 85 ++++++++++ src/embedded/constants/index.ts | 1 + src/embedded/hooks/index.ts | 1 + src/embedded/hooks/useEmbeddedView.ts | 91 ++++++++++ .../types/IterableEmbeddedComponentProps.ts | 1 + src/embedded/utils/getDefaultStyle.ts | 19 +++ src/embedded/utils/getMedia.ts | 15 ++ src/embedded/utils/getStyles.ts | 38 +++++ src/embedded/utils/index.ts | 0 18 files changed, 562 insertions(+), 25 deletions(-) create mode 100644 src/core/hooks/useComponentVisibility.ts delete mode 100644 src/embedded/components/IterableEmbeddedNotification.tsx create mode 100644 src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.styles.ts create mode 100644 src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.tsx create mode 100644 src/embedded/components/IterableEmbeddedNotification/index.ts create mode 100644 src/embedded/constants/embeddedViewDefaults.ts create mode 100644 src/embedded/constants/index.ts create mode 100644 src/embedded/hooks/index.ts create mode 100644 src/embedded/hooks/useEmbeddedView.ts create mode 100644 src/embedded/utils/getDefaultStyle.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/example/src/components/Embedded/Embedded.tsx b/example/src/components/Embedded/Embedded.tsx index 1b0f8ce53..e90c4de43 100644 --- a/example/src/components/Embedded/Embedded.tsx +++ b/example/src/components/Embedded/Embedded.tsx @@ -16,7 +16,7 @@ export const Embedded = () => { IterableEmbeddedMessage[] >([]); const [selectedViewType, setSelectedViewType] = - useState(IterableEmbeddedViewType.Banner); + useState(IterableEmbeddedViewType.Notification); const syncEmbeddedMessages = useCallback(() => { Iterable.embeddedManager.syncMessages(); diff --git a/src/core/hooks/index.ts b/src/core/hooks/index.ts index 35d77007a..18db4d045 100644 --- a/src/core/hooks/index.ts +++ b/src/core/hooks/index.ts @@ -1,2 +1,3 @@ export * from './useAppStateListener'; export * from './useDeviceOrientation'; +export * from './useComponentVisibility'; 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/embedded/components/IterableEmbeddedNotification.tsx b/src/embedded/components/IterableEmbeddedNotification.tsx deleted file mode 100644 index 686ea01e6..000000000 --- a/src/embedded/components/IterableEmbeddedNotification.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { View, Text } from 'react-native'; - -import type { IterableEmbeddedComponentProps } from '../types/IterableEmbeddedComponentProps'; - -export const IterableEmbeddedNotification = ({ - config, - message, - onButtonClick = () => {}, -}: IterableEmbeddedComponentProps) => { - console.log(`🚀 > IterableEmbeddedNotification > config:`, config); - console.log(`🚀 > IterableEmbeddedNotification > message:`, message); - console.log( - `🚀 > IterableEmbeddedNotification > onButtonClick:`, - onButtonClick - ); - - return ( - - IterableEmbeddedNotification - - ); -}; 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..f0909cfc5 --- /dev/null +++ b/src/embedded/components/IterableEmbeddedNotification/IterableEmbeddedNotification.tsx @@ -0,0 +1,96 @@ +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) => { + const { parsedStyles, handleButtonClick, handleMessageClick } = + useEmbeddedView(IterableEmbeddedViewType.Notification, { + message, + config, + onButtonClick, + onMessageClick, + }); + + const buttons = message.elements?.buttons ?? []; + + return ( + 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} + + + ); + })} + + )} + + + ); +}; 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.tsx b/src/embedded/components/IterableEmbeddedView.tsx index fa76f584f..770e4a777 100644 --- a/src/embedded/components/IterableEmbeddedView.tsx +++ b/src/embedded/components/IterableEmbeddedView.tsx @@ -4,7 +4,7 @@ import { IterableEmbeddedViewType } from '../enums/IterableEmbeddedViewType'; import { IterableEmbeddedBanner } from './IterableEmbeddedBanner'; import { IterableEmbeddedCard } from './IterableEmbeddedCard'; -import { IterableEmbeddedNotification } from './IterableEmbeddedNotification'; +import { IterableEmbeddedNotification } from './IterableEmbeddedNotification/IterableEmbeddedNotification'; import type { IterableEmbeddedComponentProps } from '../types/IterableEmbeddedComponentProps'; /** diff --git a/src/embedded/components/index.ts b/src/embedded/components/index.ts index 15af78aba..edf3ab6a0 100644 --- a/src/embedded/components/index.ts +++ b/src/embedded/components/index.ts @@ -1,4 +1,4 @@ export * from './IterableEmbeddedBanner'; export * from './IterableEmbeddedCard'; -export * from './IterableEmbeddedNotification'; +export * from './IterableEmbeddedNotification/IterableEmbeddedNotification'; export * from './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/constants/index.ts b/src/embedded/constants/index.ts new file mode 100644 index 000000000..4324689be --- /dev/null +++ b/src/embedded/constants/index.ts @@ -0,0 +1 @@ +export * from './embeddedViewDefaults'; 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..982f90022 --- /dev/null +++ b/src/embedded/hooks/useEmbeddedView.ts @@ -0,0 +1,91 @@ +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/IterableEmbeddedComponentProps'; +import { getMedia } from '../utils/getMedia'; +import { getStyles } from '../utils/getStyles'; + +export const useEmbeddedView = ( + viewType: IterableEmbeddedViewType, + { + message, + config, + onButtonClick = () => {}, + onMessageClick = () => {}, + }: 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, button.action); + }, + [onButtonClick, message] + ); + + const handleMessageClick = useCallback(() => { + onMessageClick(); + Iterable.embeddedManager.handleClick( + message, + null, + message.elements?.defaultAction + ); + }, [message, onMessageClick]); + + 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(() => { + if (isVisible) { + Iterable.embeddedManager.startImpression( + message.metadata.messageId, + message.metadata.placementId + ); + } else { + Iterable.embeddedManager.pauseImpression(message.metadata.messageId); + } + }, [isVisible, message.metadata.messageId, message.metadata.placementId]); + + return { + componentRef, + handleButtonClick, + handleLayout, + handleMessageClick, + media, + parsedStyles, + }; +}; diff --git a/src/embedded/types/IterableEmbeddedComponentProps.ts b/src/embedded/types/IterableEmbeddedComponentProps.ts index 9f2b17670..f40444cb1 100644 --- a/src/embedded/types/IterableEmbeddedComponentProps.ts +++ b/src/embedded/types/IterableEmbeddedComponentProps.ts @@ -6,4 +6,5 @@ export interface IterableEmbeddedComponentProps { message: IterableEmbeddedMessage; config?: IterableEmbeddedViewConfig | null; onButtonClick?: (button: IterableEmbeddedMessageElementsButton) => void; + onMessageClick?: () => void; } 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/index.ts b/src/embedded/utils/index.ts new file mode 100644 index 000000000..e69de29bb