-
Notifications
You must be signed in to change notification settings - Fork 40
[MOB-12271] introduce IterableEmbeddedNotification component with styling and visibility management #749
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: loren/embedded/MOB-12270-new-embedded-view-component
Are you sure you want to change the base?
Conversation
…nd visibility management
|
Diff Coverage: The code coverage on the diff in this pull request is 15.6%. Total Coverage: This PR will decrease coverage by 5.3%. File Coverage Changes
🛟 Help
This is from Qlty Cloud, the successor to Code Climate Quality. Learn more. |
4 new issues
This is from Qlty Cloud, the successor to Code Climate Quality. Learn more. |
| 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<View>(null); | ||
| const [layout, setLayout] = useState<LayoutInfo>({ | ||
| x: 0, | ||
| y: 0, | ||
| width: 0, | ||
| height: 0, | ||
| }); | ||
| const intervalRef = useRef<NodeJS.Timeout | null>(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<boolean> => { | ||
| if (!componentRef.current || layout.width === 0 || layout.height === 0) { | ||
| return Promise.resolve(false); | ||
| } | ||
|
|
||
| return new Promise<boolean>((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, | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| 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 ( | ||
| <Pressable onPress={() => handleMessageClick()}> | ||
| <View | ||
| style={[ | ||
| styles.container, | ||
| { | ||
| backgroundColor: parsedStyles.backgroundColor, | ||
| borderColor: parsedStyles.borderColor, | ||
| borderRadius: parsedStyles.borderCornerRadius, | ||
| borderWidth: parsedStyles.borderWidth, | ||
| } as ViewStyle, | ||
| ]} | ||
| > | ||
| {} | ||
| <View style={styles.bodyContainer}> | ||
| <Text | ||
| style={[ | ||
| styles.title, | ||
| { color: parsedStyles.titleTextColor } as TextStyle, | ||
| ]} | ||
| > | ||
| {message.elements?.title} | ||
| </Text> | ||
| <Text | ||
| style={[ | ||
| styles.body, | ||
| { color: parsedStyles.bodyTextColor } as TextStyle, | ||
| ]} | ||
| > | ||
| {message.elements?.body} | ||
| </Text> | ||
| </View> | ||
| {buttons.length > 0 && ( | ||
| <View style={styles.buttonContainer}> | ||
| {buttons.map((button, index) => { | ||
| const backgroundColor = | ||
| index === 0 | ||
| ? parsedStyles.primaryBtnBackgroundColor | ||
| : parsedStyles.secondaryBtnBackgroundColor; | ||
| const textColor = | ||
| index === 0 | ||
| ? parsedStyles.primaryBtnTextColor | ||
| : parsedStyles.secondaryBtnTextColor; | ||
| return ( | ||
| <TouchableOpacity | ||
| style={[styles.button, { backgroundColor } as ViewStyle]} | ||
| onPress={() => handleButtonClick(button)} | ||
| key={button.id} | ||
| > | ||
| <Text | ||
| style={[ | ||
| styles.buttonText, | ||
| { color: textColor } as TextStyle, | ||
| ]} | ||
| > | ||
| {button.title} | ||
| </Text> | ||
| </TouchableOpacity> | ||
| ); | ||
| })} | ||
| </View> | ||
| )} | ||
| </View> | ||
| </Pressable> | ||
| ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| 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, | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔹 JIRA Ticket(s) if any
✏️ Description